[
  {
    "path": ".buildkite/linux/docker/Dockerfile",
    "content": "FROM ubuntu:22.04\n\nARG DEBIAN_FRONTEND=\"noninteractive\"\n\nRUN useradd -d /state -m -u 998 user\n\nRUN apt-get update && apt install --yes gnupg ca-certificates && \\\n    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 32A37959C2FA5C3C99EFBC32A79206696452D198 \\\n    && echo \"deb https://apt.buildkite.com/buildkite-agent stable main\" > /etc/apt/sources.list.d/buildkite-agent.list \\\n    && apt-get update \\\n    && apt-get install --yes --no-install-recommends \\\n    autoconf \\\n    bash \\\n    buildkite-agent \\\n    ca-certificates \\\n    curl \\\n    findutils \\\n    g++ \\\n    gcc \\\n    git \\\n    grep \\\n    libdbus-1-3 \\\n    libegl1 \\\n    libfontconfig1 \\\n    libgl1 \\\n    libgstreamer-gl1.0-0 \\\n    libgstreamer-plugins-base1.0 \\\n    libgstreamer1.0-0 \\\n    libnss3 \\\n    libpulse-mainloop-glib0 \\\n    libpulse-mainloop-glib0 \\    \n    libssl-dev \\\n    libxcomposite1 \\\n    libxcursor1 \\\n    libxdamage1 \\\n    libxi6 \\\n    libxkbcommon-x11-0 \\\n    libxkbcommon0 \\\n    libxkbfile1\t\\\n    libxrandr2 \\\n    libxrender1 \\\n    libxtst6 \\\n    make \\\n    pkg-config \\\n    portaudio19-dev \\\n    python3-dev \\\n    rsync \\\n    unzip \\\n    zstd \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN mkdir -p /etc/buildkite-agent/hooks && chown -R user /etc/buildkite-agent\n\nCOPY buildkite.cfg /etc/buildkite-agent/buildkite-agent.cfg\nCOPY environment /etc/buildkite-agent/hooks/environment\n\nRUN mkdir /state/rust && chown user /state/rust\n\nUSER user\n\nENV CARGO_HOME=/state/rust/cargo\nENV RUSTUP_HOME=/state/rust/rustup\nRUN curl https://sh.rustup.rs -sSf | sh -s -- -y --no-modify-path --default-toolchain none\n\nWORKDIR /code/buildkite\nENTRYPOINT [\"/usr/bin/buildkite-agent\", \"start\"]\n"
  },
  {
    "path": ".buildkite/linux/docker/build.sh",
    "content": "#!/bin/bash\n# builds an 'anki-[amd|arm]' image for the current platform\n#\n# for a cross-compile on recent Docker:\n#   docker buildx create --use\n#   docker run --privileged --rm tonistiigi/binfmt --install amd64\n#   docker buildx build --platform linux/amd64 --tag anki-amd64 . --load\n\n. common.inc\n\nDOCKER_BUILDKIT=1 docker build --tag anki-${platform} .\n"
  },
  {
    "path": ".buildkite/linux/docker/buildkite.cfg",
    "content": "name=\"lin-ci\"\ntags=\"queue=lin-ci\"\nbuild-path=\"/state/build\"\nhooks-path=\"/etc/buildkite-agent/hooks\"\nno-plugins=true\nno-local-hooks=true\nno-git-submodules=true\n"
  },
  {
    "path": ".buildkite/linux/docker/common.inc",
    "content": "#!/bin/bash\n\nset -e\n\nif [[ \"$(uname -m)\" == \"x86_64\" ]]; then\n    platform=\"amd\"\nelse\n    platform=\"arm\"\nfi\n"
  },
  {
    "path": ".buildkite/linux/docker/environment",
    "content": "#!/bin/bash\n\nif [[ \"${BUILDKITE_COMMAND}\" != \".buildkite/linux/entrypoint\" &&\n    \"${BUILDKITE_COMMAND}\" != \".buildkite/linux/release-entrypoint\" ]]; then\n  echo \"Command not allowed: ${BUILDKITE_COMMAND}\"\n  exit 1\nfi\n"
  },
  {
    "path": ".buildkite/linux/docker/run.sh",
    "content": "#!/bin/bash\n# - use './run.sh' to run in the foreground\n# - use './run.sh serve' to daemonize.\n\nset -e\n\n. common.inc\n\nif [ \"$1\" = \"serve\" ]; then\n    extra_args=\"-d --restart always\"\nelse\n    extra_args=\"-it\"\nfi\n\nname=anki-${platform}\n\n# Stop and remove the existing container if it exists.\n# This doesn't delete the associated volume.\nif docker container inspect $name > /dev/null 2>&1; then\n    docker stop $name || true\n    docker container rm $name\nfi\n\ndocker run $extra_args \\\n    --name $name \\\n    -v ${name}-state:/state \\\n    -e BUILDKITE_AGENT_TOKEN \\\n    -e BUILDKITE_AGENT_TAGS \\\n    $name\n"
  },
  {
    "path": ".buildkite/linux/entrypoint",
    "content": "#!/bin/bash\n\nset -e\n\nexport PATH=\"$PATH:/state/rust/cargo/bin\"\nexport BUILD_ROOT=/state/build\nexport ONLINE_TESTS=1\n\necho \"--- Install n2\"\n./tools/install-n2\n\necho \"+++ Building and testing\"\nln -sf out/node_modules .\n\nif [ \"$CLEAR_RUST\" = \"1\" ]; then\n    rm -rf $BUILD_ROOT/rust\nfi\n\nrm -f out/build.ninja\n./ninja pylib qt check\n\necho \"--- Ensure libs importable\"\nSKIP_RUN=1 ./run\n\necho \"--- Check Rust libs\"\ncargo install cargo-deny@0.19.0\ncargo deny check\n\necho \"--- Cleanup\"\nrm -rf /tmp/* || true\n"
  },
  {
    "path": ".buildkite/linux/release-entrypoint",
    "content": "#!/bin/bash\n\nset -e\n\nexport PATH=\"$PATH:/state/rust/cargo/bin\"\nexport BUILD_ROOT=/state/build\nexport RELEASE=2\nln -sf out/node_modules .\n\necho \"--- Install n2\"\n./tools/install-n2\n\necho \"+++ Building\"\nif [ $(uname -m) = \"aarch64\" ]; then\n    export PYTHONPATH=/usr/lib/python3/dist-packages\n    ./ninja wheels:anki\nelse\n    ./ninja bundle\nfi\n"
  },
  {
    "path": ".buildkite/mac/entrypoint",
    "content": "#!/bin/bash\n\nset -e\n\nSTATE=$(pwd)/../state/anki-ci\nmkdir -p $STATE\n\necho \"+++ Building and testing\"\nln -sf out/node_modules .\nSKIP_RUNNER_BUILD=0 BUILD_ROOT=$STATE/build ./ninja pylib qt wheels check\n"
  },
  {
    "path": ".buildkite/windows/entrypoint.bat",
    "content": "set PATH=c:\\cargo\\bin;%PATH%\n\necho +++ Building and testing\n\nif exist \\buildkite\\state\\out (\n    move \\buildkite\\state\\out .\n)\nif exist \\buildkite\\state\\node_modules (\n    move \\buildkite\\state\\node_modules .\n)\n\ncall tools\\ninja build pylib qt check || exit /b 1\n\necho --- Cleanup\nmove out \\buildkite\\state\\\nmove node_modules \\buildkite\\state\\\n"
  },
  {
    "path": ".cargo/config.toml",
    "content": "[env]\nSTRINGS_PY = { value = \"out/pylib/anki/_fluent.py\", relative = true }\nSTRINGS_TS = { value = \"out/ts/lib/generated/ftl.ts\", relative = true }\nDESCRIPTORS_BIN = { value = \"out/rslib/proto/descriptors.bin\", relative = true }\n# build script will append .exe if necessary\nPROTOC = { value = \"out/extracted/protoc/bin/protoc\", relative = true }\nPYO3_NO_PYTHON = \"1\"\nMACOSX_DEPLOYMENT_TARGET = \"11\"\nPYTHONDONTWRITEBYTECODE = \"1\" # prevent junk files on Windows\n\n[term]\ncolor = \"always\"\n\n[target.'cfg(all(target_env = \"msvc\", target_os = \"windows\"))']\nrustflags = [\"-C\", \"target-feature=+crt-static\"]\n"
  },
  {
    "path": ".config/nextest.toml",
    "content": "[store]\ndir = \"out/tests/nextest\"\n"
  },
  {
    "path": ".cursor/rules/building.md",
    "content": "- To build and check the project, use ./check in the root folder (or check.bat on Windows)\n- This will format files, then run lints and unit tests.\n"
  },
  {
    "path": ".cursor/rules/i18n.md",
    "content": "- We use the fluent system+code generation for translation.\n- New strings should be added to rslib/core/. Ask for the appropriate file if you're not sure.\n- Assuming a string addons-you-have-count has been added to addons.ftl, that string is accessible in our different languages as follows:\n  - Python: from aqt.utils import tr; msg = tr.addons_you_have_count(count=3)\n  - TypeScript: import * as tr from \"@generated/ftl\"; tr.addonsYouHaveCount({count: 3})\n  - Rust: collection.tr.addons_you_have_count(3)\n- In Qt .ui files, strings that are marked as translatable will automatically use the registered ftl strings. So a QLabel with a title 'addons_you_have_count' that is marked as translatable will automatically use the translation defined in our addons.ftl file.\n"
  },
  {
    "path": ".deny.toml",
    "content": "# all-features = true\n# features = []\n\n[advisories]\ndb-path = \"~/.cargo/advisory-db\"\ndb-urls = [\"https://github.com/rustsec/advisory-db\"]\nignore = [\n  # burn depends on an unmaintained package 'paste'\n  \"RUSTSEC-2024-0436\",\n  # bincode is unmaintained (via burn). Alternatives: postcard, bitcode, rkyv, wincode\n  \"RUSTSEC-2025-0141\",\n  # rustls-pemfile is unmaintained. Alternative: use rustls-pki-types directly (PemObject trait)\n  \"RUSTSEC-2025-0134\",\n  # unic-* crates are unmaintained (used for Unicode category detection).\n  # Alternative: icu_properties\n  \"RUSTSEC-2025-0081\", # unic-char-property\n  \"RUSTSEC-2025-0075\", # unic-char-range (or use native Rust char ranges since 1.45.0)\n  \"RUSTSEC-2025-0080\", # unic-common\n  \"RUSTSEC-2025-0094\", # unic-ucd-category\n  \"RUSTSEC-2025-0098\", # unic-ucd-version\n]\n\n[licenses]\nallow = [\n  \"MIT\",\n  \"Apache-2.0\",\n  \"Apache-2.0 WITH LLVM-exception\",\n  \"CDLA-Permissive-2.0\",\n  \"ISC\",\n  \"MPL-2.0\",\n  \"BSD-2-Clause\",\n  \"BSD-3-Clause\",\n  \"CC0-1.0\",\n  \"Unlicense\",\n  \"Zlib\",\n  \"Unicode-3.0\",\n]\nconfidence-threshold = 0.8\n# eg { allow = [\"Zlib\"], name = \"adler32\", version = \"*\" },\nexceptions = []\n\n[[licenses.clarify]]\nname = \"ring\"\nversion = \"*\"\nexpression = \"MIT AND ISC AND OpenSSL\"\nlicense-files = [\n  { path = \"LICENSE\", hash = 0xbd0eed23 },\n]\n\n[licenses.private]\nignore = true\n\n[sources]\nunknown-registry = \"warn\"\nunknown-git = \"warn\"\nallow-registry = [\"https://github.com/rust-lang/crates.io-index\"]\n\n[sources.allow-org]\ngithub = [\"ankitects\"]\n\n[bans]\nmultiple-versions = \"allow\"\nwildcards = \"allow\"\nhighlight = \"all\"\nworkspace-default-features = \"allow\"\nexternal-default-features = \"allow\"\n# eg { name = \"ansi_term\", version = \"=0.11.0\" },\nallow = []\ndeny = []\n# Certain crates/versions that will be skipped when doing duplicate detection.\nskip = []\n# Similarly to `skip` allows you to skip certain crates during duplicate\n# detection. Unlike skip, it also includes the entire tree of transitive\n# dependencies starting at the specified crate, up to a certain depth, which is\n# by default infinite.\n# eg { name = \"ansi_term\", version = \"=0.11.0\", depth = 20 },\nskip-tree = []\n"
  },
  {
    "path": ".dockerignore",
    "content": "node_modules/\ntarget/\nout/"
  },
  {
    "path": ".dprint.json",
    "content": "{\n    \"typescript\": {\n        \"indentWidth\": 4,\n        \"useBraces\": \"always\"\n    },\n    \"json\": {\n        \"indentWidth\": 4\n    },\n    \"markdown\": {},\n    \"toml\": {},\n    \"includes\": [\"**/*.{ts,tsx,js,jsx,cjs,mjs,json,md,toml,svelte,scss}\"],\n    \"excludes\": [\n        \".vscode\",\n        \"**/node_modules\",\n        \"out/**\",\n        \"**/*-lock.json\",\n        \"qt/aqt/data/web/js/vendor/*.js\",\n        \"ftl/qt-repo\",\n        \"ftl/core-repo\",\n        \"ftl/usage\",\n        \"licenses.json\",\n        \".dmypy.json\",\n        \"target\",\n        \".mypy_cache\",\n        \"extra\",\n        \"ts/.svelte-kit\",\n        \"ts/vite.config.ts.timestamp*\"\n    ],\n    \"plugins\": [\n        \"https://plugins.dprint.dev/typescript-0.91.6.wasm\",\n        \"https://plugins.dprint.dev/json-0.19.3.wasm\",\n        \"https://plugins.dprint.dev/markdown-0.17.6.wasm\",\n        \"https://plugins.dprint.dev/toml-0.6.2.wasm\",\n        \"https://plugins.dprint.dev/disrupted/css-0.2.3.wasm\"\n    ]\n}\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n    root: true,\n    extends: [\"eslint:recommended\", \"plugin:compat/recommended\", \"plugin:svelte/recommended\"],\n    parser: \"@typescript-eslint/parser\",\n    parserOptions: {\n        extraFileExtensions: [\".svelte\"],\n    },\n    plugins: [\n        \"import\",\n        \"@typescript-eslint\",\n        \"@typescript-eslint/eslint-plugin\",\n    ],\n    rules: {\n        \"@typescript-eslint/ban-ts-comment\": \"warn\",\n        \"@typescript-eslint/no-unused-vars\": [\n            \"warn\",\n            { argsIgnorePattern: \"^_\", varsIgnorePattern: \"^_\" },\n        ],\n        \"no-unused-vars\": [\"warn\", { argsIgnorePattern: \"^_\" }],\n        \"import/newline-after-import\": \"warn\",\n        \"import/no-useless-path-segments\": \"warn\",\n        \"prefer-const\": \"warn\",\n        \"no-nested-ternary\": \"warn\",\n        \"curly\": \"error\",\n        \"@typescript-eslint/consistent-type-imports\": \"error\",\n    },\n    overrides: [\n        {\n            files: \"**/*.ts\",\n            extends: [\n                \"plugin:@typescript-eslint/eslint-recommended\",\n                \"plugin:@typescript-eslint/recommended\",\n            ],\n            rules: {\n                \"@typescript-eslint/no-non-null-assertion\": \"off\",\n                \"@typescript-eslint/no-explicit-any\": \"off\",\n            },\n        },\n        {\n            files: [\"*.svelte\"],\n            parser: \"svelte-eslint-parser\",\n            parserOptions: {\n                parser: \"@typescript-eslint/parser\",\n            },\n            rules: {\n                \"svelte/no-at-html-tags\": \"off\",\n                \"svelte/valid-compile\": [\"error\", { \"ignoreWarnings\": true }],\n                \"@typescript-eslint/no-explicit-any\": \"off\",\n            },\n        },\n    ],\n    env: { browser: true, es2020: true },\n    ignorePatterns: [\"backend_proto.d.ts\", \"*.svelte.d.ts\", \"vendor\", \"extra/*\", \"vite.config.ts\", \"hooks.client.js\"],\n    globals: {\n        globalThis: false,\n        NodeListOf: false,\n        $$Generic: \"readonly\",\n    },\n};\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.ftl -linguist-detectable\ncargo/remote/* linguist-vendored\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.md",
    "content": "---\nname: Developer Tasks\nabout: For bug reports, suggestions and support, please see the options below.\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n- Have a question or feature suggestion?\n- Problems building/running on your system?\n- Not 100% sure you've found a bug?\n\nIf so, please post on https://forums.ankiweb.net/ instead. This issue tracker is\nintended primarily to track development tasks, and it is easier to provide support\nover on the forums. Please make sure you read the following pages before\nyou post there:\n\n- https://faqs.ankiweb.net/when-problems-occur.html\n- https://faqs.ankiweb.net/getting-help.html\n\nIf you post questions, suggestions, or vague bug reports here, please do not be\noffended if we close your ticket without replying. If in doubt, please post on\nhttps://forums.ankiweb.net/ instead.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n    - name: Bug Reports\n      url: https://forums.ankiweb.net\n      about: This issue tracker is for developers. Please report any bugs you encounter over on the user forum, and they will be triaged there.\n\n    - name: Questions/Support\n      url: https://forums.ankiweb.net\n      about: If you have a question or need support, please post on the user forum.\n\n    - name: Feature Requests/Suggestions\n      url: https://forums.ankiweb.net/c/suggestions/17\n      about: Please post suggestions and feature requests on our user forum.\n"
  },
  {
    "path": ".github/actions/setup-anki/action.yml",
    "content": "name: Setup Anki Build Environment\ndescription: Install system dependencies, Rust toolchain, uv, and n2\n\nruns:\n  using: composite\n  steps:\n    - name: Install Linux system dependencies\n      if: runner.os == 'Linux'\n      shell: bash\n      run: |\n        sudo apt-get update\n        sudo apt-get install --yes --no-install-recommends \\\n          autoconf \\\n          bash \\\n          curl \\\n          findutils \\\n          g++ \\\n          gcc \\\n          git \\\n          grep \\\n          libdbus-1-3 \\\n          libegl1 \\\n          libfontconfig1 \\\n          libgl1 \\\n          libgstreamer-gl1.0-0 \\\n          libgstreamer-plugins-base1.0 \\\n          libgstreamer1.0-0 \\\n          libnss3 \\\n          libpulse-mainloop-glib0 \\\n          libssl-dev \\\n          libxcomposite1 \\\n          libxcursor1 \\\n          libxdamage1 \\\n          libxi6 \\\n          libxkbcommon-x11-0 \\\n          libxkbcommon0 \\\n          libxkbfile1 \\\n          libxrandr2 \\\n          libxrender1 \\\n          libxtst6 \\\n          make \\\n          pkg-config \\\n          portaudio19-dev \\\n          python3-dev \\\n          rsync \\\n          unzip \\\n          zstd\n\n    - name: Install rsync (Windows)\n      if: runner.os == 'Windows'\n      shell: bash\n      run: |\n        /c/msys64/usr/bin/pacman.exe -Sy --noconfirm rsync\n        echo \"C:\\msys64\\usr\\bin\" >> \"$GITHUB_PATH\"\n\n    # Reads toolchain version from rust-toolchain.toml automatically.\n    - name: Install Rust toolchain\n      uses: actions-rust-lang/setup-rust-toolchain@v1\n      with:\n        components: clippy\n        cache: false # ci.yml manages its own cargo cache\n        rustflags: \"\" # don't inject -D warnings; the build system handles this\n\n    - name: Install uv\n      id: setup-uv\n      uses: astral-sh/setup-uv@v7\n      with:\n        enable-cache: true\n        cache-python: true\n        prune-cache: ${{ runner.os != 'Windows' }} # Windows file-locking breaks cache pruning\n\n    # Set UV_CACHE_DIR so both setup-uv's binary and the build system's\n    # downloaded uv share the same dependency cache for faster builds.\n    - name: Configure uv cache\n      shell: bash\n      run: |\n        CACHE_DIR=\"${{ steps.setup-uv.outputs.cache-dir }}\"\n        if [ -n \"$CACHE_DIR\" ]; then\n          echo \"UV_CACHE_DIR=$CACHE_DIR\" >> \"$GITHUB_ENV\"\n        fi\n\n    # UV_BINARY tells the build system to use our uv instead of downloading\n    # its own (see build/ninja_gen/src/python.rs). Linux-only because on\n    # macOS ARM the launcher needs the downloaded archive to build a universal\n    # binary via lipo (see build/configure/src/launcher.rs).\n    # On macOS/Windows, the downloaded uv will still use the shared cache.\n    - name: Set UV_BINARY (Linux)\n      if: runner.os == 'Linux'\n      shell: bash\n      run: echo \"UV_BINARY=${{ steps.setup-uv.outputs.uv-path }}\" >> \"$GITHUB_ENV\"\n\n    # Ensure cargo-installed tools like n2 are discoverable.\n    - name: Add CARGO_HOME/bin to PATH\n      shell: bash\n      run: echo \"${CARGO_HOME:-$HOME/.cargo}/bin\" >> \"$GITHUB_PATH\"\n\n    - name: Install n2\n      shell: bash\n      run: |\n        if ! command -v n2 &>/dev/null; then\n          tools/install-n2\n        fi\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n    types: [opened, synchronize, reopened, labeled]\n\nconcurrency:\n  group: ci-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  minilints:\n    runs-on: ubuntu-24.04\n    steps:\n      # Check out PR head commit so minilints can verify the author is in CONTRIBUTORS.\n      - uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha || github.sha }}\n\n      - name: Install Rust toolchain\n        uses: actions-rust-lang/setup-rust-toolchain@v1\n\n      - name: Run minilints\n        run: cargo run -p minilints -- check /tmp/minilints.stamp\n        env:\n          CONTRIBUTORS_BYPASS_EMAILS: ${{ vars.CONTRIBUTORS_BYPASS_EMAILS }}\n\n  # Lightweight formatting checks (no build outputs needed).\n  # Uses individual tool installs instead of setup-anki to stay fast.\n  format:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust toolchain\n        uses: actions-rust-lang/setup-rust-toolchain@v1\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install n2\n        run: tools/install-n2\n        env:\n          RUSTFLAGS: \"--cap-lints warn\"\n\n      - name: Install just\n        uses: extractions/setup-just@v3\n\n      - name: Run format checks\n        run: just fmt\n\n  # Linux runs on every PR and push to main.\n  check-linux:\n    runs-on: ubuntu-24.04\n    name: check (linux)\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Restore cargo cache\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/registry/index\n            ~/.cargo/registry/cache\n            ~/.cargo/git/db\n            ~/.cargo/bin\n            ~/.cargo/.crates.toml\n            ~/.cargo/.crates2.json\n          key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}\n          restore-keys: cargo-${{ runner.os }}-\n\n      - name: Restore build output cache\n        uses: actions/cache/restore@v4\n        with:\n          path: out\n          key: build-Linux-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n          restore-keys: |\n            build-Linux-${{ github.event.pull_request.number || 'main' }}-\n            build-Linux-main-\n            build-Linux-\n\n      - name: Setup build environment\n        uses: ./.github/actions/setup-anki\n\n      - name: Install just\n        uses: extractions/setup-just@v3\n\n      - name: Symlink node_modules\n        run: ln -sf out/node_modules .\n\n      - name: Build, lint, and test\n        env:\n          ONLINE_TESTS: \"1\"\n        run: |\n          just build\n          just lint\n          just test\n\n      - name: Ensure libs importable\n        env:\n          SKIP_RUN: \"1\"\n        run: ./run\n\n      - name: Check Rust dependencies\n        uses: EmbarkStudios/cargo-deny-action@v2\n\n      # out/pyenv contains a venv with absolute Python paths that break\n      # across runs. out/build.ninja is regenerated by configure each time.\n      # Remove both before saving so the cache stays portable.\n      - name: Clean non-cacheable state\n        if: always()\n        shell: bash\n        run: |\n          rm -rf out/pyenv\n          rm -f out/build.ninja\n\n      - name: Save build output cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: out\n          key: build-Linux-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n\n  # Runs on pushes to main or on PRs with the check:macos label.\n  check-macos:\n    if: >-\n      github.event_name == 'push'\n      || contains(github.event.pull_request.labels.*.name, 'check:macos')\n    runs-on: macos-latest\n    name: check (macos)\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Restore cargo cache\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/registry/index\n            ~/.cargo/registry/cache\n            ~/.cargo/git/db\n            ~/.cargo/bin\n            ~/.cargo/.crates.toml\n            ~/.cargo/.crates2.json\n          key: cargo-macOS-${{ hashFiles('Cargo.lock') }}\n          restore-keys: cargo-macOS-\n\n      - name: Restore build output cache\n        uses: actions/cache/restore@v4\n        with:\n          path: out\n          key: build-macOS-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n          restore-keys: |\n            build-macOS-${{ github.event.pull_request.number || 'main' }}-\n            build-macOS-main-\n            build-macOS-\n\n      - name: Setup build environment\n        uses: ./.github/actions/setup-anki\n\n      - name: Install just\n        uses: extractions/setup-just@v3\n\n      - name: Symlink node_modules\n        run: ln -sf out/node_modules .\n\n      - name: Build, lint, and test\n        run: |\n          just build\n          just wheels\n          just lint\n          just test\n\n      - name: Clean non-cacheable state\n        if: always()\n        shell: bash\n        run: |\n          rm -rf out/pyenv\n          rm -f out/build.ninja\n\n      - name: Save build output cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: out\n          key: build-macOS-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n\n  # Runs on pushes to main or on PRs with the check:windows label.\n  check-windows:\n    if: >-\n      github.event_name == 'push'\n      || contains(github.event.pull_request.labels.*.name, 'check:windows')\n    runs-on: windows-latest\n    name: check (windows)\n    # Colocate CARGO_HOME and TEMP on D: to keep all I/O on the same fast\n    # local disk.\n    env:\n      CARGO_HOME: D:\\cargo-home\n      TEMP: D:\\tmp\n      TMP: D:\\tmp\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Prepare D:\\ directories\n        shell: bash\n        run: mkdir -p /d/cargo-home /d/tmp\n\n      - name: Restore cargo cache\n        uses: actions/cache@v4\n        with:\n          path: |\n            D:\\cargo-home\\registry\\index\n            D:\\cargo-home\\registry\\cache\n            D:\\cargo-home\\git\\db\n            D:\\cargo-home\\bin\n            D:\\cargo-home\\.crates.toml\n            D:\\cargo-home\\.crates2.json\n          key: cargo-Windows-${{ hashFiles('Cargo.lock') }}\n          restore-keys: cargo-Windows-\n\n      - name: Restore build output cache\n        uses: actions/cache/restore@v4\n        with:\n          path: out\n          key: build-Windows-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n          restore-keys: |\n            build-Windows-${{ github.event.pull_request.number || 'main' }}-\n            build-Windows-main-\n            build-Windows-\n\n      - name: Setup build environment\n        uses: ./.github/actions/setup-anki\n\n      - name: Install just\n        uses: extractions/setup-just@v3\n\n      - name: Build, lint, and test\n        run: |\n          just build\n          just lint\n          just test\n\n      # Also remove node_modules on Windows — file-locking corrupts the cache.\n      - name: Clean non-cacheable state\n        if: always()\n        shell: bash\n        run: |\n          rm -rf out/pyenv out/node_modules\n          rm -f out/build.ninja\n\n      - name: Save build output cache\n        if: always()\n        uses: actions/cache/save@v4\n        with:\n          path: out\n          key: build-Windows-${{ github.event.pull_request.number || 'main' }}-${{ github.run_id }}\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__\n.mypy_cache\n.DS_Store\nanki.prof\ntarget\n/user.bazelrc\n.dmypy.json\n/.idea/\n/.vscode\n/.bazel\n/windows.bazelrc\n/out\nnode_modules\n.n2_db\n.ninja_log\n.ninja_deps\n/extra\nyarn-error.log\nts/.svelte-kit\n.yarn\n.claude/settings.local.json\n.claude/user.md\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"ftl/core-repo\"]\n\tpath = ftl/core-repo\n\turl = https://github.com/ankitects/anki-core-i18n.git\n\tshallow = true\n[submodule \"ftl/qt-repo\"]\n\tpath = ftl/qt-repo\n\turl = https://github.com/ankitects/anki-desktop-ftl.git\n\tshallow = true\n"
  },
  {
    "path": ".idea.dist/repo.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"PYTHON_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <sourceFolder url=\"file://$MODULE_DIR$/out/pylib\" isTestSource=\"false\" />\n      <sourceFolder url=\"file://$MODULE_DIR$/pylib\" isTestSource=\"false\" />\n      <sourceFolder url=\"file://$MODULE_DIR$/qt\" isTestSource=\"false\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/extra\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/out/pyenv\" />\n    </content>\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>\n"
  },
  {
    "path": ".mypy.ini",
    "content": "[mypy]\npython_version = 3.9\npretty = False\nstrict_optional = False\nshow_error_codes = True\ncheck_untyped_defs = True\ndisallow_untyped_decorators = True\nwarn_redundant_casts = True\nwarn_unused_configs = True\nstrict_equality = True\nnamespace_packages = True\nexplicit_package_bases = True\nmypy_path = \n    pylib,\n    out/pylib, \n    qt,\n    out/qt,\n    ftl,\n    pylib/tools,\n    python\nexclude = (pylib/anki/_vendor)\n\n[mypy-anki.*]\ndisallow_untyped_defs = True\n[mypy-anki.importing.*]\ndisallow_untyped_defs = False\n[mypy-anki.exporting]\ndisallow_untyped_defs = False\n[mypy-aqt]\nstrict_optional = True\n[mypy-aqt.browser.*]\nstrict_optional = True\n[mypy-aqt.data.*]\nstrict_optional = True\n[mypy-aqt.forms.*]\nstrict_optional = True\n[mypy-aqt.import_export.*]\nstrict_optional = True\n[mypy-aqt.operations.*]\nstrict_optional = True\n[mypy-aqt.editor]\nstrict_optional = True\n[mypy-aqt.importing]\nstrict_optional = True\n[mypy-aqt.preferences]\nstrict_optional = True\n[mypy-aqt.overview]\nstrict_optional = True\n[mypy-aqt.customstudy]\nstrict_optional = True\n[mypy-aqt.taglimit]\nstrict_optional = True\n[mypy-aqt.modelchooser]\nstrict_optional = True\n[mypy-aqt.deckdescription]\nstrict_optional = True\n[mypy-aqt.deckbrowser]\nstrict_optional = True\n[mypy-aqt.studydeck]\nstrict_optional = True\n[mypy-aqt.tts]\nstrict_optional = True\n[mypy-aqt.mediasrv]\nstrict_optional = True\n[mypy-aqt.changenotetype]\nstrict_optional = True\n[mypy-aqt.clayout]\nstrict_optional = True\n[mypy-aqt.fields]\nstrict_optional = True\n[mypy-aqt.filtered_deck]\nstrict_optional = True\n[mypy-aqt.editcurrent]\nstrict_optional = True\n[mypy-aqt.deckoptions]\nstrict_optional = True\n[mypy-aqt.notetypechooser]\nstrict_optional = True\n[mypy-aqt.stats]\nstrict_optional = True\n[mypy-aqt.switch]\nstrict_optional = True\n[mypy-aqt.debug_console]\nstrict_optional = True\n[mypy-aqt.emptycards]\nstrict_optional = True\n[mypy-aqt.flags]\nstrict_optional = True\n[mypy-aqt.mediacheck]\nstrict_optional = True\n[mypy-aqt.theme]\nstrict_optional = True\n[mypy-aqt.toolbar]\nstrict_optional = True\n[mypy-aqt.deckchooser]\nstrict_optional = True\n[mypy-aqt.about]\nstrict_optional = True\n[mypy-aqt.webview]\nstrict_optional = True\n[mypy-aqt.mediasync]\nstrict_optional = True\n[mypy-aqt.package]\nstrict_optional = True\n[mypy-aqt.progress]\nstrict_optional = True\n[mypy-aqt.tagedit]\nstrict_optional = True\n[mypy-aqt.utils]\nstrict_optional = True\n[mypy-aqt.sync]\nstrict_optional = True\n[mypy-anki.scheduler.base]\nstrict_optional = True\n[mypy-anki._backend.rsbridge]\nignore_missing_imports = True\n[mypy-anki._vendor.stringcase]\ndisallow_untyped_defs = False\n[mypy-stringcase]\nignore_missing_imports = True\n[mypy-aqt.mpv]\ndisallow_untyped_defs = False\nignore_errors = True\n[mypy-aqt.winpaths]\ndisallow_untyped_defs = False\n\n[mypy-win32file]\nignore_missing_imports = True\n[mypy-win32pipe]\nignore_missing_imports = True\n[mypy-pywintypes]\nignore_missing_imports = True\n[mypy-winerror]\nignore_missing_imports = True\n[mypy-distro]\nignore_missing_imports = True\n[mypy-win32api]\nignore_missing_imports = True\n[mypy-xml.dom]\nignore_missing_imports = True\n[mypy-psutil]\nignore_missing_imports = True\n[mypy-bs4]\nignore_missing_imports = True\n[mypy-fluent.*]\nignore_missing_imports = True\n[mypy-compare_locales.*]\nignore_missing_imports = True\n[mypy-PyQt5.*]\nignore_errors = True\nignore_missing_imports = True\n[mypy-send2trash]\nignore_missing_imports = True\n[mypy-win32com.*]\nignore_missing_imports = True\n[mypy-jsonschema.*]\nignore_missing_imports = True\n[mypy-socks]\nignore_missing_imports = True\n[mypy-pythoncom]\nignore_missing_imports = True\n[mypy-snakeviz.*]\nignore_missing_imports = True\n[mypy-wheel.*]\nignore_missing_imports = True\n[mypy-pip_system_certs.*]\nignore_missing_imports = True\n[mypy-anki_audio]\nignore_missing_imports = True\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"trailingComma\": \"all\",\n    \"printWidth\": 88,\n    \"tabWidth\": 4,\n    \"semi\": true,\n    \"htmlWhitespaceSensitivity\": \"ignore\",\n    \"plugins\": [\"prettier-plugin-svelte\"]\n}\n"
  },
  {
    "path": ".python-version",
    "content": "3.13.5\n"
  },
  {
    "path": ".ruff.toml",
    "content": "lint.select = [\n  \"E\", # pycodestyle errors\n  \"F\", # Pyflakes errors\n  \"PL\", # Pylint rules\n  \"I\", # Isort rules\n  \"ARG\",\n  # \"UP\", # pyupgrade\n  # \"B\", # flake8-bugbear\n  # \"SIM\", # flake8-simplify\n]\n\nextend-exclude = [\"*_pb2.py\", \"*_pb2.pyi\"]\n\nlint.ignore = [\n  # Docstring rules (missing-*-docstring in pylint)\n  \"D100\", # Missing docstring in public module\n  \"D101\", # Missing docstring in public class\n  \"D103\", # Missing docstring in public function\n\n  # Import rules (wrong-import-* in pylint)\n  \"E402\", # Module level import not at top of file\n  \"E501\", # Line too long\n\n  # pycodestyle rules\n  \"E741\", # ambiguous-variable-name\n\n  # Comment rules (fixme in pylint)\n  \"FIX002\", # Line contains TODO\n\n  # Pyflakes rules\n  \"F402\", # import-shadowed-by-loop-var\n  \"F403\", # undefined-local-with-import-star\n  \"F405\", # undefined-local-with-import-star-usage\n\n  # Naming rules (invalid-name in pylint)\n  \"N801\", # Class name should use CapWords convention\n  \"N802\", # Function name should be lowercase\n  \"N803\", # Argument name should be lowercase\n  \"N806\", # Variable in function should be lowercase\n  \"N811\", # Constant imported as non-constant\n  \"N812\", # Lowercase imported as non-lowercase\n  \"N813\", # Camelcase imported as lowercase\n  \"N814\", # Camelcase imported as constant\n  \"N815\", # Variable in class scope should not be mixedCase\n  \"N816\", # Variable in global scope should not be mixedCase\n  \"N817\", # CamelCase imported as acronym\n  \"N818\", # Error suffix in exception names\n\n  # Pylint rules\n  \"PLW0603\", # global-statement\n  \"PLW2901\", # redefined-loop-name\n  \"PLC0415\", # import-outside-top-level\n  \"PLR2004\", # magic-value-comparison\n\n  # Exception handling (broad-except, bare-except in pylint)\n  \"BLE001\", # Do not catch blind exception\n\n  # Argument rules (unused-argument in pylint)\n  \"ARG001\", # Unused function argument\n  \"ARG002\", # Unused method argument\n  \"ARG005\", # Unused lambda argument\n\n  # Access rules (protected-access in pylint)\n  \"SLF001\", # Private member accessed\n\n  # String formatting (consider-using-f-string in pylint)\n  \"UP032\", # Use f-string instead of format call\n\n  # Exception rules (broad-exception-raised in pylint)\n  \"TRY301\", # Abstract raise to an inner function\n\n  # Builtin shadowing (redefined-builtin in pylint)\n  \"A001\", # Variable shadows a Python builtin\n  \"A002\", # Argument shadows a Python builtin\n  \"A003\", # Class attribute shadows a Python builtin\n]\n\n[lint.per-file-ignores]\n\"**/anki/*_pb2.py\" = [\"ALL\"]\n\n[lint.pep8-naming]\nignore-names = [\"id\", \"tr\", \"db\", \"ok\", \"ip\"]\n\n[lint.pylint]\nmax-args = 12\nmax-returns = 10\nmax-branches = 35\nmax-statements = 125\n\n[lint.isort]\nknown-first-party = [\"anki\", \"aqt\", \"tests\"]\n"
  },
  {
    "path": ".rustfmt-empty.toml",
    "content": ""
  },
  {
    "path": ".rustfmt.toml",
    "content": "# These settings are not supported on stable Rust, and are ignored by the ninja\n# build script - to use them you need to run 'cargo +nightly fmt'\ngroup_imports = \"StdExternalCrate\"\nimports_granularity = \"Item\"\nimports_layout = \"Vertical\"\nwrap_comments = true\n"
  },
  {
    "path": ".version",
    "content": "25.09.2\n"
  },
  {
    "path": ".vscode.dist/extensions.json",
    "content": "{\n    \"recommendations\": [\n        \"dprint.dprint\",\n        \"ms-python.python\",\n        \"charliermarsh.ruff\",\n        \"rust-lang.rust-analyzer\",\n        \"svelte.svelte-vscode\",\n        \"zxh404.vscode-proto3\",\n        \"usernamehw.errorlens\",\n        \"eamodio.gitlens\"\n    ]\n}\n"
  },
  {
    "path": ".vscode.dist/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Run\",\n            \"type\": \"debugpy\",\n            \"request\": \"launch\",\n            \"program\": \"tools/run.py\",\n            \"args\": [\n                // \"-p\",\n                // \"My test profile\"\n            ],\n            \"console\": \"integratedTerminal\",\n            \"cwd\": \"${workspaceFolder}\",\n            \"python\": \"${workspaceFolder}/out/pyenv/bin/python\",\n            \"windows\": {\n                \"python\": \"${workspaceFolder}/out/pyenv/scripts/python.exe\"\n            },\n            \"env\": {\n                \"PYTHONWARNINGS\": \"default\",\n                \"PYTHONPYCACHEPREFIX\": \"out/pycache\",\n                \"ANKIDEV\": \"1\",\n                \"QTWEBENGINE_REMOTE_DEBUGGING\": \"8080\",\n                \"QTWEBENGINE_CHROMIUM_FLAGS\": \"--remote-allow-origins=http://localhost:8080\",\n                \"RUST_BACKTRACE\": \"1\",\n                // \"TRACESQL\": \"1\",\n                // \"HMR\": \"1\",\n                \"ANKI_API_PORT\": \"40000\",\n                \"ANKI_API_HOST\": \"127.0.0.1\"\n            },\n            \"justMyCode\": true,\n            \"preLaunchTask\": \"ninja\"\n        }\n    ]\n}\n"
  },
  {
    "path": ".vscode.dist/settings.json",
    "content": "{\n    \"editor.formatOnSave\": true,\n    \"[python]\": {\n        \"editor.codeActionsOnSave\": {\n            \"source.organizeImports\": \"explicit\"\n        }\n    },\n    \"files.watcherExclude\": {\n        \"**/.git/objects/**\": true,\n        \"**/.git/subtree-cache/**\": true,\n        \"**/node_modules/*/**\": true,\n        \".bazel/**\": true\n    },\n    \"python.analysis.extraPaths\": [\n        \"./pylib\",\n        \"out/pylib\",\n        \"./pylib/anki/_vendor\",\n        \"out/qt\",\n        \"qt\"\n    ],\n    \"python.formatting.provider\": \"charliermarsh.ruff\",\n    \"python.linting.mypyEnabled\": false,\n    \"python.analysis.diagnosticSeverityOverrides\": {\n        \"reportMissingModuleSource\": \"none\"\n    },\n    \"rust-analyzer.check.allTargets\": false,\n    \"rust-analyzer.files.excludeDirs\": [\".bazel\", \"node_modules\"],\n    \"rust-analyzer.procMacro.enable\": true,\n    // this formats 'use' blocks in a nicer way, but requires you to run\n    // 'rustup install nightly'.\n    \"rust-analyzer.rustfmt.extraArgs\": [\"+nightly\"],\n    \"search.exclude\": {\n        \"**/node_modules\": true,\n        \".bazel/**\": true\n    },\n    \"rust-analyzer.cargo.buildScripts.enable\": true,\n    \"python.analysis.typeCheckingMode\": \"off\",\n    \"python.analysis.exclude\": [\n        \"out/launcher/**\"\n    ],\n    \"terminal.integrated.env.windows\": {\n        \"PATH\": \"c:\\\\msys64\\\\usr\\\\bin;${env:Path}\"\n    }\n}\n"
  },
  {
    "path": ".vscode.dist/tasks.json",
    "content": "{\n    \"version\": \"2.0.0\",\n    \"tasks\": [\n        {\n            \"label\": \"ninja\",\n            \"command\": \"ninja\",\n            \"args\": [\n                \"pylib\",\n                \"qt\"\n            ],\n            \"windows\": {\n                \"command\": \"tools/ninja.bat\",\n                \"args\": [\n                    \"pylib\",\n                    \"qt\"\n                ]\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": ".yarnrc.yml",
    "content": "nodeLinker: node-modules\nenableScripts: false\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# Claude Code Configuration\n\n## Project Overview\n\nAnki is a spaced repetition flashcard program with a multi-layered architecture. Main components:\n\n- Web frontend: Svelte/TypeScript in ts/\n- PyQt GUI, which embeds the web components in aqt/\n- Python library which wraps our rust Layer (pylib/, with Rust module in pylib/rsbridge)\n- Core Rust layer in rslib/\n- Protobuf definitions in proto/ that are used by the different layers to\n  talk to each other.\n\n## Building/checking\n\n./check (check.bat) will format the code and run the main build & checks.\nPlease do this as a final step before marking a task as completed.\n\n## Quick iteration\n\nDuring development, you can build/check subsections of our code:\n\n- Rust: 'cargo check'\n- Python: './tools/dmypy', and if wheel-related, './ninja wheels'\n- TypeScript/Svelte: './ninja check:svelte'\n\nBe mindful that some changes (such as modifications to .proto files) may\nneed a full build with './check' first.\n\n## Build tooling\n\n'./check' and './ninja' invoke our build system, which is implemented in build/. It takes care of downloading required deps and invoking our build\nsteps.\n\n## Translations\n\nftl/ contains our Fluent translation files. We have scripts in rslib/i18n\nto auto-generate an API for Rust, TypeScript and Python so that our code can\naccess the translations in a type-safe manner. Changes should be made to\nftl/core or ftl/qt. Except for features specific to our Qt interface, prefer\nthe core module. When adding new strings, confirm the appropriate ftl file\nfirst, and try to match the existing style.\n\n## Protobuf and IPC\n\nOur build scripts use the .proto files to define our Rust library's\nnon-Rust API. pylib/rsbridge exposes that API, and _backend.py exposes\nsnake_case methods for each protobuf RPC that call into the API.\nSimilar tooling creates a @generated/backend TypeScript module for\ncommunicating with the Rust backend (which happens over POST requests).\n\n## Fixing errors\n\nWhen dealing with build errors or failing tests, invoke 'check' or one\nof the quick iteration commands regularly. This helps verify your changes\nare correct. To locate other instances of a problem, run the check again -\ndon't attempt to grep the codebase.\n\n## Ignores\n\nThe files in out/ are auto-generated. Mostly you should ignore that folder,\nthough you may sometimes find it useful to view out/{pylib/anki,qt/_aqt,ts/lib/generated} when dealing with cross-language communication or our other generated sourcecode.\n\n## Launcher/installer\n\nThe code for our launcher is in qt/launcher, with separate code for each\nplatform.\n\n## Rust dependencies\n\nPrefer adding to the root workspace, and using dep.workspace = true in the individual Rust project.\n\n## Rust utilities\n\nrslib/{process,io} contain some helpers for file and process operations,\nwhich provide better error messages/context and some ergonomics. Use them\nwhen possible.\n\n## Rust error handling\n\nin rslib, use error/mod.rs's AnkiError/Result and snafu. In our other Rust modules, prefer anyhow + additional context where appropriate. Unwrapping\nin build scripts/tests is fine.\n\n## Individual preferences\n\nSee @.claude/user.md\n"
  },
  {
    "path": "CONTRIBUTORS",
    "content": "If you have made changes to Anki's AGPL code, you are welcome to distribute\nthe changed code under the AGPL license.\n\nIf you would like to contribute your code back to the official release, we ask\nthat you license your contributions under the BSD 3 clause license. Portions\nof the code are also used in AnkiWeb and AnkiMobile, and accepting\ncontributions under an AGPL license would mean we could no longer use the code\nwe have written in those projects.\n\nIn your first pull request, please add your name below. By adding your name to\nthis file, you assert that any code you contribute to the Anki project is\nlicensed under the BSD 3 clause license. If any pull request you make contains\ncode that you don't own the copyright to, you agree to make that clear when\nsubmitting the request.\n\nWhen submitting a pull request, GitHub Actions will check that the Git email you\nare submitting from matches the one you used to edit this file. A common issue\nis adding yourself to this file using the username on your computer, but then\nusing GitHub to rebase or edit a pull request online. This will result in your\nGit email becoming something like user@noreply.github.com. To prevent the\nautomatic check from failing, you can edit this file again using GitHub's online\neditor, making a trivial edit like adding a space after your name, and then pull\nrequests will work regardless of whether you create them using your computer or\nGitHub's online interface.\n\nFor users who previously confirmed the license of their contributions on the\nsupport site, it would be great if you could add your name below as well.\n\n********************\n\nAMBOSS MD Inc. <https://www.amboss.com/>\nAristotelis P.  <https://glutanimate.com/contact>\nErez Volk <erez.volk@gmail.com>\nzjosua <zjosua@hotmail.com>\nYngve Hoiseth <yngve@hoiseth.net>\nArthur Milchior <arthur@milchior.fr>\nIjgnd\nYoonchae Lee <bluegreenmagick@gmail.com>\nEvandro Coan <github.com/evandrocoan>\nAlan Du <alanhdu@gmail.com>\nYuchen Lei <lyc@xuming.studio>\nHenry Tang <hktang@ualberta.ca>\nSimone Gaiarin <simgunz@gmail.com>\nRai (Michal Pokorny) <agentydragon@gmail.com>\nZeno Gantner <zeno.gantner@gmail.com>\nHenrik Giesel <hengiesel@gmail.com>\nMichał Bartoszkiewicz <mbartoszkiewicz@gmail.com>\nSander Santema <github.com/sandersantema/>\nThomas Brownback <https://github.com/brownbat/>\nAndrew Gaul <andrew@gaul.org>\nkenden\nEmil Hamrin <github.com/e-hamrin>\nNickolay Yudin <kelciour@gmail.com>\nneitrinoweb <github.com/neitrinoweb/>\nAndreas Reis <github.com/nwwt>\nMatt Krump <github.com/mkrump>\nAlexander Presnyakov <flagist0@gmail.com>\nAbdo <github.com/abdnh>\naplaice <plaice.adam+github@gmail.com>\nphwoo <github.com/phwoo>\nSoren Bjornstad <anki@sorenbjornstad.com>\nAleksa Sarai <cyphar@cyphar.com>\nJakub Kaczmarzyk <jakub.kaczmarzyk@gmail.com>\nAkshara Balachandra <akshara.bala.18@gmail.com>\nlukkea <github.com/lukkea/>\nDavid Allison <davidallisongithub@gmail.com>\nDavid Allison <62114487+david-allison@users.noreply.github.com>\nTsung-Han Yu <johan456789@gmail.com>\nPiotr Kubowicz <piotr.kubowicz@gmail.com>\nRumovZ <gp5glkw78@relay.firefox.com>\nCecini <github.com/cecini>\nKrish Shah <github.com/k12ish>\nianki <iankigit@gmail.com>\nrye761 <ryebread761@gmail.com>\nGuillem Palau Salvà <guillempalausalva@gmail.com>\nMeredith Derecho <meredithderecho@gmail.com>\nDaniel Wallgren <github.com/wallgrenen>\nKerrick Staley <kerrick@kerrickstaley.com>\nMaksim Abramchuk <maximabramchuck@gmail.com>\nBenjamin Kulnik <benjamin.kulnik@student.tuwien.ac.at>\nShaun Ren <shaun.ren@linux.com>\nRyan Greenblatt <greenblattryan@gmail.com>\nMatthias Metelka <github.com/kleinerpirat>\nqubist-pixel-ux <github.com/qubist-pixel-ux>\ncherryblossom <github.com/cherryblossom000>\nHikaru Yoshiga <github.com/hikaru-y/>\nThore Tyborski <github.com/ThoreBor>\nAlexander Giorev <alex.giorev@gmail.com>\nRen Tatsumoto <tatsu@autistici.org>\nlolilolicon <lolilolicon@gmail.com>\nGesa Stupperich <gesa.stupperich@gmail.com>\ngit9527 <github.com/git9527>\nVova Selin <vselin12@gmail.com>\nqxo <49526356@qq.com>\nSpooghetti420 <github.com/spooghetti420>\nDanish Prakash <github.com/danishprakash>\nAraceli Yanez <github.com/aracelix>\nSam Bradshaw <samjr.bradshaw@gmail.com>\ngnnoh <gerongfenh@gmail.com>\nSachin Govind <sachin.govind.too@gmail.com>\nBruce Harris <github.com/bruceharris>\nPatric Cunha <patricc@agap2.pt>\nBrayan Oliveira <69634269+BrayanDSO@users.noreply.github.com>\nLuka Warren <github.com/lukawarren>\nwisherhxl <wisherhxl@gmail.com>\ndobefore <1432338032@qq.com>\nBart Louwers <bart.git@emeel.net>\nSam Penny <github.com/sam1penny>\nYutsuten <mateus.etto@gmail.com>\nZoom <zoomrmc+git@gmail.com>\nTRIAEIOU <github.com/TRIAEIOU>\nStefan Kangas <stefankangas@gmail.com>\nFabricio Duarte <fabricio.duarte.jr@gmail.com>\nMani <github.com/krmanik>\nKaben Nanlohy <kaben.nanlohy@gmail.com>\nTobias Predel <tobias.predel@gmail.com>\nDaniel Tang <danielzgtg.opensource@gmail.com>\nJack Pearson <github.com/jrpear>\nyellowjello <github.com/yellowjello>\nIngemar Berg <github.com/ingemarberg>\nBen Kerman <ben@kermanic.org>\nEuan Kemp <euank@euank.com>\nKieran Black <kieranlblack@gmail.com>\nXeR <github.com/XeR>\nmgrottenthaler <github.com/mgrottenthaler>\nAustin Siew <github.com/Aquafina-water-bottle>\nJoel Koen <mail@joelkoen.com>\nChristopher Woggon <christopher.woggon@gmail.com>\nKavel Rao <github.com/kavelrao>\nBen Yip <github.com/bennyyip>\nmmjang <752515918@qq.com>\nshunlog <github.com/shunlog>\n3ter <github.com/3ter>\nDerek Dang <github.com/derekdang/>\nLuc Mcgrady <github.com/Luc-Mcgrady>\nKehinde Adeleke <adelekekehinde06@gmail.com>\nMarko Juhanne <github.com/mjuhanne>\nGabriel Heinatz <anorot@gmail.com>\nMonty Evans <montyevans@gmail.com>\nNil Admirari <https://github.com/nihil-admirari>\nMichael Winkworth <github.com/SteelColossus>\nMateusz Wojewoda <kawa1.11@o2.pl>\nJarrett Ye <jarrett.ye@outlook.com>\nSam Waechter <github.com/swektr>\nMichael Eliachevitch <m.eliachevitch@posteo.de>\nCarlo Quick <https://github.com/CarloQuick>\nDominique Martinet <asmadeus@codewreck.org>\nchandraiyengar <github.com/chandraiyengar>\nuser1823 <92206575+user1823@users.noreply.github.com>\nGustaf Carefall <https://github.com/Gustaf-C>\nvirinci <github.com/virinci>\nsnowtimeglass <snowtimeglass@gmail.com>\nbrishtibheja <136738526+brishtibheja@users.noreply.github.com>\nBen Olson <github.com/grepgrok>\nAkash Reddy <akashreddy2003@gmail.com>\nLucio Sauer <watermanpaint@posteo.net>\nGustavo Sales <gustavosmendes14@gmail.com>\nShawn M Moore <https://github.com/sartak>\nMarko Sisovic <msisovic13@gmail.com>\nViktor Ricci <ricci@primateer.de>\nHarvey Randall <harveyrandall2001@gmail.com>\nPedro Lameiras <pedrolameiras@tecnico.ulisboa.pt>\nKai Knoblich  <kai@FreeBSD.org>\nLucas Scharenbroch <lucasscharenbroch@gmail.com>\nAntonio Cavallo <a.cavallo@cavallinux.eu>\nHan Yeong-woo <han@yeongwoo.dev>\nJean Khawand <jk@jeankhawand.com>\nPedro Schreiber <schreiber.mmb@gmail.com>\nFoxy_null <https://github.com/Foxy-null>\nArbyste <arbyste@outlook.com>\nVasll <github.com/vasll>\nlaalsaas <laalsaas@systemli.org>\nijqq <ijqq@protonmail.ch>\nAntoineQ1 <https://github.com/AntoineQ1>\njthulhu <https://github.com/jthulhu>\nEscape0707 <tothesong@gmail.com>\nLoudwig <https://github.com/Loudwig>\nWu Yi-Wei <https://github.com/Ianwu0812>\nRRomeroJr <117.rromero@gmail.com>\nXidorn Quan <me@upsuper.org>\nAlexander Bocken <alexander@bocken.org>\nJames Elmore <email@jameselmore.org>\nIan Samir Yep Manzano <https://github.com/isym444>\nDavid Culley <6276049+davidculley@users.noreply.github.com>\nRastislav Kish <rastislav.kish@protonmail.com>\njake <jake@sharnoth.com>\nExpertium <https://github.com/Expertium>\nChristian Donat <https://github.com/cdonat2>\nAsuka Minato <https://asukaminato.eu.org>\nDillon Baldwin <https://github.com/DillBal>\nVoczi <https://github.com/voczi>\nBen Nguyen <105088397+bpnguyen107@users.noreply.github.com>\nThemis Demetriades <themis100@outlook.com>\nLuke Bartholomew <lukesbart@icloud.com>\nGregory Abrasaldo <degeemon@gmail.com>\nTaylor Obyen <162023405+taylorobyen@users.noreply.github.com>\nKris Cherven <krischerven@gmail.com>\ntwwn <github.com/twwn>\nCy Pokhrel <cy@cy7.sh>\nPark Hyunwoo <phu54321@naver.com>\nTomas Fabrizio Orsi <torsi@fi.uba.ar>\nDongjin Ouyang <1113117424@qq.com>\nSawan Sunar <sawansunar24072002@gmail.com>\nhideo aoyama <https://github.com/boukendesho>\nRoss Brown <rbrownwsws@googlemail.com>\n🦙 <gh@siid.sh>\nLukas Sommer <sommerluk@gmail.com>\nLuca Auer <lolle2000.la@gmail.com>\nNiclas Heinz <nheinz@hpost.net>\nOmar Kohl <omarkohl@posteo.net>\nDavid Elizalde <david.elizalde.r.a@gmail.com>\nbeyondcompute <beyondcompute@gmail.com>\nYuki <https://github.com/YukiNagat0>\nwackbyte <wackbyte@protonmail.com>\nGithubAnon0000 <GithubAnon0000@users.noreply.github.com>\nMike Hardy <github@mikehardy.net>\nDanika_Dakika <https://github.com/Danika-Dakika>\nMumtaz Hajjo Alrifai <mumtazrifai@protonmail.com>\nThomas Graves <fate@hey.com>\nJakub Fidler <jakub.fidler@protonmail.com>\nValerie Enfys <val@unidentified.systems>\nJulien Chol <https://github.com/chel-ou>\nikkz <ylei.mk@gmail.com>\nderivativeoflog7 <https://github.com/derivativeoflog7>\nrreemmii-dev <https://github.com/rreemmii-dev>\nbabofitos <https://github.com/babofitos>\nJonathan Schoreels <https://github.com/JSchoreels>\nJL710\nMatt Brubeck <mbrubeck@limpet.net>\nYaoliang Chen <yaoliang.ch@gmail.com>\nKolbyML <https://github.com/KolbyML>\nAdnane Taghi <dev@soleuniverse.me>\nSpiritual Father <https://github.com/spiritualfather>\nEmmanuel Ferdman <https://github.com/emmanuel-ferdman>\nSunong2008 <https://github.com/Sunrongguo2008>\nMarvin Kopf <marvinkopf@outlook.com>\nKevin Nakamura <grinkers@grinkers.net>\nBradley Szoke <bradleyszoke@gmail.com>\njcznk <https://github.com/jcznk>\nThomas Rixen <thomas.rixen@student.uclouvain.be>\nSiyuan Mattuwu Yan <syan4@ualberta.ca>\nLee Doughty <32392044+leedoughty@users.noreply.github.com>\nmemchr <memchr@proton.me>\nMax Romanowski <maxr777@proton.me>\nAldlss <ayaldlss@gmail.com>\nHanna Nilsén <hanni614@student.liu.se>\nElias Johansson Lara <elias.johanssonlara@gmail.com>\nToby Penner <tobypenner01@gmail.com>\nDanilo Spillebeen <spillebeendanilo@gmail.com>\nMatbe766 <matildabergstrom01@gmail.com>\nAmanda Sternberg <mandis.sternberg@gmail.com>\narold0 <arold0@icloud.com> \nnav1s <nav1s@proton.me>\nRanjit Odedra <ranjitodedra.dev@gmail.com>\nEltaurus <https://github.com/Eltaurus-Lt>\njariji\nFrancisco Esteva <fr.esteva@duocuc.cl>\nJunia Mannervik <junia.mannervik@gmail.com>\nEmma Plante <emmaplante04@gmail.com>\nSelfishPig <https://github.com/SelfishPig>\ndefkorean <https://github.com/defkorean>\nMichael Lappas <https://github.com/michaellappas>\nBrett Schwartz <brettschwartz871@gmail.com>\nLovro Boban <lovro.boban@hotmail.com>\nYuuki Gabriele Patriarca <yuukigpatriarca@gmail.com>\nSecretX <https://github.com/SecretX33>\nDaniel Pechersky <danny.pechersky@gmail.com>\nfernandolins <1887601+fernandolins@users.noreply.github.com>\n\n********************\n\nThe text of the 3 clause BSD license follows:\n\nContributions copyright the above contributors, 2010-Present.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\nlist of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\nthis list of conditions and the following disclaimer in the documentation\nand/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its contributors\nmay be used to endorse or promote products derived from this software without\nspecific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace.package]\nversion = \"0.0.0\"\nauthors = [\"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\"]\nedition = \"2021\"\nlicense = \"AGPL-3.0-or-later\"\nrust-version = \"1.80\"\n\n[workspace]\nmembers = [\n  \"build/configure\",\n  \"build/ninja_gen\",\n  \"build/runner\",\n  \"ftl\",\n  \"pylib/rsbridge\",\n  \"qt/launcher\",\n  \"rslib\",\n  \"rslib/i18n\",\n  \"rslib/io\",\n  \"rslib/linkchecker\",\n  \"rslib/process\",\n  \"rslib/proto\",\n  \"rslib/sync\",\n  \"tools/minilints\",\n]\nresolver = \"2\"\n\n[workspace.dependencies.percent-encoding-iri]\ngit = \"https://github.com/ankitects/rust-url.git\"\nrev = \"bb930b8d089f4d30d7d19c12e54e66191de47b88\"\n\n[workspace.dependencies.linkcheck]\ngit = \"https://github.com/ankitects/linkcheck.git\"\nrev = \"184b2ca50ed39ca43da13f0b830a463861adb9ca\"\n\n[workspace.dependencies.fsrs]\nversion = \"5.2.0\"\n# git = \"https://github.com/open-spaced-repetition/fsrs-rs.git\"\n# path = \"../open-spaced-repetition/fsrs-rs\"\n\n[workspace.dependencies]\n# local\nanki = { path = \"rslib\" }\nanki_i18n = { path = \"rslib/i18n\" }\nanki_io = { path = \"rslib/io\" }\nanki_process = { path = \"rslib/process\" }\nanki_proto = { path = \"rslib/proto\" }\nanki_proto_gen = { path = \"rslib/proto_gen\" }\nninja_gen = { \"path\" = \"build/ninja_gen\" }\n\n# pinned\nunicase = \"=2.6.0\" # any changes could invalidate sqlite indexes\n\n# normal\nammonia = \"4.1.2\"\nanyhow = \"1.0.98\"\nasync-compression = { version = \"0.4.24\", features = [\"zstd\", \"tokio\"] }\nasync-stream = \"0.3.6\"\nasync-trait = \"0.1.88\"\naxum = { version = \"0.8.4\", features = [\"multipart\", \"macros\"] }\naxum-client-ip = \"1.1.3\"\naxum-extra = { version = \"0.10.1\", features = [\"typed-header\"] }\nbitflags = \"2.9.1\"\nblake3 = \"1.8.2\"\nbytes = \"1.11.1\"\ncamino = \"1.1.10\"\nchrono = { version = \"0.4.41\", default-features = false, features = [\"std\", \"clock\"] }\nclap = { version = \"4.5.40\", features = [\"derive\"] }\ncoarsetime = \"0.1.36\"\nconvert_case = \"0.8.0\"\ncriterion = { version = \"0.6.0\" }\ncsv = \"1.3.1\"\ndata-encoding = \"2.9.0\"\ndifflib = \"0.4.0\"\ndirs = \"6.0.0\"\ndunce = \"1.0.5\"\nembed-resource = \"3.0.4\"\nenvy = \"0.4.2\"\nflate2 = \"1.1.2\"\nfluent = \"0.17.0\"\nfluent-bundle = \"0.16.0\"\nfluent-syntax = \"0.12.0\"\nfnv = \"1.0.7\"\nfutures = \"0.3.31\"\nglobset = \"0.4.16\"\nhex = \"0.4.3\"\nhtmlescape = \"0.3.1\"\nhyper = \"1\"\nid_tree = \"1.8.0\"\ninflections = \"1.1.1\"\nintl-memoizer = \"0.5.3\"\nitertools = \"0.14.0\"\njunction = \"1.2.0\"\nlibc = \"0.2\"\nlibc-stdhandle = \"0.1\"\nlocale_config = \"0.3.0\"\nmaplit = \"1.0.2\"\nnom = \"8.0.0\"\nnum-format = \"0.4.4\"\nnum_cpus = \"1.17.0\"\nnum_enum = \"0.7.3\"\nonce_cell = \"1.21.3\"\npbkdf2 = { version = \"0.12\", features = [\"simple\"] }\npermutation = \"0.4.1\"\nphf = { version = \"0.11.3\", features = [\"macros\"] }\npin-project = \"1.1.10\"\nprettyplease = \"0.2.34\"\nprost = \"0.13\"\nprost-build = \"0.13\"\nprost-reflect = \"0.14.7\"\nprost-types = \"0.13\"\npulldown-cmark = \"0.13.0\"\npyo3 = { version = \"0.25.1\", features = [\"extension-module\", \"abi3\", \"abi3-py39\"] }\nrand = \"0.9.1\"\nrayon = \"1.10.0\"\nregex = \"1.11.1\"\nreqwest = { version = \"0.12.20\", default-features = false, features = [\"json\", \"socks\", \"stream\", \"multipart\"] }\nrusqlite = { version = \"0.36.0\", features = [\"trace\", \"functions\", \"collation\", \"bundled\"] }\nrustls-pemfile = \"2.2.0\"\nscopeguard = \"1.2.0\"\nserde = { version = \"1.0.219\", features = [\"derive\"] }\nserde-aux = \"4.7.0\"\nserde_json = \"1.0.140\"\nserde_repr = \"0.1.20\"\nserde_tuple = \"1.1.0\"\nsha1 = \"0.10.6\"\nsha2 = { version = \"0.10.9\" }\nsnafu = { version = \"0.8.6\", features = [\"rust_1_61\"] }\nstrum = { version = \"0.27.1\", features = [\"derive\"] }\nsyn = { version = \"2.0.103\", features = [\"parsing\", \"printing\"] }\ntar = \"0.4.44\"\ntempfile = \"3.20.0\"\ntermcolor = \"1.4.1\"\ntokio = { version = \"1.45\", features = [\"fs\", \"rt-multi-thread\", \"macros\", \"signal\"] }\ntokio-util = { version = \"0.7.15\", features = [\"io\"] }\ntower-http = { version = \"0.6.6\", features = [\"trace\"] }\ntracing = { version = \"0.1.41\", features = [\"max_level_trace\", \"release_max_level_debug\"] }\ntracing-appender = \"0.2.3\"\ntracing-subscriber = { version = \"0.3.20\", features = [\"fmt\", \"env-filter\"] }\nunic-langid = { version = \"0.9.6\", features = [\"macros\"] }\nunic-ucd-category = \"0.9.0\"\nunicode-normalization = \"0.1.24\"\nwalkdir = \"2.5.0\"\nwhich = \"8.0.0\"\nwidestring = \"1.1.0\"\nwinapi = { version = \"0.3\", features = [\"wincon\", \"winreg\"] }\nwindows = { version = \"0.61.3\", features = [\"Media_SpeechSynthesis\", \"Media_Core\", \"Foundation_Collections\", \"Storage_Streams\", \"Win32_System_Console\", \"Win32_System_Registry\", \"Win32_System_SystemInformation\", \"Win32_Foundation\", \"Win32_UI_Shell\", \"Wdk_System_SystemServices\"] }\nwiremock = \"0.6.3\"\nxz2 = \"0.1.7\"\nzip = { version = \"4.1.0\", default-features = false, features = [\"deflate\", \"time\"] }\nzstd = { version = \"0.13.3\", features = [\"zstdmt\"] }\n\n# Apply mild optimizations to our dependencies in dev mode, which among other things\n# improves sha2 performance by about 21x. Opt 1 chosen due to\n# https://doc.rust-lang.org/cargo/reference/profiles.html#overrides-and-generics. This\n# applies to the dependencies of unit tests as well.\n[profile.dev.package.\"*\"]\nopt-level = 1\ndebug = 0\n\n[profile.dev.package.anki_i18n]\nopt-level = 1\ndebug = 0\n\n[profile.dev.package.anki_proto]\nopt-level = 1\ndebug = 0\n\n# Debug info off by default, which speeds up incremental builds and produces a considerably\n# smaller library.\n[profile.dev.package.anki]\ndebug = 0\n[profile.dev.package.rsbridge]\ndebug = 0\n\n[profile.release-lto]\ninherits = \"release\"\nlto = true\n"
  },
  {
    "path": "LICENSE",
    "content": "Anki is licensed under the GNU Affero General Public License, version 3 or\nlater, with portions contributed by Anki users licensed under the BSD-3\nlicense (see CONTRIBUTORS).\n\nThe following included source code items use a license other than AGPL3:\n\nIn the pylib folder:\n\n * statsbg.py: CC BY 4.0.\n\nIn the qt folder:\n\n * Anki's translations are a mix of BSD and public domain.\n * mpv.py: MIT.\n * winpaths.py: MIT.\n * MathJax: Apache 2.\n * jQuery and jQuery-UI: MIT.\n * plot.js: MIT.\n * protobuf.js: BSD 3 clause\n\nThe above list only covers the source code that is vendored in this\nrepository. Binary distributions also include copies of Qt translation\nfiles (LGPL), and all of the Python, Rust and Javascript libraries\nthat this code references.\n\nAnki's logo is copyright Alex Fraser, and is licensed under the AGPL3 like the\nrest of Anki's code.\n\nThe logo is also available under a limited alternative license for inclusion\nin books, blogs, videos and so on. If the following conditions are met, you\nmay use the logo in your work without the need to license your work under an\nAGPL3-compatible license:\n\n * The logo must be used to refer to Anki, AnkiWeb, AnkiMobile or AnkiDroid,\n   and a link to https://apps.ankiweb.net must be provided. When your\n   content is focused specifically on AnkiDroid, a link to\n   https://play.google.com/store/apps/details?id=com.ichi2.anki&hl=en\n   may be provided instead of the first link.\n * The work must make it clear that the text/video/etc you\n   are publishing is your own content and not something originating\n   from the Anki project.\n * The logo must be used unmodified - no cropping, changing of colours\n   or adding or deleting content is allowed. You may resize the image\n   provided the horizontal and vertical dimensions are resized\n   equally.\n"
  },
  {
    "path": "README.md",
    "content": "# Anki®\n\n[![Build status](https://badge.buildkite.com/c9edf020a4aec976f9835e54751cc5409d843adbb66d043bd3.svg?branch=main)](https://buildkite.com/ankitects/anki-ci)\n\nThis repo contains the source code for the computer version of\n[Anki](https://apps.ankiweb.net).\n\n# About\n\nAnki is a spaced repetition program. Please see the [website](https://apps.ankiweb.net) to learn more.\n\n# Getting Started\n\n### Anki Betas\n\nIf you'd like to try development builds of Anki but don't feel comfortable\nbuilding the code, please see [Anki betas](https://betas.ankiweb.net/)\n\n### Developing\n\nFor more information on building and developing, please see [Development](./docs/development.md).\n\n### Contributing\n\nWant to contribute to Anki? Check out the [Contribution Guidelines](./docs/contributing.md).\n\n### Anki Contributors\n\n[CONTRIBUTORS](./CONTRIBUTORS)\n\n# License\n\nAnki's license: [LICENSE](./LICENSE)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nAnki does not currently have a bug bounty program, but if you have discovered a\nsecurity issue, a private message on our support site would be greatly\nappreciated. No account is required to post a message:\n\nhttps://anki.tenderapp.com/discussion/new\n\n## FAQ\n\n### Javascript on Cards/Templates\n\nAnki allows users and shared deck authors to augment their card designs with\nJavascript. This is used frequently, so disabling Javascript by default would\nlikely break a lot of the shared decks out there. That said, the default may be\nchanged in the future.\n\nThe computer version has a limited interface between Javascript and the parts of\nAnki outside of the webview, so arbitrary code execution outside of the webview\nshould not be possible.\n\nAnkiWeb hosts its study and editing interface on a separate ankiuser.net domain,\nso that malicious Javascript on cards can not trigger endpoints hosted on the\nmain site. If you've found that not to be the case, or found an instance of JS\nnot being filtered on the main site, please let us know.\n"
  },
  {
    "path": "build/configure/Cargo.toml",
    "content": "[package]\nname = \"configure\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanyhow.workspace = true\nitertools.workspace = true\nninja_gen.workspace = true\n"
  },
  {
    "path": "build/configure/src/aqt.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse ninja_gen::action::BuildAction;\nuse ninja_gen::command::RunCommand;\nuse ninja_gen::copy::CopyFile;\nuse ninja_gen::copy::CopyFiles;\nuse ninja_gen::glob;\nuse ninja_gen::hashmap;\nuse ninja_gen::inputs;\nuse ninja_gen::node::CompileSass;\nuse ninja_gen::node::EsbuildScript;\nuse ninja_gen::node::TypescriptCheck;\nuse ninja_gen::python::python_format;\nuse ninja_gen::python::PythonTest;\nuse ninja_gen::rsync::RsyncFiles;\nuse ninja_gen::Build;\nuse ninja_gen::Utf8Path;\nuse ninja_gen::Utf8PathBuf;\n\nuse crate::anki_version;\nuse crate::python::BuildWheel;\nuse crate::web::copy_mathjax;\n\npub fn build_and_check_aqt(build: &mut Build) -> Result<()> {\n    build_forms(build)?;\n    build_generated_sources(build)?;\n    build_data_folder(build)?;\n    build_wheel(build)?;\n    check_python(build)?;\n    Ok(())\n}\n\nfn build_forms(build: &mut Build) -> Result<()> {\n    let ui_files = glob![\"qt/aqt/forms/*.ui\"];\n    let outdir = Utf8PathBuf::from(\"qt/_aqt/forms\");\n    let mut py_files = vec![];\n    for path in ui_files.resolve() {\n        let outpath = outdir.join(path.file_name().unwrap()).into_string();\n        py_files.push(outpath.replace(\".ui\", \"_qt6.py\"));\n    }\n    build.add_action(\n        \"qt:aqt:forms\",\n        RunCommand {\n            command: \":pyenv:bin\",\n            args: \"$script $first_form\",\n            inputs: hashmap! {\n                \"script\" => inputs![\"qt/tools/build_ui.py\"],\n                \"\" => inputs![ui_files],\n            },\n            outputs: hashmap! {\n                \"first_form\" => vec![py_files[0].as_str()],\n                \"\" => py_files.iter().skip(1).map(|s| s.as_str()).collect(),\n            },\n        },\n    )\n}\n\n/// For legacy reasons, we can not easily separate sources and generated files\n/// up with a PEP420 namespace, as aqt/__init__.py exports a bunch of things.\n/// To allow code to run/typecheck without having to merge source and generated\n/// files into a separate folder, the generated files are exported as a separate\n/// _aqt module.\nfn build_generated_sources(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"qt:aqt:hooks.py\",\n        RunCommand {\n            command: \":pyenv:bin\",\n            args: \"$script $out\",\n            inputs: hashmap! {\n                \"script\" => inputs![\"qt/tools/genhooks_gui.py\"],\n                \"\" => inputs![\"pylib/anki/_vendor/stringcase.py\", \"pylib/tools/hookslib.py\"]\n            },\n            outputs: hashmap! {\n                \"out\" => vec![\"qt/_aqt/hooks.py\"]\n            },\n        },\n    )?;\n    build.add_action(\n        \"qt:aqt:sass_vars\",\n        RunCommand {\n            command: \":pyenv:bin\",\n            args: \"$script $root_scss $out\",\n            inputs: hashmap! {\n                \"script\" => inputs![\"qt/tools/extract_sass_vars.py\"],\n                \"root_scss\" => inputs![\":css:_root-vars\"],\n            },\n            outputs: hashmap! {\n                \"out\" => vec![\n                    \"qt/_aqt/colors.py\",\n                    \"qt/_aqt/props.py\"\n                ]\n            },\n        },\n    )?;\n    // we need to add a py.typed file to the generated sources, or mypy\n    // will ignore them when used with the generated wheel\n    build.add_action(\n        \"qt:aqt:py.typed\",\n        CopyFile {\n            input: \"qt/aqt/py.typed\".into(),\n            output: \"qt/_aqt/py.typed\",\n        },\n    )?;\n    Ok(())\n}\n\nfn build_data_folder(build: &mut Build) -> Result<()> {\n    build_css(build)?;\n    build_imgs(build)?;\n    build_js(build)?;\n    build_pages(build)?;\n    build_icons(build)?;\n    copy_sveltekit(build)?;\n    Ok(())\n}\n\nfn copy_sveltekit(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"qt:aqt:data:web:sveltekit\",\n        RsyncFiles {\n            inputs: inputs![\":sveltekit:folder\"],\n            target_folder: \"qt/_aqt/data/web/\",\n            strip_prefix: \"$builddir/\",\n            extra_args: \"-a --delete\",\n        },\n    )\n}\n\nfn build_css(build: &mut Build) -> Result<()> {\n    let scss_files = build.expand_inputs(inputs![glob![\"qt/aqt/data/web/css/*.scss\"]]);\n    let out_dir = Utf8Path::new(\"qt/_aqt/data/web/css\");\n    for scss in scss_files {\n        let stem = Utf8Path::new(&scss).file_stem().unwrap();\n        let mut out_path = out_dir.join(stem);\n        out_path.set_extension(\"css\");\n\n        build.add_action(\n            \"qt:aqt:data:web:css\",\n            CompileSass {\n                input: scss.into(),\n                output: out_path.as_str(),\n                deps: inputs![\":sass\"],\n                load_paths: vec![\".\", \"node_modules\"],\n            },\n        )?;\n    }\n    let other_ts_css = build.inputs_with_suffix(\n        inputs![\":ts:editor\", \":ts:editable\", \":ts:reviewer:reviewer.css\"],\n        \".css\",\n    );\n    build.add_action(\n        \"qt:aqt:data:web:css\",\n        CopyFiles {\n            inputs: other_ts_css.into(),\n            output_folder: \"qt/_aqt/data/web/css\",\n        },\n    )\n}\n\nfn build_imgs(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"qt:aqt:data:web:imgs\",\n        CopyFiles {\n            inputs: inputs![glob![\"qt/aqt/data/web/imgs/*\"]],\n            output_folder: \"qt/_aqt/data/web/imgs\",\n        },\n    )\n}\n\nfn build_js(build: &mut Build) -> Result<()> {\n    for ts_file in &[\"deckbrowser\", \"webview\", \"toolbar\", \"reviewer-bottom\"] {\n        build.add_action(\n            \"qt:aqt:data:web:js\",\n            EsbuildScript {\n                script: \"ts/transform_ts.mjs\".into(),\n                entrypoint: format!(\"qt/aqt/data/web/js/{ts_file}.ts\").into(),\n                deps: inputs![],\n                output_stem: &format!(\"qt/_aqt/data/web/js/{ts_file}\"),\n                extra_exts: &[],\n            },\n        )?;\n    }\n    let files = inputs![glob![\"qt/aqt/data/web/js/*\"]];\n    build.add_action(\n        \"check:typescript:aqt\",\n        TypescriptCheck {\n            tsconfig: \"qt/aqt/data/web/js/tsconfig.json\".into(),\n            inputs: files,\n        },\n    )?;\n    let files_from_ts = build.inputs_with_suffix(\n        inputs![\":ts:editor\", \":ts:reviewer:reviewer.js\", \":ts:mathjax\"],\n        \".js\",\n    );\n    build.add_action(\n        \"qt:aqt:data:web:js\",\n        CopyFiles {\n            inputs: files_from_ts.into(),\n            output_folder: \"qt/_aqt/data/web/js\",\n        },\n    )?;\n    build_vendor_js(build)\n}\n\nfn build_vendor_js(build: &mut Build) -> Result<()> {\n    build.add_action(\"qt:aqt:data:web:js:vendor:mathjax\", copy_mathjax())?;\n    build.add_action(\n        \"qt:aqt:data:web:js:vendor\",\n        CopyFiles {\n            inputs: inputs![\n                \":node_modules:jquery\",\n                \":node_modules:jquery-ui\",\n                \":node_modules:bootstrap-dist\",\n                \"qt/aqt/data/web/js/vendor/plot.js\"\n            ],\n            output_folder: \"qt/_aqt/data/web/js/vendor\",\n        },\n    )\n}\n\nfn build_pages(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"qt:aqt:data:web:pages\",\n        CopyFiles {\n            inputs: inputs![\":ts:pages\"],\n            output_folder: \"qt/_aqt/data/web/pages\",\n        },\n    )?;\n    Ok(())\n}\n\nfn build_icons(build: &mut Build) -> Result<()> {\n    build_themed_icons(build)?;\n    build.add_action(\n        \"qt:aqt:data:qt:icons:mdi_unthemed\",\n        CopyFiles {\n            inputs: inputs![\":node_modules:mdi_unthemed\"],\n            output_folder: \"qt/_aqt/data/qt/icons\",\n        },\n    )?;\n    build.add_action(\n        \"qt:aqt:data:qt:icons:from_src\",\n        CopyFiles {\n            inputs: inputs![glob![\"qt/aqt/data/qt/icons/*.{png,svg}\"]],\n            output_folder: \"qt/_aqt/data/qt/icons\",\n        },\n    )?;\n    build.add_action(\n        \"qt:aqt:data:qt:icons\",\n        RunCommand {\n            command: \":pyenv:bin\",\n            args: \"$script $out $in\",\n            inputs: hashmap! {\n                \"script\" => inputs![\"qt/tools/build_qrc.py\"],\n                \"in\" => inputs![\n                    \":qt:aqt:data:qt:icons:mdi_unthemed\",\n                    \":qt:aqt:data:qt:icons:mdi_themed\",\n                    \":qt:aqt:data:qt:icons:from_src\",\n                ]\n            },\n            outputs: hashmap! {\n                \"out\" => vec![\"qt/_aqt/data/qt/icons.qrc\"]\n            },\n        },\n    )?;\n    Ok(())\n}\n\nfn build_themed_icons(build: &mut Build) -> Result<()> {\n    let themed_icons_with_extra = hashmap! {\n        \"chevron-up\" => &[\"FG_DISABLED\"],\n        \"chevron-down\" => &[\"FG_DISABLED\"],\n        \"drag-vertical\" => &[\"FG_SUBTLE\"],\n        \"drag-horizontal\" => &[\"FG_SUBTLE\"],\n        \"check\" => &[\"FG_DISABLED\"],\n        \"circle-medium\" => &[\"FG_DISABLED\"],\n        \"minus-thick\" => &[\"FG_DISABLED\"],\n    };\n    for icon_path in build.expand_inputs(inputs![\":node_modules:mdi_themed\"]) {\n        let path = Utf8Path::new(&icon_path);\n        let stem = path.file_stem().unwrap();\n        let mut colors = vec![\"FG\"];\n        if let Some(&extra) = themed_icons_with_extra.get(stem) {\n            colors.extend(extra);\n        }\n        build.add_action(\n            \"qt:aqt:data:qt:icons:mdi_themed\",\n            BuildThemedIcon {\n                src_icon: path,\n                colors,\n            },\n        )?;\n    }\n    Ok(())\n}\n\nstruct BuildThemedIcon<'a> {\n    src_icon: &'a Utf8Path,\n    colors: Vec<&'a str>,\n}\n\nimpl BuildAction for BuildThemedIcon<'_> {\n    fn command(&self) -> &str {\n        \"$pyenv_bin $script $in $colors $out\"\n    }\n\n    fn files(&mut self, build: &mut impl ninja_gen::build::FilesHandle) {\n        let stem = self.src_icon.file_stem().unwrap();\n        // eg foo-light.svg, foo-dark.svg, foo-FG_SUBTLE-light.svg,\n        // foo-FG_SUBTLE-dark.svg\n        let outputs: Vec<_> = self\n            .colors\n            .iter()\n            .flat_map(|&color| {\n                let variant = if color == \"FG\" {\n                    \"\".into()\n                } else {\n                    format!(\"-{color}\")\n                };\n                [\n                    format!(\"qt/_aqt/data/qt/icons/{stem}{variant}-light.svg\"),\n                    format!(\"qt/_aqt/data/qt/icons/{stem}{variant}-dark.svg\"),\n                ]\n            })\n            .collect();\n\n        build.add_inputs(\"pyenv_bin\", inputs![\":pyenv:bin\"]);\n        build.add_inputs(\"script\", inputs![\"qt/tools/color_svg.py\"]);\n        build.add_inputs(\"in\", inputs![self.src_icon.as_str()]);\n        build.add_inputs(\"\", inputs![\":qt:aqt:sass_vars\"]);\n        build.add_variable(\"colors\", self.colors.join(\":\"));\n        build.add_outputs(\"out\", outputs);\n    }\n}\n\nfn build_wheel(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"wheels:aqt\",\n        BuildWheel {\n            name: \"aqt\",\n            version: anki_version(),\n            platform: None,\n            deps: inputs![\n                \":qt:aqt\",\n                glob!(\"qt/aqt/**\"),\n                \"qt/pyproject.toml\",\n                \"qt/hatch_build.py\"\n            ],\n        },\n    )\n}\n\nfn check_python(build: &mut Build) -> Result<()> {\n    python_format(build, \"qt\", inputs![glob!(\"qt/**/*.py\")])?;\n\n    build.add_action(\n        \"check:pytest:aqt\",\n        PythonTest {\n            folder: \"qt/tests\",\n            python_path: &[\"pylib\", \"$builddir/pylib\", \"$builddir/qt\"],\n            deps: inputs![\":pylib:anki\", \":qt:aqt\", glob![\"qt/tests/**\"]],\n        },\n    )\n}\n"
  },
  {
    "path": "build/configure/src/launcher.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse ninja_gen::archives::download_and_extract;\nuse ninja_gen::archives::empty_manifest;\nuse ninja_gen::archives::OnlineArchive;\nuse ninja_gen::command::RunCommand;\nuse ninja_gen::hashmap;\nuse ninja_gen::inputs;\nuse ninja_gen::Build;\n\npub fn setup_uv_universal(build: &mut Build) -> Result<()> {\n    if !cfg!(target_arch = \"aarch64\") {\n        return Ok(());\n    }\n\n    build.add_action(\n        \"launcher:uv_universal\",\n        RunCommand {\n            command: \"/usr/bin/lipo\",\n            args: \"-create -output $out $arm_bin $x86_bin\",\n            inputs: hashmap! {\n                \"arm_bin\" => inputs![\":extract:uv:bin\"],\n                \"x86_bin\" => inputs![\":extract:uv_mac_x86:bin\"],\n            },\n            outputs: hashmap! {\n                \"out\" => vec![\"launcher/uv\"],\n            },\n        },\n    )\n}\n\npub fn build_launcher(build: &mut Build) -> Result<()> {\n    setup_uv_universal(build)?;\n    download_and_extract(build, \"nsis_plugins\", NSIS_PLUGINS, empty_manifest())?;\n\n    Ok(())\n}\n\nconst NSIS_PLUGINS: OnlineArchive = OnlineArchive {\n    url: \"https://github.com/ankitects/anki-bundle-extras/releases/download/anki-2023-05-19/nsis.tar.zst\",\n    sha256: \"6133f730ece699de19714d0479c73bc848647d277e9cc80dda9b9ebe532b40a8\",\n};\n"
  },
  {
    "path": "build/configure/src/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod aqt;\nmod launcher;\nmod platform;\nmod pylib;\nmod python;\nmod rust;\nmod web;\n\nuse std::env;\n\nuse anyhow::Result;\nuse aqt::build_and_check_aqt;\nuse launcher::build_launcher;\nuse ninja_gen::glob;\nuse ninja_gen::inputs;\nuse ninja_gen::protobuf::check_proto;\nuse ninja_gen::protobuf::setup_protoc;\nuse ninja_gen::python::setup_uv;\nuse ninja_gen::Build;\nuse platform::overriden_python_venv_platform;\nuse pylib::build_pylib;\nuse pylib::check_pylib;\nuse python::check_python;\nuse python::setup_venv;\nuse rust::build_rust;\nuse rust::check_minilints;\nuse rust::check_rust;\nuse web::build_and_check_web;\nuse web::check_sql;\n\nuse crate::python::setup_sphinx;\n\nfn anki_version() -> String {\n    std::fs::read_to_string(\".version\")\n        .unwrap()\n        .trim()\n        .to_string()\n}\n\nfn main() -> Result<()> {\n    let mut build = Build::new()?;\n    let build = &mut build;\n\n    setup_protoc(build)?;\n    check_proto(build, inputs![glob![\"proto/**/*.proto\"]])?;\n\n    if env::var(\"OFFLINE_BUILD\").is_err() {\n        setup_uv(\n            build,\n            overriden_python_venv_platform().unwrap_or(build.host_platform),\n        )?;\n    }\n    setup_venv(build)?;\n\n    build_rust(build)?;\n    build_pylib(build)?;\n    build_and_check_web(build)?;\n    build_and_check_aqt(build)?;\n\n    if env::var(\"OFFLINE_BUILD\").is_err() {\n        build_launcher(build)?;\n    }\n\n    setup_sphinx(build)?;\n\n    check_rust(build)?;\n    check_pylib(build)?;\n    check_python(build)?;\n\n    check_sql(build)?;\n    check_minilints(build)?;\n\n    build.trailing_text = \"default pylib qt\\n\".into();\n\n    build.write_build_file()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "build/configure/src/platform.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\n\nuse ninja_gen::archives::Platform;\n\n/// Please see [`overriden_python_target_platform()`] for details.\npub fn overriden_rust_target_triple() -> Option<&'static str> {\n    overriden_python_wheel_platform().map(|p| p.as_rust_triple())\n}\n\n/// Usually None to use the host architecture, except on Windows which\n/// always uses x86_64, since WebEngine is unavailable for ARM64.\npub fn overriden_python_venv_platform() -> Option<Platform> {\n    if cfg!(target_os = \"windows\") {\n        Some(Platform::WindowsX64)\n    } else {\n        None\n    }\n}\n\n/// Like [`overriden_python_venv_platform`], but:\n/// If MAC_X86 is set, an X86 wheel will be built on macOS ARM.\n/// If LIN_ARM64 is set, an ARM64 wheel will be built on Linux AMD64.\npub fn overriden_python_wheel_platform() -> Option<Platform> {\n    if env::var(\"MAC_X86\").is_ok() {\n        Some(Platform::MacX64)\n    } else if env::var(\"LIN_ARM64\").is_ok() {\n        Some(Platform::LinuxArm)\n    } else {\n        overriden_python_venv_platform()\n    }\n}\n"
  },
  {
    "path": "build/configure/src/pylib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse ninja_gen::action::BuildAction;\nuse ninja_gen::archives::Platform;\nuse ninja_gen::command::RunCommand;\nuse ninja_gen::copy::LinkFile;\nuse ninja_gen::glob;\nuse ninja_gen::hashmap;\nuse ninja_gen::inputs;\nuse ninja_gen::python::python_format;\nuse ninja_gen::python::PythonTest;\nuse ninja_gen::Build;\n\nuse crate::anki_version;\nuse crate::platform::overriden_python_wheel_platform;\nuse crate::python::BuildWheel;\nuse crate::python::GenPythonProto;\n\npub fn build_pylib(build: &mut Build) -> Result<()> {\n    // generated files\n    build.add_action(\n        \"pylib:anki:proto\",\n        GenPythonProto {\n            proto_files: inputs![glob![\"proto/anki/*.proto\"]],\n        },\n    )?;\n    build.add_dependency(\"pylib:anki:proto\", \":rslib:proto:py\".into());\n    build.add_dependency(\"pylib:anki:i18n\", \":rslib:i18n:py\".into());\n\n    build.add_action(\n        \"pylib:anki:hooks_gen.py\",\n        RunCommand {\n            command: \":pyenv:bin\",\n            args: \"$script $out\",\n            inputs: hashmap! {\n                \"script\" => inputs![\"pylib/tools/genhooks.py\"],\n                \"\" => inputs![\"pylib/anki/_vendor/stringcase.py\", \"pylib/tools/hookslib.py\"]\n            },\n            outputs: hashmap! {\n                \"out\" => vec![\"pylib/anki/hooks_gen.py\"]\n            },\n        },\n    )?;\n    build.add_action(\n        \"pylib:anki:rsbridge\",\n        LinkFile {\n            input: inputs![\":pylib:rsbridge\"],\n            output: &format!(\n                \"pylib/anki/_rsbridge.{}\",\n                match build.host_platform {\n                    Platform::WindowsX64 | Platform::WindowsArm => \"pyd\",\n                    _ => \"so\",\n                }\n            ),\n        },\n    )?;\n    build.add_action(\"pylib:anki:buildinfo.py\", GenBuildInfo {})?;\n\n    // wheel\n    build.add_action(\n        \"wheels:anki\",\n        BuildWheel {\n            name: \"anki\",\n            version: anki_version(),\n            platform: overriden_python_wheel_platform().or(Some(build.host_platform)),\n            deps: inputs![\n                \":pylib:anki\",\n                glob!(\"pylib/anki/**\"),\n                \"pylib/pyproject.toml\",\n                \"pylib/hatch_build.py\"\n            ],\n        },\n    )?;\n    Ok(())\n}\n\npub fn check_pylib(build: &mut Build) -> Result<()> {\n    python_format(build, \"pylib\", inputs![glob!(\"pylib/**/*.py\")])?;\n\n    build.add_action(\n        \"check:pytest:pylib\",\n        PythonTest {\n            folder: \"pylib/tests\",\n            python_path: &[\"$builddir/pylib\"],\n            deps: inputs![\":pylib:anki\", glob![\"pylib/{anki,tests}/**\"]],\n        },\n    )\n}\n\npub struct GenBuildInfo {}\n\nimpl BuildAction for GenBuildInfo {\n    fn command(&self) -> &str {\n        \"$pyenv_bin $script $version_file $buildhash_file $out\"\n    }\n\n    fn files(&mut self, build: &mut impl ninja_gen::build::FilesHandle) {\n        build.add_inputs(\"pyenv_bin\", inputs![\":pyenv:bin\"]);\n        build.add_inputs(\"script\", inputs![\"pylib/tools/genbuildinfo.py\"]);\n        build.add_inputs(\"version_file\", inputs![\".version\"]);\n        build.add_inputs(\"buildhash_file\", inputs![\"$builddir/buildhash\"]);\n        build.add_outputs(\"out\", vec![\"pylib/anki/buildinfo.py\"]);\n    }\n}\n"
  },
  {
    "path": "build/configure/src/python.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse ninja_gen::action::BuildAction;\nuse ninja_gen::archives::Platform;\nuse ninja_gen::build::FilesHandle;\nuse ninja_gen::copy::CopyFiles;\nuse ninja_gen::glob;\nuse ninja_gen::input::BuildInput;\nuse ninja_gen::inputs;\nuse ninja_gen::python::python_format;\nuse ninja_gen::python::PythonEnvironment;\nuse ninja_gen::python::PythonTypecheck;\nuse ninja_gen::python::RuffCheck;\nuse ninja_gen::Build;\n\n/// Normalize version string by removing leading zeros from numeric parts\n/// while preserving pre-release markers (b1, rc2, a3, etc.)\nfn normalize_version(version: &str) -> String {\n    version\n        .split('.')\n        .map(|part| {\n            // Check if the part contains only digits\n            if part.chars().all(|c| c.is_ascii_digit()) {\n                // Numeric part: remove leading zeros\n                part.parse::<u32>().unwrap_or(0).to_string()\n            } else {\n                // Mixed part (contains both numbers and pre-release markers)\n                // Split on first non-digit character and normalize the numeric prefix\n                let chars = part.chars();\n                let mut numeric_prefix = String::new();\n                let mut rest = String::new();\n                let mut found_non_digit = false;\n\n                for ch in chars {\n                    if ch.is_ascii_digit() && !found_non_digit {\n                        numeric_prefix.push(ch);\n                    } else {\n                        found_non_digit = true;\n                        rest.push(ch);\n                    }\n                }\n\n                if numeric_prefix.is_empty() {\n                    part.to_string()\n                } else {\n                    let normalized_prefix = numeric_prefix.parse::<u32>().unwrap_or(0).to_string();\n                    format!(\"{normalized_prefix}{rest}\")\n                }\n            }\n        })\n        .collect::<Vec<_>>()\n        .join(\".\")\n}\n\npub fn setup_venv(build: &mut Build) -> Result<()> {\n    let extra_binary_exports = &[\"mypy\", \"ruff\", \"pytest\", \"protoc-gen-mypy\"];\n    build.add_action(\n        \"pyenv\",\n        PythonEnvironment {\n            venv_folder: \"pyenv\",\n            deps: inputs![\n                \"pyproject.toml\",\n                \"pylib/pyproject.toml\",\n                \"qt/pyproject.toml\",\n                \"uv.lock\"\n            ],\n            extra_args: \"--all-packages --extra qt --extra audio\",\n            extra_binary_exports,\n        },\n    )?;\n\n    Ok(())\n}\n\npub struct GenPythonProto {\n    pub proto_files: BuildInput,\n}\n\nimpl BuildAction for GenPythonProto {\n    fn command(&self) -> &str {\n        \"$protoc $\n        --plugin=protoc-gen-mypy=$protoc-gen-mypy $\n        --python_out=$builddir/pylib $\n        --mypy_out=$builddir/pylib $\n        -Iproto $in\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        let proto_inputs = build.expand_inputs(&self.proto_files);\n        let python_outputs: Vec<_> = proto_inputs\n            .iter()\n            .flat_map(|path| {\n                let path = path\n                    .replace('\\\\', \"/\")\n                    .replace(\"proto/\", \"pylib/\")\n                    .replace(\".proto\", \"_pb2\");\n                [format!(\"{path}.py\"), format!(\"{path}.pyi\")]\n            })\n            .collect();\n        build.add_inputs(\"in\", &self.proto_files);\n        build.add_inputs(\"protoc\", inputs![\":protoc_binary\"]);\n        build.add_inputs(\"protoc-gen-mypy\", inputs![\":pyenv:protoc-gen-mypy\"]);\n        build.add_outputs(\"\", python_outputs);\n    }\n\n    fn hide_progress(&self) -> bool {\n        true\n    }\n}\n\npub struct BuildWheel {\n    pub name: &'static str,\n    pub version: String,\n    pub platform: Option<Platform>,\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for BuildWheel {\n    fn command(&self) -> &str {\n        \"$uv build --wheel --out-dir=$out_dir --project=$project_dir\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        if std::env::var(\"OFFLINE_BUILD\").ok().as_deref() == Some(\"1\") {\n            let uv_path =\n                std::env::var(\"UV_BINARY\").expect(\"UV_BINARY must be set in OFFLINE_BUILD mode\");\n            build.add_inputs(\"uv\", inputs![uv_path]);\n        } else {\n            build.add_inputs(\"uv\", inputs![\":uv_binary\"]);\n        }\n\n        build.add_inputs(\"\", &self.deps);\n\n        // Set the project directory based on which package we're building\n        let project_dir = if self.name == \"anki\" { \"pylib\" } else { \"qt\" };\n        build.add_variable(\"project_dir\", project_dir);\n\n        // Set environment variable for uv to use our pyenv\n        build.add_variable(\"pyenv_path\", \"$builddir/pyenv\");\n        build.add_env_var(\"UV_PROJECT_ENVIRONMENT\", \"$pyenv_path\");\n\n        // Set output directory\n        build.add_variable(\"out_dir\", \"$builddir/wheels/\");\n\n        // Calculate the wheel filename that uv will generate\n        let tag = if let Some(platform) = self.platform {\n            let platform_tag = match platform {\n                Platform::LinuxX64 => \"manylinux_2_36_x86_64\",\n                Platform::LinuxArm => \"manylinux_2_36_aarch64\",\n                Platform::MacX64 => \"macosx_12_0_x86_64\",\n                Platform::MacArm => \"macosx_12_0_arm64\",\n                Platform::WindowsX64 => \"win_amd64\",\n                Platform::WindowsArm => \"win_arm64\",\n            };\n            format!(\"cp39-abi3-{platform_tag}\")\n        } else {\n            \"py3-none-any\".into()\n        };\n\n        // Set environment variable for hatch_build.py to use the correct platform tag\n        build.add_variable(\"wheel_tag\", &tag);\n        build.add_env_var(\"ANKI_WHEEL_TAG\", \"$wheel_tag\");\n\n        let name = self.name;\n\n        let normalized_version = normalize_version(&self.version);\n\n        let wheel_path = format!(\"wheels/{name}-{normalized_version}-{tag}.whl\");\n        build.add_outputs(\"out\", vec![wheel_path]);\n    }\n}\n\npub fn check_python(build: &mut Build) -> Result<()> {\n    python_format(build, \"tools\", inputs![glob!(\"tools/**/*.py\")])?;\n\n    build.add_action(\n        \"check:mypy\",\n        PythonTypecheck {\n            folders: &[\n                \"pylib\",\n                \"qt/aqt\",\n                \"qt/tools\",\n                \"out/pylib/anki\",\n                \"out/qt/_aqt\",\n                \"python\",\n                \"tools\",\n            ],\n            deps: inputs![\n                glob![\"{pylib,ftl,qt}/**/*.{py,pyi}\"],\n                \":pylib:anki\",\n                \":qt:aqt\"\n            ],\n        },\n    )?;\n\n    let ruff_folders = &[\"qt/aqt\", \"ftl\", \"pylib/tools\", \"tools\", \"python\"];\n    let ruff_deps = inputs![\n        glob![\"{pylib,ftl,qt,python,tools}/**/*.py\"],\n        \":pylib:anki\",\n        \":qt:aqt\"\n    ];\n    build.add_action(\n        \"check:ruff\",\n        RuffCheck {\n            folders: ruff_folders,\n            deps: ruff_deps.clone(),\n            check_only: true,\n        },\n    )?;\n    build.add_action(\n        \"fix:ruff\",\n        RuffCheck {\n            folders: ruff_folders,\n            deps: ruff_deps,\n            check_only: false,\n        },\n    )?;\n\n    Ok(())\n}\n\nstruct Sphinx {\n    deps: BuildInput,\n}\n\nimpl BuildAction for Sphinx {\n    fn command(&self) -> &str {\n        if std::env::var(\"OFFLINE_BUILD\").ok().as_deref() == Some(\"1\") {\n            \"$python python/sphinx/build.py\"\n        } else {\n            \"$uv sync --extra sphinx && $python python/sphinx/build.py\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        if std::env::var(\"OFFLINE_BUILD\").ok().as_deref() == Some(\"1\") {\n            let uv_path =\n                std::env::var(\"UV_BINARY\").expect(\"UV_BINARY must be set in OFFLINE_BUILD mode\");\n            build.add_inputs(\"uv\", inputs![uv_path]);\n        } else {\n            build.add_inputs(\"uv\", inputs![\":uv_binary\"]);\n            // Set environment variable to use the existing pyenv\n            build.add_variable(\"pyenv_path\", \"$builddir/pyenv\");\n            build.add_env_var(\"UV_PROJECT_ENVIRONMENT\", \"$pyenv_path\");\n        }\n        build.add_inputs(\"python\", inputs![\":pyenv:bin\"]);\n        build.add_inputs(\"\", &self.deps);\n        build.add_output_stamp(\"python/sphinx/stamp\");\n    }\n\n    fn hide_success(&self) -> bool {\n        false\n    }\n}\n\npub(crate) fn setup_sphinx(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"python:sphinx:copy_conf\",\n        CopyFiles {\n            inputs: inputs![glob!(\"python/sphinx/{conf.py,index.rst}\")],\n            output_folder: \"python/sphinx\",\n        },\n    )?;\n    build.add_action(\n        \"python:sphinx\",\n        Sphinx {\n            deps: inputs![\n                \":pylib\",\n                \":qt\",\n                \":python:sphinx:copy_conf\",\n                \"pyproject.toml\"\n            ],\n        },\n    )?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_normalize_version_basic() {\n        assert_eq!(normalize_version(\"1.2.3\"), \"1.2.3\");\n        assert_eq!(normalize_version(\"01.02.03\"), \"1.2.3\");\n        assert_eq!(normalize_version(\"1.0.0\"), \"1.0.0\");\n    }\n\n    #[test]\n    fn test_normalize_version_with_prerelease() {\n        assert_eq!(normalize_version(\"1.2.3b1\"), \"1.2.3b1\");\n        assert_eq!(normalize_version(\"01.02.03b1\"), \"1.2.3b1\");\n        assert_eq!(normalize_version(\"1.0.0rc2\"), \"1.0.0rc2\");\n        assert_eq!(normalize_version(\"2.1.0a3\"), \"2.1.0a3\");\n        assert_eq!(normalize_version(\"1.2.3beta1\"), \"1.2.3beta1\");\n        assert_eq!(normalize_version(\"1.2.3alpha1\"), \"1.2.3alpha1\");\n    }\n}\n"
  },
  {
    "path": "build/configure/src/rust.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\n\nuse anyhow::Result;\nuse ninja_gen::action::BuildAction;\nuse ninja_gen::build::BuildProfile;\nuse ninja_gen::build::FilesHandle;\nuse ninja_gen::cargo::CargoBuild;\nuse ninja_gen::cargo::CargoClippy;\nuse ninja_gen::cargo::CargoFormat;\nuse ninja_gen::cargo::CargoTest;\nuse ninja_gen::cargo::RustOutput;\nuse ninja_gen::git::SyncSubmodule;\nuse ninja_gen::glob;\nuse ninja_gen::hash::simple_hash;\nuse ninja_gen::input::BuildInput;\nuse ninja_gen::inputs;\nuse ninja_gen::Build;\n\nuse crate::platform::overriden_rust_target_triple;\n\npub fn build_rust(build: &mut Build) -> Result<()> {\n    prepare_translations(build)?;\n    build_proto_descriptors_and_interfaces(build)?;\n    build_rsbridge(build)\n}\n\nfn prepare_translations(build: &mut Build) -> Result<()> {\n    let offline_build = env::var(\"OFFLINE_BUILD\").is_ok();\n\n    // ensure repos are checked out\n    build.add_action(\n        \"ftl:repo:core\",\n        SyncSubmodule {\n            path: \"ftl/core-repo\",\n            offline_build,\n        },\n    )?;\n    build.add_action(\n        \"ftl:repo:qt\",\n        SyncSubmodule {\n            path: \"ftl/qt-repo\",\n            offline_build,\n        },\n    )?;\n    // build anki_i18n and spit out strings.json\n    build.add_action(\n        \"rslib:i18n\",\n        CargoBuild {\n            inputs: inputs![\n                glob![\"rslib/i18n/**\"],\n                glob![\"ftl/{core,core-repo,qt,qt-repo}/**\"],\n                \":ftl:repo\",\n            ],\n            outputs: &[\n                RustOutput::Data(\"py\", \"pylib/anki/_fluent.py\"),\n                RustOutput::Data(\"ts\", \"ts/lib/generated/ftl.ts\"),\n            ],\n            target: None,\n            extra_args: \"-p anki_i18n\",\n            release_override: None,\n        },\n    )?;\n\n    build.add_action(\n        \"ftl:bin\",\n        CargoBuild {\n            inputs: inputs![glob![\"ftl/**\"],],\n            outputs: &[RustOutput::Binary(\"ftl\")],\n            target: None,\n            extra_args: \"-p ftl\",\n            release_override: None,\n        },\n    )?;\n\n    // These don't use :group notation, as it doesn't make sense to invoke multiple\n    // commands as a group.\n    build.add_action(\n        \"ftl-sync\",\n        FtlCommand {\n            args: \"sync\",\n            deps: inputs![\":ftl:repo\", glob![\"ftl/**\"]],\n        },\n    )?;\n\n    build.add_action(\n        \"ftl-deprecate\",\n        FtlCommand {\n            args: \"deprecate --ftl-roots ftl/core ftl/qt --source-roots pylib qt rslib ts --json-roots ftl/usage\",\n            deps: inputs![\"ftl/core\", \"ftl/qt\", \"pylib\", \"qt\", \"rslib\", \"ts\"],\n        },\n    )?;\n\n    Ok(())\n}\n\nstruct FtlCommand {\n    args: &'static str,\n    deps: BuildInput,\n}\n\nimpl BuildAction for FtlCommand {\n    fn command(&self) -> &str {\n        \"$ftl_bin $args\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\"\", &self.deps);\n        build.add_inputs(\"ftl_bin\", inputs![\":ftl:bin\"]);\n        build.add_variable(\"args\", self.args);\n        build.add_output_stamp(format!(\"ftl/stamp.{}\", simple_hash(self.args)));\n    }\n}\n\nfn build_proto_descriptors_and_interfaces(build: &mut Build) -> Result<()> {\n    let outputs = vec![\n        RustOutput::Data(\"descriptors.bin\", \"rslib/proto/descriptors.bin\"),\n        RustOutput::Data(\"py\", \"pylib/anki/_backend_generated.py\"),\n        RustOutput::Data(\"ts\", \"ts/lib/generated/backend.ts\"),\n    ];\n    build.add_action(\n        \"rslib:proto\",\n        CargoBuild {\n            inputs: inputs![glob![\"{proto,rslib/proto}/**\"], \":protoc_binary\",],\n            outputs: &outputs,\n            target: None,\n            extra_args: \"-p anki_proto\",\n            release_override: None,\n        },\n    )?;\n    Ok(())\n}\n\nfn build_rsbridge(build: &mut Build) -> Result<()> {\n    let features = if cfg!(target_os = \"linux\") {\n        \"rustls\"\n    } else {\n        \"native-tls\"\n    };\n    build.add_action(\n        \"pylib:rsbridge\",\n        CargoBuild {\n            inputs: inputs![\n                glob![\"{pylib/rsbridge/**,rslib/**}\"],\n                // declare a dependency on i18n/proto so they get built first, allowing\n                // things depending on them to build faster, and ensuring\n                // changes to the ftl files trigger a rebuild\n                \":rslib:i18n\",\n                \":rslib:proto\",\n                // when env vars change the build hash gets updated\n                \"$builddir/env\",\n                \"$builddir/buildhash\",\n                // building on Windows requires python3.lib\n                if cfg!(windows) {\n                    inputs![\":pyenv:bin\"]\n                } else {\n                    inputs![]\n                }\n            ],\n            outputs: &[RustOutput::DynamicLib(\"rsbridge\")],\n            target: overriden_rust_target_triple(),\n            extra_args: &format!(\"-p rsbridge --features {features}\"),\n            release_override: None,\n        },\n    )\n}\n\npub fn check_rust(build: &mut Build) -> Result<()> {\n    let inputs = inputs![\n        glob!(\"{rslib/**,pylib/rsbridge/**,ftl/**,build/**,qt/launcher/**,tools/minilints/**}\"),\n        \"Cargo.lock\",\n        \"Cargo.toml\",\n        \"rust-toolchain.toml\",\n    ];\n    build.add_action(\n        \"check:format:rust\",\n        CargoFormat {\n            inputs: inputs.clone(),\n            check_only: true,\n            working_dir: Some(\"cargo/format\"),\n        },\n    )?;\n    build.add_action(\n        \"format:rust\",\n        CargoFormat {\n            inputs: inputs.clone(),\n            check_only: false,\n            working_dir: Some(\"cargo/format\"),\n        },\n    )?;\n\n    let inputs = inputs![\n        inputs,\n        // defer tests until build has completed; ensure re-run on changes\n        \":pylib:rsbridge\"\n    ];\n\n    build.add_action(\n        \"check:clippy\",\n        CargoClippy {\n            inputs: inputs.clone(),\n        },\n    )?;\n    build.add_action(\"check:rust_test\", CargoTest { inputs })?;\n\n    Ok(())\n}\n\npub fn check_minilints(build: &mut Build) -> Result<()> {\n    struct RunMinilints {\n        pub deps: BuildInput,\n        pub fix: bool,\n    }\n\n    impl BuildAction for RunMinilints {\n        fn command(&self) -> &str {\n            \"$minilints_bin $fix $stamp\"\n        }\n\n        fn bypass_runner(&self) -> bool {\n            true\n        }\n\n        fn files(&mut self, build: &mut impl FilesHandle) {\n            build.add_inputs(\"minilints_bin\", inputs![\":build:minilints\"]);\n            build.add_inputs(\"\", &self.deps);\n            build.add_variable(\"fix\", if self.fix { \"fix\" } else { \"check\" });\n            build.add_output_stamp(format!(\"tests/minilints.{}\", self.fix));\n        }\n\n        fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n            build.add_action(\n                \"build:minilints\",\n                CargoBuild {\n                    inputs: inputs![glob!(\"tools/minilints/**/*\")],\n                    outputs: &[RustOutput::Binary(\"minilints\")],\n                    target: None,\n                    extra_args: \"-p minilints\",\n                    release_override: Some(BuildProfile::Debug),\n                },\n            )\n        }\n    }\n\n    let files = inputs![\n        glob![\n            \"**/*.{py,rs,ts,svelte,mjs,md}\",\n            \"{node_modules,ts/.svelte-kit}/**\"\n        ],\n        \"Cargo.lock\"\n    ];\n\n    build.add_action(\n        \"check:minilints\",\n        RunMinilints {\n            deps: files.clone(),\n            fix: false,\n        },\n    )?;\n    build.add_action(\n        \"fix:minilints\",\n        RunMinilints {\n            deps: files,\n            fix: true,\n        },\n    )?;\n    Ok(())\n}\n"
  },
  {
    "path": "build/configure/src/web.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse ninja_gen::action::BuildAction;\nuse ninja_gen::copy::CopyFiles;\nuse ninja_gen::glob;\nuse ninja_gen::hashmap;\nuse ninja_gen::input::BuildInput;\nuse ninja_gen::inputs;\nuse ninja_gen::node::node_archive;\nuse ninja_gen::node::CompileSass;\nuse ninja_gen::node::DPrint;\nuse ninja_gen::node::EsbuildScript;\nuse ninja_gen::node::Eslint;\nuse ninja_gen::node::GenTypescriptProto;\nuse ninja_gen::node::Prettier;\nuse ninja_gen::node::SqlFormat;\nuse ninja_gen::node::SvelteCheck;\nuse ninja_gen::node::SveltekitBuild;\nuse ninja_gen::node::ViteTest;\nuse ninja_gen::rsync::RsyncFiles;\nuse ninja_gen::Build;\n\npub fn build_and_check_web(build: &mut Build) -> Result<()> {\n    setup_node(build)?;\n    build_sass(build)?;\n    build_and_check_tslib(build)?;\n    build_sveltekit(build)?;\n    declare_and_check_other_libraries(build)?;\n    build_and_check_pages(build)?;\n    build_and_check_editor(build)?;\n    build_and_check_reviewer(build)?;\n    build_and_check_mathjax(build)?;\n    check_web(build)?;\n\n    Ok(())\n}\n\nfn build_sveltekit(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"sveltekit\",\n        SveltekitBuild {\n            output_folder: inputs![\"sveltekit\"],\n            deps: inputs![\n                \"ts/tsconfig.json\",\n                glob![\"ts/**\", \"ts/.svelte-kit/**\"],\n                \":ts:lib\"\n            ],\n        },\n    )\n}\n\nfn setup_node(build: &mut Build) -> Result<()> {\n    ninja_gen::node::setup_node(\n        build,\n        node_archive(build.host_platform),\n        &[\n            \"dprint\",\n            \"svelte-check\",\n            \"eslint\",\n            \"sass\",\n            \"tsc\",\n            \"tsx\",\n            \"vite\",\n            \"vitest\",\n            \"protoc-gen-es\",\n            \"prettier\",\n        ],\n        hashmap! {\n            \"jquery\" => vec![\n                \"jquery/dist/jquery.min.js\".into()\n            ],\n            \"jquery-ui\" => vec![\n                \"jquery-ui-dist/jquery-ui.min.js\".into()\n            ],\n            \"bootstrap-dist\" => vec![\n                \"bootstrap/dist/js/bootstrap.bundle.min.js\".into(),\n            ],\n            \"mathjax\" => MATHJAX_FILES.iter().map(|&v| v.into()).collect(),\n            \"mdi_unthemed\" => [\n                // saved searches\n                \"heart-outline.svg\",\n                // today\n                \"clock-outline.svg\",\n                // state\n                \"circle.svg\",\n                \"circle-outline.svg\",\n                // flags\n                \"flag-variant.svg\",\n                \"flag-variant-outline.svg\",\n                \"flag-variant-off-outline.svg\",\n                // decks\n                \"book-outline.svg\",\n                \"book-clock-outline.svg\",\n                \"book-cog-outline.svg\",\n                // notetypes\n                \"newspaper.svg\",\n                // cardtype\n                \"application-braces-outline.svg\",\n                // fields\n                \"form-textbox.svg\",\n                // tags\n                \"tag-outline.svg\",\n                \"tag-off-outline.svg\",\n            ].iter().map(|file| format!(\"@mdi/svg/svg/{file}\").into()).collect(),\n            \"mdi_themed\" => [\n                // sidebar tools\n                \"magnify.svg\",\n                \"selection-drag.svg\",\n                // QComboBox arrows\n                \"chevron-up.svg\",\n                \"chevron-down.svg\",\n                // QHeaderView arrows\n                \"menu-up.svg\",\n                \"menu-down.svg\",\n                // drag handle\n                \"drag-vertical.svg\",\n                \"drag-horizontal.svg\",\n                // checkbox\n                \"check.svg\",\n                \"minus-thick.svg\",\n                // QRadioButton\n                \"circle-medium.svg\",\n            ].iter().map(|file| format!(\"@mdi/svg/svg/{file}\").into()).collect(),\n        },\n    )?;\n    Ok(())\n}\n\nfn build_and_check_tslib(build: &mut Build) -> Result<()> {\n    build.add_dependency(\"ts:generated:i18n\", \":rslib:i18n:ts\".into());\n    build.add_action(\n        \"ts:generated:proto\",\n        GenTypescriptProto {\n            protos: inputs![glob![\"proto/**/*.proto\"]],\n            include_dirs: &[\"proto\"],\n            out_dir: \"out/ts/lib/generated\",\n            out_path_transform: |path| {\n                path.replace(\"proto/\", \"ts/lib/generated/\")\n                    .replace(\"proto\\\\\", \"ts/lib/generated\\\\\")\n            },\n            ts_transform_script: \"ts/tools/markpure.ts\",\n        },\n    )?;\n    // ensure _service files are generated by rslib\n    build.add_dependency(\"ts:generated:proto\", inputs![\":rslib:proto:ts\"]);\n    // copy source files from ts/lib/generated\n    build.add_action(\n        \"ts:generated:src\",\n        CopyFiles {\n            inputs: inputs![glob![\"ts/lib/generated/*.ts\"]],\n            output_folder: \"ts/lib/generated\",\n        },\n    )?;\n\n    let src_files = inputs![glob![\"ts/lib/**\"]];\n\n    build.add_dependency(\"ts:lib\", inputs![\":ts:generated\"]);\n    build.add_dependency(\"ts:lib\", src_files);\n\n    Ok(())\n}\n\nfn declare_and_check_other_libraries(build: &mut Build) -> Result<()> {\n    for (library, inputs) in [\n        (\"sveltelib\", inputs![\":ts:lib\", glob!(\"ts/sveltelib/**\")]),\n        (\"domlib\", inputs![\":ts:lib\", glob!(\"ts/domlib/**\")]),\n        (\n            \"components\",\n            inputs![\":ts:lib\", \":ts:sveltelib\", glob!(\"ts/components/**\")],\n        ),\n        (\"html-filter\", inputs![glob!(\"ts/html-filter/**\")]),\n    ] {\n        let library_with_ts = format!(\"ts:{library}\");\n        build.add_dependency(&library_with_ts, inputs.clone());\n    }\n\n    Ok(())\n}\n\nfn build_and_check_pages(build: &mut Build) -> Result<()> {\n    let mut build_page = |name: &str, html: bool, deps: BuildInput| -> Result<()> {\n        let group = format!(\"ts:{name}\");\n        let deps = inputs![deps, glob!(format!(\"ts/{name}/**\"))];\n        let extra_exts = if html { &[\"css\", \"html\"][..] } else { &[\"css\"] };\n        let entrypoint = if html {\n            format!(\"ts/routes/{name}/index.ts\")\n        } else {\n            format!(\"ts/{name}/index.ts\")\n        };\n        build.add_action(\n            &group,\n            EsbuildScript {\n                script: inputs![\"ts/bundle_svelte.mjs\"],\n                entrypoint: inputs![entrypoint],\n                output_stem: &format!(\"ts/{name}/{name}\"),\n                deps: deps.clone(),\n                extra_exts,\n            },\n        )?;\n        build.add_dependency(\"ts:pages\", inputs![format!(\":{group}\")]);\n\n        Ok(())\n    };\n    // we use the generated .css file separately\n    build_page(\n        \"editable\",\n        false,\n        inputs![\n            //\n            \":ts:lib\",\n            \":ts:components\",\n            \":ts:domlib\",\n            \":ts:sveltelib\",\n            \":sass\",\n            \":sveltekit\",\n        ],\n    )?;\n    build_page(\n        \"congrats\",\n        true,\n        inputs![\n            //\n            \":ts:lib\",\n            \":ts:components\",\n            \":sass\",\n            \":sveltekit\"\n        ],\n    )?;\n\n    Ok(())\n}\n\nfn build_and_check_editor(build: &mut Build) -> Result<()> {\n    let editor_deps = inputs![\n        //\n        \":ts:lib\",\n        \":ts:components\",\n        \":ts:domlib\",\n        \":ts:sveltelib\",\n        \":ts:html-filter\",\n        \":sass\",\n        \":sveltekit\",\n        glob!(\"ts/{editable,editor,routes/image-occlusion}/**\")\n    ];\n\n    build.add_action(\n        \"ts:editor\",\n        EsbuildScript {\n            script: \"ts/bundle_svelte.mjs\".into(),\n            entrypoint: \"ts/editor/index.ts\".into(),\n            output_stem: \"ts/editor/editor\",\n            deps: editor_deps.clone(),\n            extra_exts: &[\"css\"],\n        },\n    )?;\n\n    Ok(())\n}\n\nfn build_and_check_reviewer(build: &mut Build) -> Result<()> {\n    let reviewer_deps = inputs![\n        \":ts:lib\",\n        glob!(\"ts/{reviewer,image-occlusion}/**\"),\n        \":sveltekit\"\n    ];\n    build.add_action(\n        \"ts:reviewer:reviewer.js\",\n        EsbuildScript {\n            script: inputs![\"ts/bundle_ts.mjs\"],\n            entrypoint: \"ts/reviewer/index_wrapper.ts\".into(),\n            output_stem: \"ts/reviewer/reviewer\",\n            deps: reviewer_deps.clone(),\n            extra_exts: &[],\n        },\n    )?;\n    build.add_action(\n        \"ts:reviewer:reviewer.css\",\n        CompileSass {\n            input: inputs![\"ts/reviewer/reviewer.scss\"],\n            output: \"ts/reviewer/reviewer.css\",\n            deps: inputs![\":sass\", \"ts/routes/image-occlusion/review.scss\"],\n            load_paths: vec![\".\"],\n        },\n    )?;\n    build.add_action(\n        \"ts:reviewer:reviewer_extras_bundle.js\",\n        EsbuildScript {\n            script: inputs![\"ts/bundle_ts.mjs\"],\n            entrypoint: \"ts/reviewer/reviewer_extras.ts\".into(),\n            output_stem: \"ts/reviewer/reviewer_extras_bundle\",\n            deps: reviewer_deps.clone(),\n            extra_exts: &[],\n        },\n    )?;\n    build.add_action(\n        \"ts:reviewer:reviewer_extras.css\",\n        CompileSass {\n            input: inputs![\"ts/reviewer/reviewer_extras.scss\"],\n            output: \"ts/reviewer/reviewer_extras.css\",\n            deps: inputs![\"ts/routes/image-occlusion/review.scss\"],\n            load_paths: vec![\".\"],\n        },\n    )?;\n\n    Ok(())\n}\n\nfn check_web(build: &mut Build) -> Result<()> {\n    let fmt_excluded = \"{target,ts/.svelte-kit,node_modules}/**\";\n    let dprint_files = inputs![glob![\"**/*.{ts,mjs,js,md,json,toml,scss}\", fmt_excluded]];\n    let prettier_files = inputs![glob![\"**/*.svelte\", fmt_excluded]];\n\n    build.add_action(\n        \"check:format:dprint\",\n        DPrint {\n            inputs: dprint_files.clone(),\n            check_only: true,\n        },\n    )?;\n    build.add_action(\n        \"format:dprint\",\n        DPrint {\n            inputs: dprint_files,\n            check_only: false,\n        },\n    )?;\n    build.add_action(\n        \"check:format:prettier\",\n        Prettier {\n            inputs: prettier_files.clone(),\n            check_only: true,\n        },\n    )?;\n    build.add_action(\n        \"format:prettier\",\n        Prettier {\n            inputs: prettier_files,\n            check_only: false,\n        },\n    )?;\n    build.add_action(\n        \"check:vitest\",\n        ViteTest {\n            deps: inputs![\n                \":node_modules\",\n                \":ts:generated\",\n                glob![\"ts/{svelte.config.js,vite.config.ts,tsconfig.json}\"],\n                glob![\"ts/{lib,deck-options,html-filter,domlib,reviewer,change-notetype}/**/*\"],\n            ],\n        },\n    )?;\n    build.add_action(\n        \"check:svelte\",\n        SvelteCheck {\n            tsconfig: inputs![\"ts/tsconfig.json\"],\n            inputs: inputs![\n                \":node_modules\",\n                \":ts:generated\",\n                glob![\"ts/**/*\", \"ts/.svelte-kit/**\"],\n            ],\n        },\n    )?;\n    let eslint_rc = inputs![\".eslintrc.cjs\"];\n    for folder in [\"ts\", \"qt/aqt/data/web/js\"] {\n        let inputs = inputs![glob![format!(\"{folder}/**\"), \"ts/.svelte-kit/**\"]];\n        build.add_action(\n            \"check:eslint\",\n            Eslint {\n                folder,\n                inputs: inputs.clone(),\n                eslint_rc: eslint_rc.clone(),\n                fix: false,\n            },\n        )?;\n        build.add_action(\n            \"fix:eslint\",\n            Eslint {\n                folder,\n                inputs,\n                eslint_rc: eslint_rc.clone(),\n                fix: true,\n            },\n        )?;\n    }\n\n    Ok(())\n}\n\npub fn check_sql(build: &mut Build) -> Result<()> {\n    build.add_action(\n        \"check:format:sql\",\n        SqlFormat {\n            inputs: inputs![glob![\"**/*.sql\"]],\n            check_only: true,\n        },\n    )?;\n    build.add_action(\n        \"format:sql\",\n        SqlFormat {\n            inputs: inputs![glob![\"**/*.sql\"]],\n            check_only: false,\n        },\n    )?;\n    Ok(())\n}\n\nfn build_and_check_mathjax(build: &mut Build) -> Result<()> {\n    let files = inputs![glob![\"ts/mathjax/*\"], \":sveltekit\"];\n    build.add_action(\n        \"ts:mathjax\",\n        EsbuildScript {\n            script: \"ts/transform_ts.mjs\".into(),\n            entrypoint: \"ts/mathjax/index.ts\".into(),\n            deps: files.clone(),\n            output_stem: \"ts/mathjax/mathjax\",\n            extra_exts: &[],\n        },\n    )\n}\n\npub const MATHJAX_FILES: &[&str] = &[\n    \"mathjax/es5/a11y/assistive-mml.js\",\n    \"mathjax/es5/a11y/complexity.js\",\n    \"mathjax/es5/a11y/explorer.js\",\n    \"mathjax/es5/a11y/semantic-enrich.js\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff\",\n    \"mathjax/es5/output/chtml/fonts/woff-v2/MathJax_Zero.woff\",\n    \"mathjax/es5/tex-chtml-full.js\",\n    \"mathjax/es5/sre/mathmaps/de.json\",\n    \"mathjax/es5/sre/mathmaps/en.json\",\n    \"mathjax/es5/sre/mathmaps/es.json\",\n    \"mathjax/es5/sre/mathmaps/fr.json\",\n    \"mathjax/es5/sre/mathmaps/hi.json\",\n    \"mathjax/es5/sre/mathmaps/it.json\",\n    \"mathjax/es5/sre/mathmaps/nemeth.json\",\n];\n\npub fn copy_mathjax() -> impl BuildAction {\n    RsyncFiles {\n        inputs: inputs![\":node_modules:mathjax\"],\n        target_folder: \"qt/_aqt/data/web/js/vendor/mathjax\",\n        strip_prefix: \"$builddir/node_modules/mathjax/es5\",\n        extra_args: \"\",\n    }\n}\n\nfn build_sass(build: &mut Build) -> Result<()> {\n    build.add_dependency(\"sass\", inputs![glob!(\"ts/lib/sass/**\")]);\n\n    build.add_action(\n        \"css:_root-vars\",\n        CompileSass {\n            input: inputs![\"ts/lib/sass/_root-vars.scss\"],\n            output: \"ts/lib/sass/_root-vars.css\",\n            deps: inputs![glob![\"ts/lib/sass/*\"]],\n            load_paths: vec![],\n        },\n    )?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "build/ninja_gen/Cargo.toml",
    "content": "[package]\nname = \"ninja_gen\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanki_io.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\ndunce.workspace = true\nglobset.workspace = true\nitertools.workspace = true\nmaplit.workspace = true\nnum_cpus.workspace = true\nregex.workspace = true\nserde_json.workspace = true\nsha2.workspace = true\nwalkdir.workspace = true\nwhich.workspace = true\n\n[target.'cfg(windows)'.dependencies]\nreqwest = { workspace = true, features = [\"blocking\", \"json\", \"native-tls\"] }\n\n[target.'cfg(not(windows))'.dependencies]\nreqwest = { workspace = true, features = [\"blocking\", \"json\", \"rustls-tls\"] }\n\n[[bin]]\nname = \"update_uv\"\npath = \"src/bin/update_uv.rs\"\n\n[[bin]]\nname = \"update_protoc\"\npath = \"src/bin/update_protoc.rs\"\n\n[[bin]]\nname = \"update_node\"\npath = \"src/bin/update_node.rs\"\n"
  },
  {
    "path": "build/ninja_gen/src/action.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\n\nuse crate::build::FilesHandle;\nuse crate::Build;\n\npub trait BuildAction {\n    /// Command line to invoke for each build statement.\n    fn command(&self) -> &str;\n\n    /// Declare the input files and variables, and output files.\n    fn files(&mut self, build: &mut impl FilesHandle);\n\n    /// If true, this action will not trigger a rebuild of dependent targets if\n    /// the output files are unchanged. This corresponds to Ninja's \"restat\"\n    /// argument.\n    fn check_output_timestamps(&self) -> bool {\n        false\n    }\n\n    /// True if this rule generates build.ninja\n    fn generator(&self) -> bool {\n        false\n    }\n\n    /// Called on first action invocation; can be used to inject other build\n    /// actions to perform initial setup.\n    #[allow(unused_variables)]\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        Ok(())\n    }\n\n    fn concurrency_pool(&self) -> Option<&'static str> {\n        None\n    }\n\n    fn bypass_runner(&self) -> bool {\n        false\n    }\n\n    fn hide_success(&self) -> bool {\n        true\n    }\n\n    fn hide_progress(&self) -> bool {\n        false\n    }\n\n    fn name(&self) -> &'static str {\n        std::any::type_name::<Self>()\n            .split(\"::\")\n            .last()\n            .unwrap()\n            .split('<')\n            .next()\n            .unwrap()\n    }\n}\n\n#[cfg(test)]\ntrait TestBuildAction {}\n\n#[cfg(test)]\nimpl<T: TestBuildAction + ?Sized> BuildAction for T {\n    fn command(&self) -> &str {\n        \"test\"\n    }\n    fn files(&mut self, _build: &mut impl FilesHandle) {}\n}\n\n#[allow(dead_code, unused_variables)]\n#[test]\nfn should_strip_regions_in_type_name() {\n    struct Bare;\n    impl TestBuildAction for Bare {}\n    assert_eq!(Bare {}.name(), \"Bare\");\n\n    struct WithLifeTime<'a>(&'a str);\n    impl TestBuildAction for WithLifeTime<'_> {}\n    assert_eq!(WithLifeTime(\"test\").name(), \"WithLifeTime\");\n\n    struct WithMultiLifeTime<'a, 'b>(&'a str, &'b str);\n    impl TestBuildAction for WithMultiLifeTime<'_, '_> {}\n    assert_eq!(\n        WithMultiLifeTime(\"test\", \"test\").name(),\n        \"WithMultiLifeTime\"\n    );\n\n    struct WithGeneric<T>(T);\n    impl<T> TestBuildAction for WithGeneric<T> {}\n    assert_eq!(WithGeneric(3).name(), \"WithGeneric\");\n}\n"
  },
  {
    "path": "build/ninja_gen/src/archives.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\n\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\n\nuse crate::action::BuildAction;\nuse crate::input::BuildInput;\nuse crate::inputs;\nuse crate::Build;\n\n#[derive(Clone, Copy, Debug)]\npub struct OnlineArchive {\n    pub url: &'static str,\n    pub sha256: &'static str,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\npub enum Platform {\n    LinuxX64,\n    LinuxArm,\n    MacX64,\n    MacArm,\n    WindowsX64,\n    WindowsArm,\n}\n\nimpl Platform {\n    pub fn current() -> Self {\n        let os = std::env::consts::OS;\n        let arch = std::env::consts::ARCH;\n        match (os, arch) {\n            (\"linux\", \"x86_64\") => Self::LinuxX64,\n            (\"linux\", \"aarch64\") => Self::LinuxArm,\n            (\"macos\", \"x86_64\") => Self::MacX64,\n            (\"macos\", \"aarch64\") => Self::MacArm,\n            (\"windows\", \"x86_64\") => Self::WindowsX64,\n            (\"windows\", \"aarch64\") => Self::WindowsArm,\n            _ => panic!(\"unsupported os/arch {os} {arch} - PR welcome!\"),\n        }\n    }\n\n    pub fn tls_feature() -> &'static str {\n        match Self::current() {\n            // On Linux, wheels are not allowed to link to OpenSSL, and linking setup\n            // caused pain for AnkiDroid in the past. On other platforms, we stick to\n            // native libraries, for smaller binaries.\n            Platform::LinuxX64 | Platform::LinuxArm => \"rustls\",\n            _ => \"native-tls\",\n        }\n    }\n\n    pub fn as_rust_triple(&self) -> &'static str {\n        match self {\n            Platform::LinuxX64 => \"x86_64-unknown-linux-gnu\",\n            Platform::LinuxArm => \"aarch64-unknown-linux-gnu\",\n            Platform::MacX64 => \"x86_64-apple-darwin\",\n            Platform::MacArm => \"aarch64-apple-darwin\",\n            Platform::WindowsX64 => \"x86_64-pc-windows-msvc\",\n            Platform::WindowsArm => \"aarch64-pc-windows-msvc\",\n        }\n    }\n}\n\n/// Append .exe to path if on Windows.\npub fn with_exe(path: &str) -> Cow<'_, str> {\n    if cfg!(windows) {\n        format!(\"{path}.exe\").into()\n    } else {\n        path.into()\n    }\n}\n\nstruct DownloadArchive {\n    pub archive: OnlineArchive,\n}\n\nimpl BuildAction for DownloadArchive {\n    fn command(&self) -> &str {\n        \"$runner archive download $url $checksum $out\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        let (_, filename) = self.archive.url.rsplit_once('/').unwrap();\n        let output_path = Utf8Path::new(\"download\").join(filename);\n\n        build.add_variable(\"url\", self.archive.url);\n        build.add_variable(\"checksum\", self.archive.sha256);\n        build.add_outputs(\"out\", &[output_path.into_string()])\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n\nstruct ExtractArchive<'a, I> {\n    pub archive_path: BuildInput,\n    /// The folder that the archive should be extracted into, relative to\n    /// $builddir/extracted. If the archive contains a single top-level\n    /// folder, its contents will be extracted into the provided folder, so\n    /// that output like tool-1.2/ can be extracted into tool/.\n    pub extraction_folder_name: &'a str,\n    /// Files contained inside the archive, relative to the archive root, and\n    /// excluding the top-level folder if it is the sole top-level entry.\n    /// Any files you wish to use as part of subsequent rules\n    /// must be declared here.\n    pub file_manifest: HashMap<&'static str, I>,\n}\n\nimpl<I> ExtractArchive<'_, I> {\n    fn extraction_folder(&self) -> Utf8PathBuf {\n        Utf8Path::new(\"$builddir\")\n            .join(\"extracted\")\n            .join(self.extraction_folder_name)\n    }\n}\n\nimpl<I> BuildAction for ExtractArchive<'_, I>\nwhere\n    I: IntoIterator,\n    I::Item: AsRef<str>,\n{\n    fn command(&self) -> &str {\n        \"$runner archive extract $in $extraction_folder\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"in\", inputs![self.archive_path.clone()]);\n\n        let folder = self.extraction_folder();\n        build.add_variable(\"extraction_folder\", folder.to_string());\n        for (subgroup, files) in self.file_manifest.drain() {\n            build.add_outputs_ext(\n                subgroup,\n                files\n                    .into_iter()\n                    .map(|f| folder.join(f.as_ref()).to_string()),\n                !subgroup.is_empty(),\n            );\n        }\n        build.add_output_stamp(folder.with_extension(\"marker\"));\n    }\n\n    fn name(&self) -> &'static str {\n        \"extract\"\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n\n/// See [DownloadArchive] and [ExtractArchive].\npub fn download_and_extract<I>(\n    build: &mut Build,\n    group_name: &str,\n    archive: OnlineArchive,\n    file_manifest: HashMap<&'static str, I>,\n) -> Result<()>\nwhere\n    I: IntoIterator,\n    I::Item: AsRef<str>,\n{\n    let download_group = format!(\"download:{group_name}\");\n    build.add_action(&download_group, DownloadArchive { archive })?;\n\n    let extract_group = format!(\"extract:{group_name}\");\n    build.add_action(\n        extract_group,\n        ExtractArchive {\n            archive_path: inputs![format!(\":{download_group}\")],\n            extraction_folder_name: group_name,\n            file_manifest,\n        },\n    )?;\n    Ok(())\n}\n\npub fn empty_manifest() -> HashMap<&'static str, &'static [&'static str]> {\n    Default::default()\n}\n"
  },
  {
    "path": "build/ninja_gen/src/bin/update_node.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::error::Error;\nuse std::fs;\nuse std::path::Path;\n\nuse regex::Regex;\nuse reqwest::blocking::Client;\nuse serde_json::Value;\n\n#[derive(Debug)]\nstruct NodeRelease {\n    version: String,\n    files: Vec<NodeFile>,\n}\n\n#[derive(Debug)]\nstruct NodeFile {\n    filename: String,\n    url: String,\n}\n\nfn main() -> Result<(), Box<dyn Error>> {\n    let release_info = fetch_node_release_info()?;\n    let new_text = generate_node_archive_function(&release_info)?;\n    update_node_text(&new_text)?;\n    println!(\"Node.js archive function updated successfully!\");\n    Ok(())\n}\n\nfn fetch_node_release_info() -> Result<NodeRelease, Box<dyn Error>> {\n    let client = Client::new();\n\n    // Get the Node.js release info\n    let response = client\n        .get(\"https://nodejs.org/dist/index.json\")\n        .header(\"User-Agent\", \"anki-build-updater\")\n        .send()?;\n\n    let releases: Vec<Value> = response.json()?;\n\n    // Find the latest LTS release\n    let latest = releases\n        .iter()\n        .find(|release| {\n            // LTS releases have a non-false \"lts\" field\n            release[\"lts\"].as_str().is_some() && release[\"lts\"] != false\n        })\n        .ok_or(\"No LTS releases found\")?;\n\n    let version = latest[\"version\"]\n        .as_str()\n        .ok_or(\"Version not found\")?\n        .to_string();\n\n    let files = latest[\"files\"]\n        .as_array()\n        .ok_or(\"Files array not found\")?\n        .iter()\n        .map(|f| f.as_str().unwrap_or(\"\"))\n        .collect::<Vec<_>>();\n\n    let lts_name = latest[\"lts\"].as_str().unwrap_or(\"unknown\");\n    println!(\"Found Node.js LTS version: {version} ({lts_name})\");\n\n    // Map platforms to their expected file keys and full filenames\n    let platform_mapping = vec![\n        (\n            \"linux-x64\",\n            \"linux-x64\",\n            format!(\"node-{version}-linux-x64.tar.xz\"),\n        ),\n        (\n            \"linux-arm64\",\n            \"linux-arm64\",\n            format!(\"node-{version}-linux-arm64.tar.xz\"),\n        ),\n        (\n            \"darwin-x64\",\n            \"osx-x64-tar\",\n            format!(\"node-{version}-darwin-x64.tar.xz\"),\n        ),\n        (\n            \"darwin-arm64\",\n            \"osx-arm64-tar\",\n            format!(\"node-{version}-darwin-arm64.tar.xz\"),\n        ),\n        (\n            \"win-x64\",\n            \"win-x64-zip\",\n            format!(\"node-{version}-win-x64.zip\"),\n        ),\n        (\n            \"win-arm64\",\n            \"win-arm64-zip\",\n            format!(\"node-{version}-win-arm64.zip\"),\n        ),\n    ];\n\n    let mut node_files = Vec::new();\n\n    for (platform, file_key, filename) in platform_mapping {\n        // Check if this file exists in the release\n        if files.contains(&file_key) {\n            let url = format!(\"https://nodejs.org/dist/{version}/{filename}\");\n            node_files.push(NodeFile {\n                filename: filename.clone(),\n                url,\n            });\n            println!(\"Found file for {platform}: {filename} (key: {file_key})\");\n        } else {\n            return Err(\n                format!(\"File not found for {platform} (key: {file_key}): {filename}\").into(),\n            );\n        }\n    }\n\n    Ok(NodeRelease {\n        version,\n        files: node_files,\n    })\n}\n\nfn generate_node_archive_function(release: &NodeRelease) -> Result<String, Box<dyn Error>> {\n    let client = Client::new();\n\n    // Fetch the SHASUMS256.txt file once\n    println!(\"Fetching SHA256 checksums...\");\n    let shasums_url = format!(\"https://nodejs.org/dist/{}/SHASUMS256.txt\", release.version);\n    let shasums_response = client\n        .get(&shasums_url)\n        .header(\"User-Agent\", \"anki-build-updater\")\n        .send()?;\n    let shasums_text = shasums_response.text()?;\n\n    // Create a mapping from filename patterns to platform names - using the exact\n    // patterns we stored in files\n    let platform_mapping = vec![\n        (\"linux-x64.tar.xz\", \"LinuxX64\"),\n        (\"linux-arm64.tar.xz\", \"LinuxArm\"),\n        (\"darwin-x64.tar.xz\", \"MacX64\"),\n        (\"darwin-arm64.tar.xz\", \"MacArm\"),\n        (\"win-x64.zip\", \"WindowsX64\"),\n        (\"win-arm64.zip\", \"WindowsArm\"),\n    ];\n\n    let mut platform_blocks = Vec::new();\n\n    for (file_pattern, platform_name) in platform_mapping {\n        // Find the file that ends with this pattern\n        if let Some(file) = release\n            .files\n            .iter()\n            .find(|f| f.filename.ends_with(file_pattern))\n        {\n            // Find the SHA256 for this file\n            let sha256 = shasums_text\n                .lines()\n                .find(|line| line.contains(&file.filename))\n                .and_then(|line| line.split_whitespace().next())\n                .ok_or_else(|| format!(\"SHA256 not found for {}\", file.filename))?;\n\n            println!(\n                \"Found SHA256 for {}: {} => {}\",\n                platform_name, file.filename, sha256\n            );\n\n            let block = format!(\n                \"        Platform::{} => OnlineArchive {{\\n            url: \\\"{}\\\",\\n            sha256: \\\"{}\\\",\\n        }},\",\n                platform_name, file.url, sha256\n            );\n            platform_blocks.push(block);\n        } else {\n            return Err(format!(\n                \"File not found for platform {platform_name}: no file ending with {file_pattern}\"\n            )\n            .into());\n        }\n    }\n\n    let function = format!(\n        \"pub fn node_archive(platform: Platform) -> OnlineArchive {{\\n    match platform {{\\n{}\\n    }}\\n}}\",\n        platform_blocks.join(\"\\n\")\n    );\n\n    Ok(function)\n}\n\nfn update_node_text(new_function: &str) -> Result<(), Box<dyn Error>> {\n    let node_rs_content = read_node_rs()?;\n\n    // Regex to match the entire node_archive function with proper multiline\n    // matching\n    let re = Regex::new(\n        r\"(?s)pub fn node_archive\\(platform: Platform\\) -> OnlineArchive \\{.*?\\n\\s*\\}\\s*\\n\\s*\\}\",\n    )?;\n\n    let updated_content = re.replace(&node_rs_content, new_function);\n\n    write_node_rs(&updated_content)?;\n    Ok(())\n}\n\nfn read_node_rs() -> Result<String, Box<dyn Error>> {\n    // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs\n    let manifest_dir =\n        std::env::var(\"CARGO_MANIFEST_DIR\").map_err(|_| \"CARGO_MANIFEST_DIR not set\")?;\n    let path = Path::new(&manifest_dir).join(\"src\").join(\"node.rs\");\n    Ok(fs::read_to_string(path)?)\n}\n\nfn write_node_rs(content: &str) -> Result<(), Box<dyn Error>> {\n    // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs\n    let manifest_dir =\n        std::env::var(\"CARGO_MANIFEST_DIR\").map_err(|_| \"CARGO_MANIFEST_DIR not set\")?;\n    let path = Path::new(&manifest_dir).join(\"src\").join(\"node.rs\");\n    fs::write(path, content)?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_regex_replacement() {\n        let sample_content = r#\"Some other code\npub fn node_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.xz\",\n            sha256: \"old_hash\",\n        },\n        Platform::MacX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-x64.tar.xz\",\n            sha256: \"old_hash\",\n        },\n    }\n}\n\nMore code here\"#;\n\n        let new_function = r#\"pub fn node_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v21.0.0/node-v21.0.0-linux-x64.tar.xz\",\n            sha256: \"new_hash\",\n        },\n        Platform::MacX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v21.0.0/node-v21.0.0-darwin-x64.tar.xz\",\n            sha256: \"new_hash\",\n        },\n    }\n}\"#;\n\n        let re = Regex::new(\n            r\"(?s)pub fn node_archive\\(platform: Platform\\) -> OnlineArchive \\{.*?\\n\\s*\\}\\s*\\n\\s*\\}\"\n        ).unwrap();\n\n        let result = re.replace(sample_content, new_function);\n        assert!(result.contains(\"v21.0.0\"));\n        assert!(result.contains(\"new_hash\"));\n        assert!(!result.contains(\"old_hash\"));\n        assert!(result.contains(\"Some other code\"));\n        assert!(result.contains(\"More code here\"));\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/bin/update_protoc.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::error::Error;\nuse std::fs;\nuse std::path::Path;\n\nuse regex::Regex;\nuse reqwest::blocking::Client;\nuse serde_json::Value;\nuse sha2::Digest;\nuse sha2::Sha256;\n\nfn fetch_protoc_release_info() -> Result<String, Box<dyn Error>> {\n    let client = Client::new();\n\n    println!(\"Fetching latest protoc release info from GitHub...\");\n    // Fetch latest release info\n    let response = client\n        .get(\"https://api.github.com/repos/protocolbuffers/protobuf/releases/latest\")\n        .header(\"User-Agent\", \"Anki-Build-Script\")\n        .send()?;\n\n    let release_info: Value = response.json()?;\n    let assets = release_info[\"assets\"]\n        .as_array()\n        .expect(\"assets should be an array\");\n\n    // Map platform names to their corresponding asset patterns\n    let platform_patterns = [\n        (\"LinuxX64\", \"linux-x86_64\"),\n        (\"LinuxArm\", \"linux-aarch_64\"),\n        (\"MacX64\", \"osx-universal_binary\"), // Mac uses universal binary for both\n        (\"MacArm\", \"osx-universal_binary\"),\n        (\"WindowsX64\", \"win64\"), // Windows uses x86 binary for both archs\n        (\"WindowsArm\", \"win64\"),\n    ];\n\n    let mut match_blocks = Vec::new();\n\n    for (platform, pattern) in platform_patterns {\n        // Find the asset matching the platform pattern\n        let asset = assets.iter().find(|asset| {\n            let name = asset[\"name\"].as_str().unwrap_or(\"\");\n            name.starts_with(\"protoc-\") && name.contains(pattern) && name.ends_with(\".zip\")\n        });\n\n        if asset.is_none() {\n            eprintln!(\"No asset found for platform {platform} pattern {pattern}\");\n            continue;\n        }\n\n        let asset = asset.unwrap();\n        let download_url = asset[\"browser_download_url\"].as_str().unwrap();\n        let asset_name = asset[\"name\"].as_str().unwrap();\n\n        // Download the file and calculate SHA256 locally\n        println!(\"Downloading and checksumming {asset_name} for {platform}...\");\n        let response = client\n            .get(download_url)\n            .header(\"User-Agent\", \"Anki-Build-Script\")\n            .send()?;\n\n        let bytes = response.bytes()?;\n        let mut hasher = Sha256::new();\n        hasher.update(&bytes);\n        let sha256 = format!(\"{:x}\", hasher.finalize());\n\n        // Handle platform-specific match patterns\n        let match_pattern = match platform {\n            \"MacX64\" => \"Platform::MacX64 | Platform::MacArm\",\n            \"MacArm\" => continue, // Skip MacArm since it's handled with MacX64\n            \"WindowsX64\" => \"Platform::WindowsX64 | Platform::WindowsArm\",\n            \"WindowsArm\" => continue, // Skip WindowsArm since it's handled with WindowsX64\n            _ => &format!(\"Platform::{platform}\"),\n        };\n\n        match_blocks.push(format!(\n            \"        {match_pattern} => {{\\n            OnlineArchive {{\\n                url: \\\"{download_url}\\\",\\n                sha256: \\\"{sha256}\\\",\\n            }}\\n        }}\"\n        ));\n    }\n\n    Ok(format!(\n        \"pub fn protoc_archive(platform: Platform) -> OnlineArchive {{\\n    match platform {{\\n{}\\n    }}\\n}}\",\n        match_blocks.join(\",\\n\")\n    ))\n}\n\nfn read_protobuf_rs() -> Result<String, Box<dyn Error>> {\n    let manifest_dir = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap_or_else(|_| \".\".to_string());\n    let path = Path::new(&manifest_dir).join(\"src/protobuf.rs\");\n    println!(\"Reading {}\", path.display());\n    let content = fs::read_to_string(path)?;\n    Ok(content)\n}\n\nfn update_protoc_text(old_text: &str, new_protoc_text: &str) -> Result<String, Box<dyn Error>> {\n    let re =\n        Regex::new(r\"(?ms)^pub fn protoc_archive\\(platform: Platform\\) -> OnlineArchive \\{.*?\\n\\}\")\n            .unwrap();\n    if !re.is_match(old_text) {\n        return Err(\"Could not find protoc_archive function block to replace\".into());\n    }\n    let new_content = re.replace(old_text, new_protoc_text).to_string();\n    println!(\"Original lines: {}\", old_text.lines().count());\n    println!(\"Updated lines: {}\", new_content.lines().count());\n    Ok(new_content)\n}\n\nfn write_protobuf_rs(content: &str) -> Result<(), Box<dyn Error>> {\n    let manifest_dir = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap_or_else(|_| \".\".to_string());\n    let path = Path::new(&manifest_dir).join(\"src/protobuf.rs\");\n    println!(\"Writing to {}\", path.display());\n    fs::write(path, content)?;\n    Ok(())\n}\n\nfn main() -> Result<(), Box<dyn Error>> {\n    let new_protoc_archive = fetch_protoc_release_info()?;\n    let content = read_protobuf_rs()?;\n    let updated_content = update_protoc_text(&content, &new_protoc_archive)?;\n    write_protobuf_rs(&updated_content)?;\n    println!(\"Successfully updated protoc_archive function in protobuf.rs\");\n    Ok(())\n}\n"
  },
  {
    "path": "build/ninja_gen/src/bin/update_uv.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::error::Error;\nuse std::fs;\nuse std::path::Path;\n\nuse regex::Regex;\nuse reqwest::blocking::Client;\nuse serde_json::Value;\n\nfn fetch_uv_release_info() -> Result<String, Box<dyn Error>> {\n    let client = Client::new();\n\n    println!(\"Fetching latest uv release info from GitHub...\");\n    // Fetch latest release info\n    let response = client\n        .get(\"https://api.github.com/repos/astral-sh/uv/releases/latest\")\n        .header(\"User-Agent\", \"Anki-Build-Script\")\n        .send()?;\n\n    let release_info: Value = response.json()?;\n    let assets = release_info[\"assets\"]\n        .as_array()\n        .expect(\"assets should be an array\");\n\n    // Map platform names to their corresponding asset patterns\n    let platform_patterns = [\n        (\"LinuxX64\", \"x86_64-unknown-linux-gnu\"),\n        (\"LinuxArm\", \"aarch64-unknown-linux-gnu\"),\n        (\"MacX64\", \"x86_64-apple-darwin\"),\n        (\"MacArm\", \"aarch64-apple-darwin\"),\n        (\"WindowsX64\", \"x86_64-pc-windows-msvc\"),\n        (\"WindowsArm\", \"aarch64-pc-windows-msvc\"),\n    ];\n\n    let mut match_blocks = Vec::new();\n\n    for (platform, pattern) in platform_patterns {\n        // Find the asset matching the platform pattern (the binary)\n        let asset = assets.iter().find(|asset| {\n            let name = asset[\"name\"].as_str().unwrap_or(\"\");\n            name.contains(pattern) && (name.ends_with(\".tar.gz\") || name.ends_with(\".zip\"))\n        });\n        if asset.is_none() {\n            eprintln!(\"No asset found for platform {platform} pattern {pattern}\");\n            continue;\n        }\n        let asset = asset.unwrap();\n        let download_url = asset[\"browser_download_url\"].as_str().unwrap();\n        let asset_name = asset[\"name\"].as_str().unwrap();\n\n        // Find the corresponding .sha256 or .sha256sum asset\n        let sha_asset = assets.iter().find(|a| {\n            let name = a[\"name\"].as_str().unwrap_or(\"\");\n            name == format!(\"{asset_name}.sha256\") || name == format!(\"{asset_name}.sha256sum\")\n        });\n        if sha_asset.is_none() {\n            eprintln!(\"No sha256 asset found for {asset_name}\");\n            continue;\n        }\n        let sha_asset = sha_asset.unwrap();\n        let sha_url = sha_asset[\"browser_download_url\"].as_str().unwrap();\n        println!(\"Fetching SHA256 for {platform}...\");\n        let sha_text = client\n            .get(sha_url)\n            .header(\"User-Agent\", \"Anki-Build-Script\")\n            .send()?\n            .text()?;\n        // The sha file is usually of the form: \"<sha256>  <filename>\"\n        let sha256 = sha_text.split_whitespace().next().unwrap_or(\"\");\n\n        match_blocks.push(format!(\n            \"        Platform::{platform} => {{\\n            OnlineArchive {{\\n                url: \\\"{download_url}\\\",\\n                sha256: \\\"{sha256}\\\",\\n            }}\\n        }}\"\n        ));\n    }\n\n    Ok(format!(\n        \"pub fn uv_archive(platform: Platform) -> OnlineArchive {{\\n    match platform {{\\n{}\\n    }}\",\n        match_blocks.join(\",\\n\")\n    ))\n}\n\nfn read_python_rs() -> Result<String, Box<dyn Error>> {\n    let manifest_dir = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap_or_else(|_| \".\".to_string());\n    let path = Path::new(&manifest_dir).join(\"src/python.rs\");\n    println!(\"Reading {}\", path.display());\n    let content = fs::read_to_string(path)?;\n    Ok(content)\n}\n\nfn update_uv_text(old_text: &str, new_uv_text: &str) -> Result<String, Box<dyn Error>> {\n    let re = Regex::new(r\"(?ms)^pub fn uv_archive\\(platform: Platform\\) -> OnlineArchive \\{.*?\\n\\s*\\}\\s*\\n\\s*\\}\\s*\\n\\s*\\}\").unwrap();\n    if !re.is_match(old_text) {\n        return Err(\"Could not find uv_archive function block to replace\".into());\n    }\n    let new_content = re.replace(old_text, new_uv_text).to_string();\n    println!(\"Original lines: {}\", old_text.lines().count());\n    println!(\"Updated lines: {}\", new_content.lines().count());\n    Ok(new_content)\n}\n\nfn write_python_rs(content: &str) -> Result<(), Box<dyn Error>> {\n    let manifest_dir = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap_or_else(|_| \".\".to_string());\n    let path = Path::new(&manifest_dir).join(\"src/python.rs\");\n    println!(\"Writing to {}\", path.display());\n    fs::write(path, content)?;\n    Ok(())\n}\n\nfn main() -> Result<(), Box<dyn Error>> {\n    let new_uv_archive = fetch_uv_release_info()?;\n    let content = read_python_rs()?;\n    let updated_content = update_uv_text(&content, &new_uv_archive)?;\n    write_python_rs(&updated_content)?;\n    println!(\"Successfully updated uv_archive function in python.rs\");\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_update_uv_text_with_actual_file() {\n        let content = fs::read_to_string(\"src/python.rs\").unwrap();\n        let original_lines = content.lines().count();\n\n        const EXPECTED_LINES_REMOVED: usize = 38;\n\n        let updated = update_uv_text(&content, \"\").unwrap();\n        let updated_lines = updated.lines().count();\n\n        assert_eq!(\n            updated_lines,\n            original_lines - EXPECTED_LINES_REMOVED,\n            \"Expected line count to decrease by exactly {EXPECTED_LINES_REMOVED} lines (original: {original_lines}, updated: {updated_lines})\"\n        );\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::fmt::Write;\n\nuse anyhow::Result;\nuse camino::Utf8PathBuf;\nuse itertools::Itertools;\n\nuse crate::action::BuildAction;\nuse crate::archives::Platform;\nuse crate::configure::ConfigureBuild;\nuse crate::input::space_separated;\nuse crate::input::BuildInput;\n\n#[derive(Debug)]\npub struct Build {\n    pub variables: HashMap<&'static str, String>,\n    pub buildroot: Utf8PathBuf,\n    pub build_profile: BuildProfile,\n    pub pools: Vec<(&'static str, usize)>,\n    pub trailing_text: String,\n    pub host_platform: Platform,\n    pub have_n2: bool,\n\n    pub(crate) output_text: String,\n    action_names: HashSet<&'static str>,\n    pub(crate) groups: HashMap<String, Vec<String>>,\n}\n\nimpl Build {\n    pub fn new() -> Result<Self> {\n        let buildroot = if cfg!(windows) {\n            Utf8PathBuf::from(\"out\")\n        } else {\n            // on Unix systems we allow out to be a symlink to an external location\n            Utf8PathBuf::from(\"out\").canonicalize_utf8()?\n        };\n\n        let mut build = Build {\n            buildroot,\n            build_profile: BuildProfile::from_env(),\n            host_platform: Platform::current(),\n            variables: Default::default(),\n            pools: Default::default(),\n            trailing_text: Default::default(),\n            output_text: Default::default(),\n            action_names: Default::default(),\n            groups: Default::default(),\n            have_n2: which::which(\"n2\").is_ok(),\n        };\n\n        build.add_action(\"build:configure\", ConfigureBuild {})?;\n\n        Ok(build)\n    }\n\n    pub fn variable(&mut self, name: &'static str, value: impl Into<String>) {\n        self.variables.insert(name, value.into());\n    }\n\n    pub fn pool(&mut self, name: &'static str, size: usize) {\n        self.pools.push((name, size));\n    }\n\n    /// Evaluate the provided closure only once, using `key` to determine\n    /// uniqueness. This key should not match any build action name.\n    pub fn once_only(\n        &mut self,\n        key: &'static str,\n        block: impl FnOnce(&mut Build) -> Result<()>,\n    ) -> Result<()> {\n        if self.action_names.insert(key) {\n            block(self)\n        } else {\n            Ok(())\n        }\n    }\n\n    pub fn add_action(&mut self, group: impl AsRef<str>, action: impl BuildAction) -> Result<()> {\n        let group = group.as_ref();\n        let groups = split_groups(group);\n        let group = groups[0];\n        let command = action.command();\n\n        let action_name = action.name();\n        // first invocation?\n        let mut first_invocation = false;\n        self.once_only(action_name, |build| {\n            action.on_first_instance(build)?;\n            first_invocation = true;\n            Ok(())\n        })?;\n\n        let action_name = action_name.to_string();\n\n        // ensure separator is delivered to runner, not shell\n        let command = if cfg!(windows) || action.bypass_runner() {\n            command.into()\n        } else {\n            command.replace(\"&&\", \"\\\"&&\\\"\")\n        };\n\n        let mut statement = BuildStatement::from_build_action(\n            group,\n            action,\n            &self.groups,\n            self.build_profile,\n            self.have_n2,\n        );\n\n        if first_invocation {\n            let command = statement.prepare_command(command)?;\n            writeln!(\n                &mut self.output_text,\n                \"\\\nrule {action_name}\n  command = {command}\",\n            )\n            .unwrap();\n            for (k, v) in &statement.rule_variables {\n                writeln!(&mut self.output_text, \"  {k} = {v}\").unwrap();\n            }\n            self.output_text.push('\\n');\n        }\n\n        let (all_outputs, subgroups) = statement.render_into(&mut self.output_text);\n        for group in groups {\n            self.add_resolved_files_to_group(group, &all_outputs);\n        }\n        for (subgroup, outputs) in subgroups {\n            let group_with_subgroup = format!(\"{group}:{subgroup}\");\n            self.add_resolved_files_to_group(&group_with_subgroup, &outputs);\n        }\n\n        Ok(())\n    }\n\n    /// Add one or more resolved files to a group. Does not add to the parent\n    /// groups; that must be done by the caller.\n    fn add_resolved_files_to_group<'a>(\n        &mut self,\n        group: &str,\n        files: impl IntoIterator<Item = &'a String>,\n    ) {\n        let buf = self.groups.entry(group.to_owned()).or_default();\n        buf.extend(files.into_iter().map(ToString::to_string));\n    }\n\n    /// Allows you to add dependencies on files or build steps that aren't\n    /// required to build the group itself, but are required by consumers of\n    /// that group. Can also be used to allow substitution of local binaries\n    /// for downloaded ones (eg :node_binary).\n    pub fn add_dependency(&mut self, group: &str, deps: BuildInput) {\n        let files = self.expand_inputs(deps);\n        let groups = split_groups(group);\n        for group in groups {\n            self.add_resolved_files_to_group(group, &files);\n        }\n    }\n\n    /// Outputs from a given build statement group. An error if no files have\n    /// been registered yet.\n    pub fn group_outputs(&self, group_name: &'static str) -> &[String] {\n        self.groups\n            .get(group_name)\n            .unwrap_or_else(|| panic!(\"expected files in {group_name}\"))\n    }\n\n    /// Single output from a given build statement group. An error if no files\n    /// have been registered yet, or more than one file has been registered.\n    pub fn group_output(&self, group_name: &'static str) -> String {\n        let outputs = self.group_outputs(group_name);\n        assert_eq!(outputs.len(), 1);\n        outputs.first().unwrap().into()\n    }\n\n    pub fn expand_inputs(&self, inputs: impl AsRef<BuildInput>) -> Vec<String> {\n        expand_inputs(inputs, &self.groups)\n    }\n\n    /// Expand inputs, the return a filtered subset.\n    pub fn filter_inputs<F>(&self, inputs: impl AsRef<BuildInput>, func: F) -> Vec<String>\n    where\n        F: FnMut(&String) -> bool,\n    {\n        self.expand_inputs(inputs)\n            .into_iter()\n            .filter(func)\n            .collect()\n    }\n\n    pub fn inputs_with_suffix(&self, inputs: impl AsRef<BuildInput>, ext: &str) -> Vec<String> {\n        self.filter_inputs(inputs, |f| f.ends_with(ext))\n    }\n}\n\nfn split_groups(group: &str) -> Vec<&str> {\n    let mut rest = group;\n    let mut groups = vec![group];\n    while let Some((head, _tail)) = rest.rsplit_once(':') {\n        groups.push(head);\n        rest = head;\n    }\n    groups\n}\n\nstruct BuildStatement<'a> {\n    /// Cache of outputs by already-evaluated build rules, allowing later rules\n    /// to more easily consume the outputs of previous rules.\n    existing_outputs: &'a HashMap<String, Vec<String>>,\n    rule_name: &'static str,\n    // implicit refers to files that are not automatically assigned to $in and $out by Ninja,\n    implicit_inputs: Vec<String>,\n    implicit_outputs: Vec<String>,\n    explicit_inputs: Vec<String>,\n    explicit_outputs: Vec<String>,\n    order_only_inputs: Vec<String>,\n    output_subsets: Vec<(String, Vec<String>)>,\n    variables: Vec<(String, String)>,\n    rule_variables: Vec<(String, String)>,\n    output_stamp: bool,\n    env_vars: Vec<String>,\n    working_dir: Option<String>,\n    create_dirs: Vec<String>,\n    build_profile: BuildProfile,\n    bypass_runner: bool,\n}\n\nimpl BuildStatement<'_> {\n    fn from_build_action<'a>(\n        group: &str,\n        mut action: impl BuildAction,\n        existing_outputs: &'a HashMap<String, Vec<String>>,\n        build_profile: BuildProfile,\n        have_n2: bool,\n    ) -> BuildStatement<'a> {\n        let mut stmt = BuildStatement {\n            existing_outputs,\n            rule_name: action.name(),\n            implicit_inputs: Default::default(),\n            implicit_outputs: Default::default(),\n            explicit_inputs: Default::default(),\n            explicit_outputs: Default::default(),\n            order_only_inputs: Default::default(),\n            variables: Default::default(),\n            rule_variables: Default::default(),\n            output_subsets: Default::default(),\n            output_stamp: false,\n            env_vars: Default::default(),\n            working_dir: None,\n            create_dirs: Default::default(),\n            build_profile,\n            bypass_runner: action.bypass_runner(),\n        };\n        action.files(&mut stmt);\n\n        if stmt.explicit_outputs.is_empty() && stmt.implicit_outputs.is_empty() {\n            panic!(\"{} must generate at least one output\", action.name());\n        }\n        stmt.variables.push((\"description\".into(), group.into()));\n        if action.check_output_timestamps() {\n            stmt.rule_variables.push((\"restat\".into(), \"1\".into()));\n        }\n        if action.generator() {\n            stmt.rule_variables.push((\"generator\".into(), \"1\".into()));\n        }\n        if let Some(pool) = action.concurrency_pool() {\n            stmt.rule_variables.push((\"pool\".into(), pool.into()));\n        }\n        if have_n2 {\n            if action.hide_success() {\n                stmt.rule_variables\n                    .push((\"hide_success\".into(), \"1\".into()));\n            }\n            if action.hide_progress() {\n                stmt.rule_variables\n                    .push((\"hide_progress\".into(), \"1\".into()));\n            }\n        }\n\n        stmt\n    }\n\n    /// Returns a list of all output files, which `Build` will add to\n    /// `existing_outputs`, and any subgroups.\n    fn render_into(mut self, buf: &mut String) -> (Vec<String>, Vec<(String, Vec<String>)>) {\n        let action_name = self.rule_name;\n        self.implicit_inputs.sort();\n        self.implicit_outputs.sort();\n        let inputs_str = to_ninja_target_string(\n            &self.explicit_inputs,\n            &self.implicit_inputs,\n            &self.order_only_inputs,\n        );\n        let outputs_str =\n            to_ninja_target_string(&self.explicit_outputs, &self.implicit_outputs, &[]);\n\n        writeln!(buf, \"build {outputs_str}: {action_name} {inputs_str}\").unwrap();\n        for (key, value) in self.variables.iter().sorted() {\n            writeln!(buf, \"  {key} = {value}\").unwrap();\n        }\n        writeln!(buf).unwrap();\n\n        let outputs_vec = {\n            self.implicit_outputs.extend(self.explicit_outputs);\n            self.implicit_outputs\n        };\n        (outputs_vec, self.output_subsets)\n    }\n\n    fn prepare_command(&mut self, command: String) -> Result<String> {\n        if self.bypass_runner {\n            return Ok(command);\n        }\n        if command.starts_with(\"$runner\") {\n            self.implicit_inputs.push(\"$runner\".into());\n            return Ok(command);\n        }\n        let mut buf = String::from(\"$runner run \");\n        if self.output_stamp {\n            write!(&mut buf, \"--stamp=$stamp \")?;\n        }\n        for var in &self.env_vars {\n            write!(&mut buf, \"--env=\\\"{var}\\\" \")?;\n        }\n        for dir in &self.create_dirs {\n            write!(&mut buf, \"--mkdir={dir} \")?;\n        }\n        if let Some(working_dir) = &self.working_dir {\n            write!(&mut buf, \"--cwd={working_dir} \")?;\n        }\n        buf.push_str(&command);\n        Ok(buf)\n    }\n}\n\nfn expand_inputs(\n    input: impl AsRef<BuildInput>,\n    existing_outputs: &HashMap<String, Vec<String>>,\n) -> Vec<String> {\n    let mut vec = vec![];\n    input.as_ref().add_to_vec(&mut vec, existing_outputs);\n    vec\n}\n\n#[derive(Debug, Eq, PartialEq, Clone, Copy)]\npub enum BuildProfile {\n    Debug,\n    Release,\n    ReleaseWithLto,\n}\n\nimpl BuildProfile {\n    fn from_env() -> Self {\n        match std::env::var(\"RELEASE\").unwrap_or_default().as_str() {\n            \"1\" => Self::Release,\n            \"2\" => Self::ReleaseWithLto,\n            _ => Self::Debug,\n        }\n    }\n}\n\npub trait FilesHandle {\n    /// Add inputs to the build statement. Can be called multiple times with\n    /// different variables. This is a shortcut for calling .expand_inputs()\n    /// and then .add_inputs_vec()\n    /// - If the variable name is non-empty, a variable of the same name will be\n    ///   created so the file list can be accessed in the command. By\n    ///   convention, this is often `in`.\n    fn add_inputs(&mut self, variable: &'static str, inputs: impl AsRef<BuildInput>);\n    fn add_inputs_vec(&mut self, variable: &'static str, inputs: Vec<String>);\n    fn add_order_only_inputs(&mut self, variable: &'static str, inputs: impl AsRef<BuildInput>);\n\n    /// Add a variable that can be referenced in the command.\n    fn add_variable(&mut self, name: impl Into<String>, value: impl Into<String>);\n\n    fn expand_input(&self, input: &BuildInput) -> String;\n    fn expand_inputs(&self, inputs: impl AsRef<BuildInput>) -> Vec<String>;\n\n    /// Like [FilesHandle::add_outputs_ext], without adding a subgroup.\n    fn add_outputs(\n        &mut self,\n        variable: &'static str,\n        outputs: impl IntoIterator<Item = impl AsRef<str>>,\n    ) {\n        self.add_outputs_ext(variable, outputs, false);\n    }\n\n    /// Add outputs to the build statement. Can be called multiple times with\n    /// different variables.\n    /// - Each output automatically has $builddir/ prefixed to it if it does not\n    ///   already start with it.\n    /// - If the variable name is non-empty, a variable of the same name will be\n    ///   created so the file list can be accessed in the command. By\n    ///   convention, this is often `out`.\n    /// - If subgroup is true, the files are also placed in a subgroup. Eg if a\n    ///   rule `foo` exists and subgroup `bar` is provided, the files are\n    ///   accessible via `:foo:bar`. The variable name must not be empty, or\n    ///   called `out`.\n    fn add_outputs_ext(\n        &mut self,\n        variable: impl Into<String>,\n        outputs: impl IntoIterator<Item = impl AsRef<str>>,\n        subgroup: bool,\n    );\n\n    /// Save an output stamp if the command completes successfully. Note that\n    /// if you have bypassed the runner, you will need to create the file\n    /// yourself.\n    fn add_output_stamp(&mut self, path: impl Into<String>);\n    /// Set an env var for the duration of the provided command(s).\n    /// Note this is defined once for the rule, so if the value should change\n    /// for each command, `constant_value` should reference a `$variable` you\n    /// have defined.\n    fn add_env_var(&mut self, key: &str, constant_value: &str);\n    /// Set the current working dir for the provided command(s).\n    /// Note this is defined once for the rule, so if the value should change\n    /// for each command, `constant_value` should reference a `$variable` you\n    /// have defined.\n    fn set_working_dir(&mut self, constant_value: &str);\n    /// Ensure provided folder and parent folders are created before running\n    /// the command. Can be called multiple times. Defines a variable pointing\n    /// at the folder.\n    fn create_dir_all(&mut self, key: &str, path: impl Into<String>);\n\n    fn build_profile(&self) -> BuildProfile;\n}\n\nimpl FilesHandle for BuildStatement<'_> {\n    fn add_inputs(&mut self, variable: &'static str, inputs: impl AsRef<BuildInput>) {\n        self.add_inputs_vec(variable, FilesHandle::expand_inputs(self, inputs));\n    }\n\n    fn add_inputs_vec(&mut self, variable: &'static str, inputs: Vec<String>) {\n        match variable {\n            \"in\" => self.explicit_inputs.extend(inputs),\n            other_key => {\n                if !other_key.is_empty() {\n                    self.add_variable(other_key, space_separated(&inputs));\n                }\n                self.implicit_inputs.extend(inputs);\n            }\n        }\n    }\n\n    fn add_order_only_inputs(&mut self, variable: &'static str, inputs: impl AsRef<BuildInput>) {\n        let inputs = FilesHandle::expand_inputs(self, inputs);\n        if !variable.is_empty() {\n            self.add_variable(variable, space_separated(&inputs))\n        }\n        self.order_only_inputs.extend(inputs);\n    }\n\n    fn add_variable(&mut self, key: impl Into<String>, value: impl Into<String>) {\n        self.variables.push((key.into(), value.into()));\n    }\n\n    fn expand_input(&self, input: &BuildInput) -> String {\n        let mut vec = Vec::with_capacity(1);\n        input.add_to_vec(&mut vec, self.existing_outputs);\n        if vec.len() != 1 {\n            panic!(\"expected {input:?} to resolve to a single file; got ${vec:?}\");\n        }\n        vec.pop().unwrap()\n    }\n\n    fn add_outputs_ext(\n        &mut self,\n        variable: impl Into<String>,\n        outputs: impl IntoIterator<Item = impl AsRef<str>>,\n        subgroup: bool,\n    ) {\n        let outputs = outputs.into_iter().map(|v| {\n            let v = v.as_ref();\n            let v = if !v.starts_with(\"$builddir/\") && !v.starts_with(\"$builddir\\\\\") {\n                format!(\"$builddir/{v}\")\n            } else {\n                v.to_owned()\n            };\n            if cfg!(windows) {\n                v.replace('/', \"\\\\\")\n            } else {\n                v\n            }\n        });\n        let variable = variable.into();\n        match variable.as_str() {\n            \"out\" => self.explicit_outputs.extend(outputs),\n            other_key => {\n                let outputs: Vec<_> = outputs.collect();\n                if !other_key.is_empty() {\n                    self.add_variable(other_key, space_separated(&outputs));\n                }\n                if subgroup {\n                    assert!(!other_key.is_empty());\n                    self.output_subsets\n                        .push((other_key.to_owned(), outputs.to_owned()));\n                }\n\n                self.implicit_outputs.extend(outputs);\n            }\n        }\n    }\n\n    fn expand_inputs(&self, inputs: impl AsRef<BuildInput>) -> Vec<String> {\n        expand_inputs(inputs, self.existing_outputs)\n    }\n\n    fn build_profile(&self) -> BuildProfile {\n        self.build_profile\n    }\n\n    fn add_output_stamp(&mut self, path: impl Into<String>) {\n        self.output_stamp = true;\n        self.add_outputs(\"stamp\", vec![path.into()]);\n    }\n\n    fn add_env_var(&mut self, key: &str, constant_value: &str) {\n        self.env_vars.push(format!(\"{key}={constant_value}\"));\n    }\n\n    fn set_working_dir(&mut self, constant_value: &str) {\n        self.working_dir = Some(constant_value.to_owned());\n    }\n\n    fn create_dir_all(&mut self, key: &str, path: impl Into<String>) {\n        let path = path.into();\n        self.add_variable(key, &path);\n        self.create_dirs.push(path);\n    }\n}\n\nfn to_ninja_target_string(\n    explicit: &[String],\n    implicit: &[String],\n    order_only: &[String],\n) -> String {\n    let mut joined = space_separated(explicit);\n    if !implicit.is_empty() {\n        joined.push_str(\" | \");\n        joined.push_str(&space_separated(implicit));\n    }\n    if !order_only.is_empty() {\n        joined.push_str(\" || \");\n        joined.push_str(&space_separated(order_only));\n    }\n    joined\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn test_split_groups() {\n        assert_eq!(&split_groups(\"foo\"), &[\"foo\"]);\n        assert_eq!(&split_groups(\"foo:bar\"), &[\"foo:bar\", \"foo\"]);\n        assert_eq!(\n            &split_groups(\"foo:bar:baz\"),\n            &[\"foo:bar:baz\", \"foo:bar\", \"foo\"]\n        );\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/cargo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\n\nuse crate::action::BuildAction;\nuse crate::archives::with_exe;\nuse crate::build::BuildProfile;\nuse crate::build::FilesHandle;\nuse crate::input::BuildInput;\nuse crate::inputs;\nuse crate::Build;\n\n#[derive(Debug, PartialEq, Eq)]\npub enum RustOutput<'a> {\n    Binary(&'a str),\n    StaticLib(&'a str),\n    DynamicLib(&'a str),\n    /// (group_name, fully qualified path)\n    Data(&'a str, &'a str),\n}\n\nimpl RustOutput<'_> {\n    pub fn name(&self) -> &str {\n        match self {\n            RustOutput::Binary(pkg) => pkg,\n            RustOutput::StaticLib(pkg) => pkg,\n            RustOutput::DynamicLib(pkg) => pkg,\n            RustOutput::Data(name, _) => name,\n        }\n    }\n\n    pub fn path(\n        &self,\n        rust_base: &Utf8Path,\n        target: Option<&str>,\n        build_profile: BuildProfile,\n    ) -> String {\n        let filename = match *self {\n            RustOutput::Binary(package) => {\n                if cfg!(windows) {\n                    format!(\"{package}.exe\")\n                } else {\n                    package.into()\n                }\n            }\n            RustOutput::StaticLib(package) => format!(\"lib{package}.a\"),\n            RustOutput::DynamicLib(package) => {\n                if cfg!(windows) {\n                    format!(\"{package}.dll\")\n                } else if cfg!(target_os = \"macos\") {\n                    format!(\"lib{package}.dylib\")\n                } else {\n                    format!(\"lib{package}.so\")\n                }\n            }\n            RustOutput::Data(_, path) => return path.to_string(),\n        };\n        let mut path: Utf8PathBuf = rust_base.into();\n        if let Some(target) = target {\n            path = path.join(target);\n        }\n        path = path.join(profile_output_dir(build_profile)).join(filename);\n        path.to_string()\n    }\n}\n\nfn profile_output_dir(profile: BuildProfile) -> &'static str {\n    match profile {\n        BuildProfile::Debug => \"debug\",\n        BuildProfile::Release => \"release\",\n        BuildProfile::ReleaseWithLto => \"release-lto\",\n    }\n}\n\n#[derive(Debug, Default)]\npub struct CargoBuild<'a> {\n    pub inputs: BuildInput,\n    pub outputs: &'a [RustOutput<'a>],\n    pub target: Option<&'static str>,\n    pub extra_args: &'a str,\n    pub release_override: Option<BuildProfile>,\n}\n\nimpl BuildAction for CargoBuild<'_> {\n    fn command(&self) -> &str {\n        \"cargo build $release_arg $target_arg $cargo_flags $extra_args\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        let release_build = self\n            .release_override\n            .unwrap_or_else(|| build.build_profile());\n        let release_arg = profile_arg_for_cargo(release_build).unwrap_or_default();\n        let target_arg = if let Some(target) = self.target {\n            format!(\"--target {target}\")\n        } else {\n            \"\".into()\n        };\n\n        build.add_inputs(\"\", &self.inputs);\n        build.add_inputs(\n            \"\",\n            inputs![\".cargo/config.toml\", \"rust-toolchain.toml\", \"Cargo.lock\"],\n        );\n        build.add_variable(\"release_arg\", release_arg);\n        build.add_variable(\"target_arg\", target_arg);\n        build.add_variable(\"extra_args\", self.extra_args);\n\n        let output_root = Utf8Path::new(\"$builddir/rust\");\n        for output in self.outputs {\n            let name = output.name();\n            let path = output.path(output_root, self.target, release_build);\n            build.add_outputs_ext(name, vec![path], true);\n        }\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        setup_flags(build)\n    }\n}\n\nfn profile_arg_for_cargo(profile: BuildProfile) -> Option<&'static str> {\n    match profile {\n        BuildProfile::Debug => None,\n        BuildProfile::Release => Some(\"--release\"),\n        BuildProfile::ReleaseWithLto => Some(\"--profile release-lto\"),\n    }\n}\n\nfn setup_flags(build: &mut Build) -> Result<()> {\n    build.once_only(\"cargo_flags_and_pool\", |build| {\n        build.variable(\"cargo_flags\", \"--locked\");\n        Ok(())\n    })\n}\n\npub struct CargoTest {\n    pub inputs: BuildInput,\n}\n\nimpl BuildAction for CargoTest {\n    fn command(&self) -> &str {\n        \"cargo nextest run --color=always --failure-output=final --status-level=none $cargo_flags\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\"\", &self.inputs);\n        build.add_inputs(\"\", inputs![\":cargo-nextest\"]);\n        build.add_env_var(\"ANKI_TEST_MODE\", \"1\");\n        build.add_output_stamp(\"tests/cargo_test\");\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        build.add_action(\n            \"cargo-nextest\",\n            CargoInstall {\n                binary_name: \"cargo-nextest\",\n                args: \"cargo-nextest --version 0.9.99 --locked --no-default-features --features default-no-update\",\n            },\n        )?;\n        setup_flags(build)\n    }\n}\n\npub struct CargoClippy {\n    pub inputs: BuildInput,\n}\n\nimpl BuildAction for CargoClippy {\n    fn command(&self) -> &str {\n        \"cargo clippy $cargo_flags --tests -- -Dclippy::dbg_macro -Dwarnings\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\n            \"\",\n            inputs![&self.inputs, \"Cargo.lock\", \"rust-toolchain.toml\"],\n        );\n        build.add_output_stamp(\"tests/cargo_clippy\");\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        setup_flags(build)\n    }\n}\n\npub struct CargoFormat {\n    pub inputs: BuildInput,\n    pub check_only: bool,\n    pub working_dir: Option<&'static str>,\n}\n\nimpl BuildAction for CargoFormat {\n    fn command(&self) -> &str {\n        \"cargo fmt $mode --all\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\"\", &self.inputs);\n        build.add_variable(\"mode\", if self.check_only { \"--check\" } else { \"\" });\n        if let Some(working_dir) = self.working_dir {\n            build.set_working_dir(\"$working_dir\");\n            build.add_variable(\"working_dir\", working_dir);\n        }\n        build.add_output_stamp(format!(\n            \"tests/cargo_format.{}\",\n            if self.check_only { \"check\" } else { \"fmt\" }\n        ));\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        setup_flags(build)\n    }\n}\n\n/// Use Cargo to download and build a Rust binary. If `binary_name` is `foo`, a\n/// `$foo` variable will be defined with the path to the binary.\npub struct CargoInstall {\n    pub binary_name: &'static str,\n    /// eg 'foo --version 1.3' or '--git git://...'\n    pub args: &'static str,\n}\n\nimpl BuildAction for CargoInstall {\n    fn command(&self) -> &str {\n        \"cargo install --color always $args --root $builddir\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_variable(\"args\", self.args);\n        build.add_outputs(\"\", vec![with_exe(&format!(\"bin/{}\", self.binary_name))])\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n\npub struct CargoRun {\n    pub binary_name: &'static str,\n    pub cargo_args: &'static str,\n    pub bin_args: &'static str,\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for CargoRun {\n    fn command(&self) -> &str {\n        \"cargo run --bin $binary $cargo_args -- $bin_args\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\"\", &self.deps);\n        build.add_variable(\"binary\", self.binary_name);\n        build.add_variable(\"cargo_args\", self.cargo_args);\n        build.add_variable(\"bin_args\", self.bin_args);\n        build.add_outputs(\"\", vec![format!(\"phony-{}\", self.binary_name)]);\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/command.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse crate::action::BuildAction;\nuse crate::input::space_separated;\nuse crate::input::BuildInput;\nuse crate::inputs;\n\npub struct RunCommand<'a> {\n    // Will be automatically included as a dependency\n    pub command: &'static str,\n    // Arguments to the script, eg `$in $out` or `$in > $out`.\n    pub args: &'a str,\n    pub inputs: HashMap<&'static str, BuildInput>,\n    pub outputs: HashMap<&'static str, Vec<&'a str>>,\n}\n\nimpl BuildAction for RunCommand<'_> {\n    fn command(&self) -> &str {\n        \"$cmd $args\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        // Because we've defined a generic rule instead of making one for a specific use\n        // case, we need to manually intepolate variables in the user-provided\n        // args.\n        let mut args = self.args.to_string();\n        for (key, inputs) in &self.inputs {\n            let files = build.expand_inputs(inputs);\n            build.add_inputs(\"\", inputs);\n            if !key.is_empty() {\n                args = args.replace(&format!(\"${key}\"), &space_separated(files));\n            }\n        }\n        for (key, outputs) in &self.outputs {\n            if !key.is_empty() {\n                let outputs = outputs.iter().map(|o| {\n                    if !o.starts_with(\"$builddir/\") {\n                        format!(\"$builddir/{o}\")\n                    } else {\n                        (*o).into()\n                    }\n                });\n                args = args.replace(&format!(\"${key}\"), &space_separated(outputs));\n            }\n        }\n\n        build.add_inputs(\"cmd\", inputs![self.command]);\n        build.add_variable(\"args\", args);\n        for outputs in self.outputs.values() {\n            build.add_outputs(\"\", outputs);\n        }\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/configure.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\n\nuse crate::action::BuildAction;\nuse crate::build::BuildProfile;\nuse crate::build::FilesHandle;\nuse crate::cargo::CargoBuild;\nuse crate::cargo::RustOutput;\nuse crate::glob;\nuse crate::inputs;\nuse crate::Build;\n\npub struct ConfigureBuild {}\n\nimpl BuildAction for ConfigureBuild {\n    fn command(&self) -> &str {\n        \"$cmd\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        build.add_inputs(\"cmd\", inputs![\":build:configure_bin\"]);\n        // reconfigure when external inputs change\n        build.add_inputs(\"\", inputs![\"$builddir/env\", \".version\", \".git\"]);\n        build.add_outputs(\"\", [\"build.ninja\"])\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        build.add_action(\n            \"build:configure_bin\",\n            CargoBuild {\n                inputs: inputs![glob![\"build/**/*\"]],\n                outputs: &[RustOutput::Binary(\"configure\")],\n                target: None,\n                extra_args: \"-p configure\",\n                release_override: Some(BuildProfile::Debug),\n            },\n        )?;\n        Ok(())\n    }\n\n    fn generator(&self) -> bool {\n        true\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/copy.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse camino::Utf8Path;\n\nuse crate::action::BuildAction;\nuse crate::input::BuildInput;\n\n/// Copy the provided files into the specified destination folder.\n/// Directory structure is not preserved - eg foo/bar.js is copied\n/// into out/$output_folder/bar.js.\npub struct CopyFiles<'a> {\n    pub inputs: BuildInput,\n    /// The folder (relative to the build folder) that files should be copied\n    /// into.\n    pub output_folder: &'a str,\n}\n\nimpl BuildAction for CopyFiles<'_> {\n    fn command(&self) -> &str {\n        // The -f is because we may need to overwrite read-only files copied from Bazel.\n        \"cp -fr $in $builddir/$folder\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        let inputs = build.expand_inputs(&self.inputs);\n        let output_folder = Utf8Path::new(self.output_folder);\n        let outputs: Vec<_> = inputs\n            .iter()\n            .map(|f| output_folder.join(Utf8Path::new(f).file_name().unwrap()))\n            .collect();\n        build.add_inputs(\"in\", &self.inputs);\n        build.add_outputs(\"\", outputs);\n        build.add_variable(\"folder\", self.output_folder);\n    }\n}\n\n/// Copy a single file to the provided output path, which should be relative to\n/// the output folder. This can be used to create a copy with a different name.\npub struct CopyFile<'a> {\n    pub input: BuildInput,\n    pub output: &'a str,\n}\n\nimpl BuildAction for CopyFile<'_> {\n    fn command(&self) -> &str {\n        \"cp $in $out\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"in\", &self.input);\n        build.add_outputs(\"out\", vec![self.output]);\n    }\n}\n\n/// Create a symbolic link to the provided output path, which should be relative\n/// to the output folder. This can be used to create a copy with a different\n/// name.\npub struct LinkFile<'a> {\n    pub input: BuildInput,\n    pub output: &'a str,\n}\n\nimpl BuildAction for LinkFile<'_> {\n    fn command(&self) -> &str {\n        if cfg!(windows) {\n            \"cmd /c copy $in $out\"\n        } else {\n            \"ln -sf $in $out\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"in\", &self.input);\n        build.add_outputs(\"out\", vec![self.output]);\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/git.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\nuse itertools::Itertools;\n\nuse super::*;\nuse crate::action::BuildAction;\nuse crate::input::BuildInput;\n\npub struct SyncSubmodule {\n    pub path: &'static str,\n    pub offline_build: bool,\n}\n\nimpl BuildAction for SyncSubmodule {\n    fn command(&self) -> &str {\n        if self.offline_build {\n            \"echo OFFLINE_BUILD is set, skipping git repository update for $path\"\n        } else {\n            \"git -c protocol.file.allow=always submodule update --checkout --init $path\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        if !self.offline_build {\n            if let Some(head) = locate_git_head() {\n                build.add_inputs(\"\", head);\n            } else {\n                println!(\"Warning, .git/HEAD not found; submodules may be stale\");\n            }\n        }\n\n        build.add_variable(\"path\", self.path);\n        build.add_output_stamp(format!(\"git/{}\", self.path));\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        build.pool(\"git\", 1);\n        Ok(())\n    }\n\n    fn concurrency_pool(&self) -> Option<&'static str> {\n        Some(\"git\")\n    }\n}\n\n/// We check the mtime of .git/HEAD to detect when we should sync submodules.\n/// If this repo is a submodule of another project, .git/HEAD will not exist,\n/// and we fall back on .git/modules/*/HEAD in a parent folder instead.\nfn locate_git_head() -> Option<BuildInput> {\n    let standard_path = Utf8Path::new(\".git/HEAD\");\n    if standard_path.exists() {\n        return Some(inputs![standard_path.to_string()]);\n    }\n\n    let mut folder = Utf8PathBuf::from_path_buf(\n        dunce::canonicalize(Utf8Path::new(\".\").canonicalize().unwrap()).unwrap(),\n    )\n    .unwrap();\n    loop {\n        let path = folder.join(\".git\").join(\"modules\");\n        if path.exists() {\n            let heads = path\n                .read_dir_utf8()\n                .unwrap()\n                .filter_map(|p| {\n                    let head = p.unwrap().path().join(\"HEAD\");\n                    if head.exists() {\n                        Some(head.as_str().replace(':', \"$:\"))\n                    } else {\n                        None\n                    }\n                })\n                .collect_vec();\n            return Some(inputs![heads]);\n        }\n        if let Some(parent) = folder.parent() {\n            folder = parent.to_owned();\n        } else {\n            return None;\n        }\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/hash.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::hash_map::DefaultHasher;\nuse std::hash::Hash;\nuse std::hash::Hasher;\n\npub fn simple_hash(hashable: impl Hash) -> u64 {\n    let mut hasher = DefaultHasher::new();\n    hashable.hash(&mut hasher);\n    hasher.finish()\n}\n"
  },
  {
    "path": "build/ninja_gen/src/input.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse std::sync::LazyLock;\n\nuse camino::Utf8PathBuf;\n\n#[derive(Debug, Clone, Hash, Default)]\npub enum BuildInput {\n    Single(String),\n    Multiple(Vec<String>),\n    Glob(Glob),\n    Inputs(Vec<BuildInput>),\n    #[default]\n    Empty,\n}\n\nimpl AsRef<BuildInput> for BuildInput {\n    fn as_ref(&self) -> &BuildInput {\n        self\n    }\n}\n\nimpl From<String> for BuildInput {\n    fn from(v: String) -> Self {\n        BuildInput::Single(v)\n    }\n}\n\nimpl From<&str> for BuildInput {\n    fn from(v: &str) -> Self {\n        BuildInput::Single(v.to_owned())\n    }\n}\n\nimpl From<Vec<String>> for BuildInput {\n    fn from(v: Vec<String>) -> Self {\n        BuildInput::Multiple(v)\n    }\n}\n\nimpl From<Glob> for BuildInput {\n    fn from(v: Glob) -> Self {\n        BuildInput::Glob(v)\n    }\n}\n\nimpl From<&BuildInput> for BuildInput {\n    fn from(v: &BuildInput) -> Self {\n        BuildInput::Inputs(vec![v.clone()])\n    }\n}\n\nimpl From<&[BuildInput]> for BuildInput {\n    fn from(v: &[BuildInput]) -> Self {\n        BuildInput::Inputs(v.to_vec())\n    }\n}\n\nimpl From<Vec<BuildInput>> for BuildInput {\n    fn from(v: Vec<BuildInput>) -> Self {\n        BuildInput::Inputs(v)\n    }\n}\n\nimpl From<Utf8PathBuf> for BuildInput {\n    fn from(v: Utf8PathBuf) -> Self {\n        BuildInput::Single(v.into_string())\n    }\n}\n\nimpl BuildInput {\n    pub fn add_to_vec(\n        &self,\n        vec: &mut Vec<String>,\n        exisiting_outputs: &HashMap<String, Vec<String>>,\n    ) {\n        let mut resolve_and_add = |value: &str| {\n            if let Some(stripped) = value.strip_prefix(':') {\n                let files = exisiting_outputs.get(stripped).unwrap_or_else(|| {\n                    println!(\"{:?}\", &exisiting_outputs);\n                    panic!(\"input referenced {value}, but rule missing/not processed\");\n                });\n                for file in files {\n                    vec.push(file.into())\n                }\n            } else {\n                vec.push(value.into());\n            }\n        };\n\n        match self {\n            BuildInput::Single(s) => resolve_and_add(s),\n            BuildInput::Multiple(v) => {\n                for item in v {\n                    resolve_and_add(item);\n                }\n            }\n            BuildInput::Glob(glob) => {\n                for path in glob.resolve() {\n                    vec.push(path.into_string());\n                }\n            }\n            BuildInput::Inputs(inputs) => {\n                for input in inputs {\n                    input.add_to_vec(vec, exisiting_outputs)\n                }\n            }\n            BuildInput::Empty => {}\n        }\n    }\n}\n\n#[derive(Debug, Clone, Hash)]\npub struct Glob {\n    pub include: String,\n    pub exclude: Option<String>,\n}\n\nstatic CACHED_FILES: LazyLock<Vec<Utf8PathBuf>> = LazyLock::new(cache_files);\n\n/// Walking the source tree once instead of for each glob yields ~4x speed\n/// improvements.\nfn cache_files() -> Vec<Utf8PathBuf> {\n    walkdir::WalkDir::new(\".\")\n        // ensure the output order is predictable\n        .sort_by_file_name()\n        .into_iter()\n        .filter_entry(move |e| {\n            // don't walk into symlinks, or the top-level out/, or .git\n            !(e.path_is_symlink()\n                || (e.depth() == 1 && (e.file_name() == \"out\" || e.file_name() == \".git\")))\n        })\n        .filter_map(move |e| {\n            let path = e.as_ref().unwrap().path().strip_prefix(\"./\").unwrap();\n            if !path.is_dir() {\n                Some(Utf8PathBuf::from_path_buf(path.to_owned()).unwrap())\n            } else {\n                None\n            }\n        })\n        .collect()\n}\n\nimpl Glob {\n    pub fn resolve(&self) -> impl Iterator<Item = Utf8PathBuf> {\n        let include = globset::GlobBuilder::new(&self.include)\n            .literal_separator(true)\n            .build()\n            .unwrap()\n            .compile_matcher();\n        let exclude = self.exclude.as_ref().map(|glob| {\n            globset::GlobBuilder::new(glob)\n                .literal_separator(true)\n                .build()\n                .unwrap()\n                .compile_matcher()\n        });\n        CACHED_FILES.iter().filter_map(move |path| {\n            if include.is_match(path) {\n                let excluded = exclude\n                    .as_ref()\n                    .map(|exclude| exclude.is_match(path))\n                    .unwrap_or_default();\n                if !excluded {\n                    return Some(path.to_owned());\n                }\n            }\n            None\n        })\n    }\n}\n\npub fn space_separated<I>(iter: I) -> String\nwhere\n    I: IntoIterator,\n    I::Item: Display,\n{\n    itertools::join(iter, \" \")\n}\n"
  },
  {
    "path": "build/ninja_gen/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod action;\npub mod archives;\npub mod build;\npub mod cargo;\npub mod command;\npub mod configure;\npub mod copy;\npub mod git;\npub mod hash;\npub mod input;\npub mod node;\npub mod protobuf;\npub mod python;\npub mod render;\npub mod rsync;\npub mod sass;\n\npub use build::Build;\npub use camino::Utf8Path;\npub use camino::Utf8PathBuf;\npub use maplit::hashmap;\npub use which::which;\n\n#[macro_export]\nmacro_rules! inputs {\n    ($($param:expr),+ $(,)?) => {\n        $crate::input::BuildInput::from(vec![$($crate::input::BuildInput::from($param)),+])\n    };\n    () => {\n        $crate::input::BuildInput::Empty\n    };\n}\n\n#[macro_export]\nmacro_rules! glob {\n    ($include:expr) => {\n        $crate::input::Glob {\n            include: $include.into(),\n            exclude: None,\n        }\n    };\n    ($include:expr, $exclude:expr) => {\n        $crate::input::Glob {\n            include: $include.into(),\n            exclude: Some($exclude.into()),\n        }\n    };\n}\n"
  },
  {
    "path": "build/ninja_gen/src/node.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\n\nuse anyhow::Result;\nuse itertools::Itertools;\n\nuse super::*;\nuse crate::action::BuildAction;\nuse crate::archives::download_and_extract;\nuse crate::archives::OnlineArchive;\nuse crate::archives::Platform;\nuse crate::hash::simple_hash;\nuse crate::input::space_separated;\nuse crate::input::BuildInput;\n\npub fn node_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz\",\n            sha256: \"325c0f1261e0c61bcae369a1274028e9cfb7ab7949c05512c5b1e630f7e80e12\",\n        },\n        Platform::LinuxArm => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-arm64.tar.xz\",\n            sha256: \"140aee84be6774f5fb3f404be72adbe8420b523f824de82daeb5ab218dab7b18\",\n        },\n        Platform::MacX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-x64.tar.xz\",\n            sha256: \"f79de1f64df4ac68493a344bb5ab7d289d0275271e87b543d1278392c9de778a\",\n        },\n        Platform::MacArm => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-arm64.tar.xz\",\n            sha256: \"cc9cc294eaf782dd93c8c51f460da610cc35753c6a9947411731524d16e97914\",\n        },\n        Platform::WindowsX64 => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-x64.zip\",\n            sha256: \"721ab118a3aac8584348b132767eadf51379e0616f0db802cc1e66d7f0d98f85\",\n        },\n        Platform::WindowsArm => OnlineArchive {\n            url: \"https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-arm64.zip\",\n            sha256: \"78355dc9ca117bb71d3f081e4b1b281855e2b134f3939bb0ca314f7567b0e621\",\n        },\n    }\n}\n\npub struct YarnSetup {}\n\nimpl BuildAction for YarnSetup {\n    fn command(&self) -> &str {\n        if cfg!(windows) {\n            \"corepack.cmd enable yarn\"\n        } else {\n            \"corepack enable yarn\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"\", inputs![\":node_binary\"]);\n        build.add_outputs_ext(\n            \"bin\",\n            vec![if cfg!(windows) {\n                \"extracted/node/yarn.cmd\"\n            } else {\n                \"extracted/node/bin/yarn\"\n            }],\n            true,\n        );\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\npub struct YarnInstall<'a> {\n    pub package_json_and_lock: BuildInput,\n    pub exports: HashMap<&'a str, Vec<Cow<'a, str>>>,\n}\n\nimpl BuildAction for YarnInstall<'_> {\n    fn command(&self) -> &str {\n        \"$runner yarn $yarn $out\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"\", &self.package_json_and_lock);\n        build.add_inputs(\"yarn\", inputs![\":yarn:bin\"]);\n        build.add_outputs(\"out\", vec![\"node_modules/.marker\"]);\n        for (key, value) in &self.exports {\n            let outputs: Vec<_> = value.iter().map(|o| format!(\"node_modules/{o}\")).collect();\n            build.add_outputs_ext(*key, outputs, true);\n        }\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n\nfn with_cmd_ext(bin: &str) -> Cow<'_, str> {\n    if cfg!(windows) {\n        format!(\"{bin}.cmd\").into()\n    } else {\n        bin.into()\n    }\n}\n\npub fn setup_node(\n    build: &mut Build,\n    archive: OnlineArchive,\n    binary_exports: &[&'static str],\n    mut data_exports: HashMap<&str, Vec<Cow<str>>>,\n) -> Result<()> {\n    let node_binary = match std::env::var(\"NODE_BINARY\") {\n        Ok(path) => {\n            assert!(\n                Utf8Path::new(&path).is_absolute(),\n                \"NODE_BINARY must be absolute\"\n            );\n            path.into()\n        }\n        Err(_) => {\n            download_and_extract(\n                build,\n                \"node\",\n                archive,\n                hashmap! {\n                    \"bin\" => vec![if cfg!(windows) { \"node.exe\" } else { \"bin/node\" }],\n                    \"npm\" => vec![if cfg!(windows) { \"npm.cmd \" } else { \"bin/npm\" }]\n                },\n            )?;\n            inputs![\":extract:node:bin\"]\n        }\n    };\n    build.add_dependency(\"node_binary\", node_binary);\n\n    match std::env::var(\"YARN_BINARY\") {\n        Ok(path) => {\n            assert!(\n                Utf8Path::new(&path).is_absolute(),\n                \"YARN_BINARY must be absolute\"\n            );\n            build.add_dependency(\"yarn:bin\", inputs![path]);\n        }\n        Err(_) => {\n            build.add_action(\"yarn\", YarnSetup {})?;\n        }\n    };\n\n    for binary in binary_exports {\n        data_exports.insert(\n            *binary,\n            vec![format!(\".bin/{}\", with_cmd_ext(binary)).into()],\n        );\n    }\n    build.add_action(\n        \"node_modules\",\n        YarnInstall {\n            package_json_and_lock: inputs![\"yarn.lock\", \"package.json\"],\n            exports: data_exports,\n        },\n    )?;\n    Ok(())\n}\n\npub struct EsbuildScript<'a> {\n    pub script: BuildInput,\n    pub entrypoint: BuildInput,\n    pub deps: BuildInput,\n    /// .js will be appended, and any extra extensions\n    pub output_stem: &'a str,\n    /// eg ['.css', '.html']\n    pub extra_exts: &'a [&'a str],\n}\n\nimpl BuildAction for EsbuildScript<'_> {\n    fn command(&self) -> &str {\n        \"$node_bin $script $entrypoint $out\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"node_bin\", inputs![\":node_binary\"]);\n        build.add_inputs(\"script\", &self.script);\n        build.add_inputs(\"entrypoint\", &self.entrypoint);\n        build.add_inputs(\"\", inputs![\"yarn.lock\", \":node_modules\", &self.deps]);\n        build.add_inputs(\"\", inputs![\"out/env\"]);\n        let stem = self.output_stem;\n        let mut outs = vec![format!(\"{stem}.js\")];\n        outs.extend(self.extra_exts.iter().map(|ext| format!(\"{stem}.{ext}\")));\n        build.add_outputs(\"out\", outs);\n    }\n}\n\npub struct DPrint {\n    pub inputs: BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for DPrint {\n    fn command(&self) -> &str {\n        \"$dprint $mode\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"dprint\", inputs![\":node_modules:dprint\"]);\n        build.add_inputs(\"\", &self.inputs);\n        let mode = if self.check_only { \"check\" } else { \"fmt\" };\n        build.add_variable(\"mode\", mode);\n        build.add_output_stamp(format!(\"tests/dprint.{mode}\"));\n    }\n}\n\npub struct Prettier {\n    pub inputs: BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for Prettier {\n    fn command(&self) -> &str {\n        \"$yarn prettier --cache $mode $pattern\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"yarn\", inputs![\":yarn:bin\"]);\n        build.add_inputs(\"prettier\", inputs![\":node_modules:prettier\"]);\n        build.add_inputs(\"\", &self.inputs);\n        build.add_variable(\"pattern\", r#\"\"**/*.svelte\"\"#);\n        let (file_ext, mode) = if self.check_only {\n            (\"fmt\", \"--check\")\n        } else {\n            (\"check\", \"--write\")\n        };\n        build.add_variable(\"mode\", mode);\n        build.add_output_stamp(format!(\"tests/prettier.{file_ext}\"));\n    }\n}\n\npub struct SvelteCheck {\n    pub tsconfig: BuildInput,\n    pub inputs: BuildInput,\n}\n\nimpl BuildAction for SvelteCheck {\n    fn command(&self) -> &str {\n        \"$yarn svelte-check:once\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"svelte-check\", inputs![\":node_modules:svelte-check\"]);\n        build.add_inputs(\"tsconfig\", &self.tsconfig);\n        build.add_inputs(\"yarn\", inputs![\":yarn:bin\"]);\n        build.add_inputs(\"\", &self.inputs);\n        build.add_inputs(\"\", inputs![\"yarn.lock\"]);\n        let hash = simple_hash(&self.tsconfig);\n        build.add_output_stamp(format!(\"tests/svelte-check.{hash}\"));\n    }\n\n    fn hide_progress(&self) -> bool {\n        true\n    }\n}\n\npub struct TypescriptCheck {\n    pub tsconfig: BuildInput,\n    pub inputs: BuildInput,\n}\n\nimpl BuildAction for TypescriptCheck {\n    fn command(&self) -> &str {\n        \"$tsc --noEmit -p $tsconfig\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"tsc\", inputs![\":node_modules:tsc\"]);\n        build.add_inputs(\"tsconfig\", &self.tsconfig);\n        build.add_inputs(\"\", &self.inputs);\n        build.add_inputs(\"\", inputs![\"yarn.lock\"]);\n        let hash = simple_hash(&self.tsconfig);\n        build.add_output_stamp(format!(\"tests/typescript.{hash}\"));\n    }\n}\n\npub struct Eslint<'a> {\n    pub folder: &'a str,\n    pub inputs: BuildInput,\n    pub eslint_rc: BuildInput,\n    pub fix: bool,\n}\n\nimpl BuildAction for Eslint<'_> {\n    fn command(&self) -> &str {\n        \"$eslint --max-warnings=0 -c $eslint_rc $fix $folder\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"eslint\", inputs![\":node_modules:eslint\"]);\n        build.add_inputs(\"eslint_rc\", &self.eslint_rc);\n        build.add_inputs(\"in\", &self.inputs);\n        build.add_inputs(\"\", inputs![\"yarn.lock\", \"ts/tsconfig.json\"]);\n        build.add_variable(\"fix\", if self.fix { \"--fix\" } else { \"\" });\n        build.add_variable(\"folder\", self.folder);\n        let hash = simple_hash(self.folder);\n        let kind = if self.fix { \"fix\" } else { \"check\" };\n        build.add_output_stamp(format!(\"tests/eslint.{kind}.{hash}\"));\n    }\n}\n\npub struct ViteTest {\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for ViteTest {\n    fn command(&self) -> &str {\n        \"$yarn vitest:once\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"vitest\", inputs![\":node_modules:vitest\"]);\n        build.add_inputs(\"yarn\", inputs![\":yarn:bin\"]);\n        build.add_inputs(\"\", &self.deps);\n        build.add_output_stamp(\"tests/vitest\");\n    }\n}\n\npub struct SqlFormat {\n    pub inputs: BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for SqlFormat {\n    fn command(&self) -> &str {\n        \"$tsx $sql_format $mode $in\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"tsx\", inputs![\":node_modules:tsx\"]);\n        build.add_inputs(\"sql_format\", inputs![\"ts/tools/sql_format.ts\"]);\n        build.add_inputs(\"in\", &self.inputs);\n        let mode = if self.check_only { \"check\" } else { \"fix\" };\n        build.add_variable(\"mode\", mode);\n        build.add_output_stamp(format!(\"tests/sql_format.{mode}\"));\n    }\n}\n\npub struct GenTypescriptProto<'a> {\n    pub protos: BuildInput,\n    pub include_dirs: &'a [&'a str],\n    /// Automatically created.\n    pub out_dir: &'a str,\n    /// Can be used to adjust the output js/dts files to point to out_dir.\n    pub out_path_transform: fn(&str) -> String,\n    /// Script to apply modifications to the generated files.\n    pub ts_transform_script: &'static str,\n}\n\nimpl BuildAction for GenTypescriptProto<'_> {\n    fn command(&self) -> &str {\n        \"$protoc $includes $in \\\n        --plugin $gen-es --es_out $out_dir && \\\n        $tsx $transform_script $out_dir\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        let proto_files = build.expand_inputs(&self.protos);\n        let output_files: Vec<_> = proto_files\n            .iter()\n            .flat_map(|f| {\n                let js_path = f.replace(\".proto\", \"_pb.js\");\n                let dts_path = f.replace(\".proto\", \"_pb.d.ts\");\n                [\n                    (self.out_path_transform)(&js_path),\n                    (self.out_path_transform)(&dts_path),\n                ]\n            })\n            .collect();\n\n        build.create_dir_all(\"out_dir\", self.out_dir);\n        build.add_variable(\n            \"includes\",\n            self.include_dirs\n                .iter()\n                .map(|d| format!(\"-I {d}\"))\n                .join(\" \"),\n        );\n        build.add_inputs(\"protoc\", inputs![\":protoc_binary\"]);\n        build.add_inputs(\"gen-es\", inputs![\":node_modules:protoc-gen-es\"]);\n        build.add_inputs_vec(\"in\", proto_files);\n        build.add_inputs(\"\", inputs![\"yarn.lock\"]);\n        build.add_inputs(\"tsx\", inputs![\":node_modules:tsx\"]);\n        build.add_inputs(\"transform_script\", inputs![self.ts_transform_script]);\n\n        build.add_outputs(\"\", output_files);\n    }\n}\n\npub struct CompileSass<'a> {\n    pub input: BuildInput,\n    pub output: &'a str,\n    pub deps: BuildInput,\n    pub load_paths: Vec<&'a str>,\n}\n\nimpl BuildAction for CompileSass<'_> {\n    fn command(&self) -> &str {\n        \"$sass -s compressed $args $in -- $out\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"sass\", inputs![\":node_modules:sass\"]);\n        build.add_inputs(\"in\", &self.input);\n        build.add_inputs(\"\", &self.deps);\n\n        let args = space_separated(self.load_paths.iter().map(|path| format!(\"-I {path}\")));\n        build.add_variable(\"args\", args);\n\n        build.add_outputs(\"out\", vec![self.output]);\n    }\n}\n\n/// Usually we rely on esbuild to transpile our .ts files on the fly, but when\n/// we want generated code to be able to import a .ts file, we need to use\n/// typescript to generate .js/.d.ts files, or types can't be looked up, and\n/// esbuild can't find the file to bundle.\npub struct CompileTypescript<'a> {\n    pub ts_files: BuildInput,\n    /// Automatically created.\n    pub out_dir: &'a str,\n    /// Can be used to adjust the output js/dts files to point to out_dir.\n    pub out_path_transform: fn(&str) -> String,\n}\n\nimpl BuildAction for CompileTypescript<'_> {\n    fn command(&self) -> &str {\n        \"$tsc $in --outDir $out_dir -d --skipLibCheck --types node\"\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"tsc\", inputs![\":node_modules:tsc\"]);\n        build.add_inputs(\"in\", &self.ts_files);\n        build.add_inputs(\"\", inputs![\"yarn.lock\"]);\n\n        let ts_files = build.expand_inputs(&self.ts_files);\n        let output_files: Vec<_> = ts_files\n            .iter()\n            .flat_map(|f| {\n                let js_path = f.replace(\".ts\", \".js\");\n                let dts_path = f.replace(\".ts\", \".d.ts\");\n                [\n                    (self.out_path_transform)(&js_path),\n                    (self.out_path_transform)(&dts_path),\n                ]\n            })\n            .collect();\n\n        build.create_dir_all(\"out_dir\", self.out_dir);\n        build.add_outputs(\"\", output_files);\n    }\n}\n\n/// The output_folder will be declared as a build output, but each file inside\n/// it is not declared, as the files will vary.\npub struct SveltekitBuild {\n    pub output_folder: BuildInput,\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for SveltekitBuild {\n    fn command(&self) -> &str {\n        if std::env::var(\"HMR\").is_err() {\n            \"$yarn build\"\n        } else {\n            \"echo\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl build::FilesHandle) {\n        build.add_inputs(\"node_modules\", inputs![\":node_modules\"]);\n        build.add_inputs(\"yarn\", inputs![\":yarn:bin\"]);\n        build.add_inputs(\"\", &self.deps);\n        build.add_inputs(\"\", inputs![\"yarn.lock\"]);\n        build.add_output_stamp(\"sveltekit.marker\");\n        build.add_outputs_ext(\"folder\", vec![\"sveltekit\"], true);\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/protobuf.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\n\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse maplit::hashmap;\n\nuse crate::action::BuildAction;\nuse crate::archives::download_and_extract;\nuse crate::archives::with_exe;\nuse crate::archives::OnlineArchive;\nuse crate::archives::Platform;\nuse crate::hash::simple_hash;\nuse crate::input::BuildInput;\nuse crate::inputs;\nuse crate::Build;\n\npub fn protoc_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => {\n            OnlineArchive {\n                url: \"https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip\",\n                sha256: \"96553041f1a91ea0efee963cb16f462f5985b4d65365f3907414c360044d8065\",\n            }\n        },\n        Platform::LinuxArm => {\n            OnlineArchive {\n                url: \"https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-aarch_64.zip\",\n                sha256: \"6c554de11cea04c56ebf8e45b54434019b1cd85223d4bbd25c282425e306ecc2\",\n            }\n        },\n        Platform::MacX64 | Platform::MacArm => {\n            OnlineArchive {\n                url: \"https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-osx-universal_binary.zip\",\n                sha256: \"99ea004549c139f46da5638187a85bbe422d78939be0fa01af1aa8ab672e395f\",\n            }\n        },\n        Platform::WindowsX64 | Platform::WindowsArm => {\n            OnlineArchive {\n                url: \"https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-win64.zip\",\n                sha256: \"70381b116ab0d71cb6a5177d9b17c7c13415866603a0fd40d513dafe32d56c35\",\n            }\n        }\n    }\n}\n\nfn clang_format_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => {\n            OnlineArchive {\n                url: \"https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_linux_x86_64.zip\",\n                sha256: \"64060bc4dbca30d0d96aab9344e2783008b16e1cae019a2532f1126ca5ec5449\",\n            }\n        }\n        Platform::LinuxArm => {\n            // todo: replace with arm64 binary\n            OnlineArchive {\n                url: \"https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_linux_x86_64.zip\",\n                sha256: \"64060bc4dbca30d0d96aab9344e2783008b16e1cae019a2532f1126ca5ec5449\",\n            }\n        }\n        Platform::MacX64 | Platform::MacArm => {\n            OnlineArchive {\n                url: \"https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_macos_x86_64.zip\",\n                sha256: \"238be68d9478163a945754f06a213483473044f5a004c4125d3d9d8d3556466e\",\n            }\n        }\n        Platform::WindowsX64 | Platform::WindowsArm=> {\n            OnlineArchive {\n                url: \"https://github.com/ankitects/clang-format-binaries/releases/download/anki-2021-01-09/clang-format_windows_x86_64.zip\",\n                sha256: \"7d9f6915e3f0fb72407830f0fc37141308d2e6915daba72987a52f309fbeaccc\",\n            }\n        }\n    }\n}\npub struct ClangFormat {\n    pub inputs: BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for ClangFormat {\n    fn command(&self) -> &str {\n        \"$clang-format --style=google $args $in\"\n    }\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"clang-format\", inputs![\":extract:clang-format:bin\"]);\n        build.add_inputs(\"in\", &self.inputs);\n        let (args, mode) = if self.check_only {\n            (\"--dry-run -ferror-limit=1 -Werror\", \"check\")\n        } else {\n            (\"-i\", \"fix\")\n        };\n        build.add_variable(\"args\", args);\n        let hash = simple_hash(&self.inputs);\n        build.add_output_stamp(format!(\"tests/clang-format.{mode}.{hash}\"));\n    }\n    fn on_first_instance(&self, build: &mut crate::Build) -> anyhow::Result<()> {\n        let binary = with_exe(\"clang-format\");\n        download_and_extract(\n            build,\n            \"clang-format\",\n            clang_format_archive(build.host_platform),\n            hashmap! {\n                \"bin\" => [binary]\n            },\n        )\n    }\n}\n\npub fn setup_protoc(build: &mut Build) -> Result<()> {\n    let protoc_binary = match env::var(\"PROTOC_BINARY\") {\n        Ok(path) => {\n            assert!(\n                Utf8Path::new(&path).is_absolute(),\n                \"PROTOC_BINARY must be absolute\"\n            );\n            path.into()\n        }\n        Err(_) => {\n            download_and_extract(\n                build,\n                \"protoc\",\n                protoc_archive(build.host_platform),\n                hashmap! {\n                    \"bin\" => [with_exe(\"bin/protoc\")]\n                },\n            )?;\n            inputs![\":extract:protoc:bin\"]\n        }\n    };\n    build.add_dependency(\"protoc_binary\", protoc_binary);\n    Ok(())\n}\n\npub fn check_proto(build: &mut Build, inputs: BuildInput) -> Result<()> {\n    build.add_action(\n        \"check:format:proto\",\n        ClangFormat {\n            inputs: inputs.clone(),\n            check_only: true,\n        },\n    )?;\n    build.add_action(\n        \"format:proto\",\n        ClangFormat {\n            inputs,\n            check_only: false,\n        },\n    )?;\n    Ok(())\n}\n"
  },
  {
    "path": "build/ninja_gen/src/python.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\n\nuse anki_io::read_file;\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse maplit::hashmap;\n\nuse crate::action::BuildAction;\nuse crate::archives::download_and_extract;\nuse crate::archives::with_exe;\nuse crate::archives::OnlineArchive;\nuse crate::archives::Platform;\nuse crate::hash::simple_hash;\nuse crate::input::BuildInput;\nuse crate::inputs;\nuse crate::Build;\n\n// To update, run 'cargo run --bin update_uv'.\n// You'll need to do this when bumping Python versions, as uv bakes in\n// the latest known version.\n// When updating Python version, make sure to update version tag in BuildWheel\n// too.\npub fn uv_archive(platform: Platform) -> OnlineArchive {\n    match platform {\n        Platform::LinuxX64 => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-unknown-linux-gnu.tar.gz\",\n                sha256: \"909278eb197c5ed0e9b5f16317d1255270d1f9ea4196e7179ce934d48c4c2545\",\n            }\n        },\n        Platform::LinuxArm => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-unknown-linux-gnu.tar.gz\",\n                sha256: \"0b2ad9fe4295881615295add8cc5daa02549d29cc9a61f0578e397efcf12f08f\",\n            }\n        },\n        Platform::MacX64 => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-apple-darwin.tar.gz\",\n                sha256: \"d785753ac092e25316180626aa691c5dfe1fb075290457ba4fdb72c7c5661321\",\n            }\n        },\n        Platform::MacArm => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-apple-darwin.tar.gz\",\n                sha256: \"721f532b73171586574298d4311a91d5ea2c802ef4db3ebafc434239330090c6\",\n            }\n        },\n        Platform::WindowsX64 => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-x86_64-pc-windows-msvc.zip\",\n                sha256: \"e199b10bef1a7cc540014483e7f60f825a174988f41020e9d2a6b01bd60f0669\",\n            }\n        },\n        Platform::WindowsArm => {\n            OnlineArchive {\n                url: \"https://github.com/astral-sh/uv/releases/download/0.7.13/uv-aarch64-pc-windows-msvc.zip\",\n                sha256: \"bb40708ad549ad6a12209cb139dd751bf0ede41deb679ce7513ce197bd9ef234\",\n            }\n        }\n    }\n}\n\npub fn setup_uv(build: &mut Build, platform: Platform) -> Result<()> {\n    let uv_binary = match env::var(\"UV_BINARY\") {\n        Ok(path) => {\n            assert!(\n                Utf8Path::new(&path).is_absolute(),\n                \"UV_BINARY must be absolute\"\n            );\n            path.into()\n        }\n        Err(_) => {\n            download_and_extract(\n                build,\n                \"uv\",\n                uv_archive(platform),\n                hashmap! { \"bin\" => [\n                with_exe(\"uv\")\n                                ] },\n            )?;\n            inputs![\":extract:uv:bin\"]\n        }\n    };\n    build.add_dependency(\"uv_binary\", uv_binary);\n\n    // Our macOS packaging needs access to the x86 binary on ARM.\n    if cfg!(target_arch = \"aarch64\") {\n        download_and_extract(\n            build,\n            \"uv_mac_x86\",\n            uv_archive(Platform::MacX64),\n            hashmap! { \"bin\" => [\n                with_exe(\"uv\")\n            ] },\n        )?;\n    }\n    // Our Linux packaging needs access to the ARM binary on x86\n    if cfg!(target_arch = \"x86_64\") {\n        download_and_extract(\n            build,\n            \"uv_lin_arm\",\n            uv_archive(Platform::LinuxArm),\n            hashmap! { \"bin\" => [\n                with_exe(\"uv\")\n            ] },\n        )?;\n    }\n\n    Ok(())\n}\n\npub struct PythonEnvironment {\n    pub deps: BuildInput,\n    // todo: rename\n    pub venv_folder: &'static str,\n    pub extra_args: &'static str,\n    pub extra_binary_exports: &'static [&'static str],\n}\n\nimpl BuildAction for PythonEnvironment {\n    fn command(&self) -> &str {\n        if env::var(\"OFFLINE_BUILD\").is_err() {\n            \"$runner pyenv $uv_binary $builddir/$pyenv_folder $python -- $extra_args\"\n        } else {\n            \"echo 'OFFLINE_BUILD is set. Using the existing PythonEnvironment.'\"\n        }\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        let bin_path = |binary: &str| -> Vec<String> {\n            let folder = self.venv_folder;\n            let path = if cfg!(windows) {\n                format!(\"{folder}/scripts/{binary}.exe\")\n            } else {\n                format!(\"{folder}/bin/{binary}\")\n            };\n            vec![path]\n        };\n\n        build.add_inputs(\"\", &self.deps);\n        build.add_variable(\"pyenv_folder\", self.venv_folder);\n        if env::var(\"OFFLINE_BUILD\").is_err() {\n            build.add_inputs(\"uv_binary\", inputs![\":uv_binary\"]);\n\n            // Set --python flag to .python-version (--no-config ignores it)\n            // override if PYTHON_BINARY is set\n            let python = env::var(\"PYTHON_BINARY\").unwrap_or_else(|_| {\n                let python_version =\n                    read_file(\".python-version\").expect(\"No .python-version in cwd\");\n                let python_version_str =\n                    String::from_utf8(python_version).expect(\"Invalid UTF-8 in .python-version\");\n                python_version_str.trim().to_string()\n            });\n            build.add_variable(\"python\", python);\n            build.add_variable(\"extra_args\", self.extra_args);\n        }\n\n        build.add_outputs_ext(\"bin\", bin_path(\"python\"), true);\n        for binary in self.extra_binary_exports {\n            build.add_outputs_ext(*binary, bin_path(binary), true);\n        }\n        build.add_output_stamp(format!(\"{}/.stamp\", self.venv_folder));\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n\npub struct PythonTypecheck {\n    pub folders: &'static [&'static str],\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for PythonTypecheck {\n    fn command(&self) -> &str {\n        \"$mypy $folders\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"\", &self.deps);\n        build.add_inputs(\"mypy\", inputs![\":pyenv:mypy\"]);\n        build.add_inputs(\"\", inputs![\".mypy.ini\"]);\n        build.add_variable(\"folders\", self.folders.join(\" \"));\n\n        let hash = simple_hash(self.folders);\n        build.add_output_stamp(format!(\"tests/python_typecheck.{hash}\"));\n    }\n\n    fn hide_progress(&self) -> bool {\n        true\n    }\n}\n\nstruct PythonFormat<'a> {\n    pub inputs: &'a BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for PythonFormat<'_> {\n    fn command(&self) -> &str {\n        \"$ruff format $mode $in && $ruff check --select I --fix $in\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"in\", self.inputs);\n        build.add_inputs(\"ruff\", inputs![\":pyenv:ruff\"]);\n\n        let hash = simple_hash(self.inputs);\n        build.add_variable(\"mode\", if self.check_only { \"--check\" } else { \"\" });\n\n        build.add_output_stamp(format!(\n            \"tests/python_format.{}.{hash}\",\n            if self.check_only { \"check\" } else { \"fix\" }\n        ));\n    }\n}\n\npub fn python_format(build: &mut Build, group: &str, inputs: BuildInput) -> Result<()> {\n    build.add_action(\n        format!(\"check:format:python:{group}\"),\n        PythonFormat {\n            inputs: &inputs,\n            check_only: true,\n        },\n    )?;\n\n    build.add_action(\n        format!(\"format:python:{group}\"),\n        PythonFormat {\n            inputs: &inputs,\n            check_only: false,\n        },\n    )?;\n    Ok(())\n}\n\npub struct RuffCheck {\n    pub folders: &'static [&'static str],\n    pub deps: BuildInput,\n    pub check_only: bool,\n}\n\nimpl BuildAction for RuffCheck {\n    fn command(&self) -> &str {\n        \"$ruff check $folders $mode\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"\", &self.deps);\n        build.add_inputs(\"\", inputs![\".ruff.toml\"]);\n        build.add_inputs(\"ruff\", inputs![\":pyenv:ruff\"]);\n        build.add_variable(\"folders\", self.folders.join(\" \"));\n        build.add_variable(\n            \"mode\",\n            if self.check_only {\n                \"\"\n            } else {\n                \"--fix --unsafe-fixes\"\n            },\n        );\n\n        let hash = simple_hash(&self.deps);\n        let kind = if self.check_only { \"check\" } else { \"fix\" };\n        build.add_output_stamp(format!(\"tests/python_ruff.{kind}.{hash}\"));\n    }\n}\n\npub struct PythonTest {\n    pub folder: &'static str,\n    pub python_path: &'static [&'static str],\n    pub deps: BuildInput,\n}\n\nimpl BuildAction for PythonTest {\n    fn command(&self) -> &str {\n        \"$pytest -p no:cacheprovider $folder\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        build.add_inputs(\"\", &self.deps);\n        build.add_inputs(\"pytest\", inputs![\":pyenv:pytest\"]);\n        build.add_variable(\"folder\", self.folder);\n        build.add_variable(\n            \"pythonpath\",\n            self.python_path.join(if cfg!(windows) { \";\" } else { \":\" }),\n        );\n        build.add_env_var(\"PYTHONPATH\", \"$pythonpath\");\n        build.add_env_var(\"ANKI_TEST_MODE\", \"1\");\n        let hash = simple_hash(self.folder);\n        build.add_output_stamp(format!(\"tests/python_pytest.{hash}\"));\n    }\n\n    fn hide_progress(&self) -> bool {\n        true\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/render.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Write;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anyhow::Result;\nuse itertools::Itertools;\n\nuse crate::archives::with_exe;\nuse crate::input::space_separated;\nuse crate::Build;\n\nimpl Build {\n    pub fn render(&self) -> String {\n        let mut buf = String::new();\n\n        writeln!(\n            &mut buf,\n            \"# This file is automatically generated by configure.rs. Any edits will be lost.\\n\"\n        )\n        .unwrap();\n\n        writeln!(&mut buf, \"builddir = {}\", self.buildroot.as_str()).unwrap();\n        writeln!(\n            &mut buf,\n            \"runner = $builddir/rust/release/{}\",\n            with_exe(\"runner\")\n        )\n        .unwrap();\n        for (key, value) in &self.variables {\n            writeln!(&mut buf, \"{key} = {value}\").unwrap();\n        }\n        buf.push('\\n');\n\n        for (key, value) in &self.pools {\n            writeln!(&mut buf, \"pool {key}\\n  depth = {value}\").unwrap();\n        }\n        buf.push('\\n');\n\n        buf.push_str(&self.output_text);\n\n        for (group, targets) in self.groups.iter().sorted() {\n            let group = group.replace(':', \"_\");\n            writeln!(\n                &mut buf,\n                \"build {group}: phony {}\",\n                space_separated(targets)\n            )\n            .unwrap();\n            buf.push('\\n');\n        }\n\n        buf.push_str(&self.trailing_text);\n\n        buf\n    }\n\n    pub fn write_build_file(&self) -> Result<()> {\n        create_dir_all(&self.buildroot)?;\n        let path = self.buildroot.join(\"build.ninja\");\n        let contents = self.render().into_bytes();\n        write_file_if_changed(path, contents)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/rsync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse camino::Utf8Path;\n\nuse crate::action::BuildAction;\nuse crate::build::FilesHandle;\nuse crate::input::space_separated;\nuse crate::input::BuildInput;\n\n/// Rsync the provided inputs into `output_folder`, preserving directory\n/// structure, eg foo/bar.js -> out/$target_folder/foo/bar.js. `strip_prefix`\n/// can be used to remove a portion of the the path when copying. If the input\n/// files are from previous build outputs, the prefix should begin with\n/// `$builddir/`.\npub struct RsyncFiles<'a> {\n    pub inputs: BuildInput,\n    pub target_folder: &'a str,\n    pub strip_prefix: &'static str,\n    pub extra_args: &'a str,\n}\n\nimpl BuildAction for RsyncFiles<'_> {\n    fn command(&self) -> &str {\n        \"$runner rsync $extra_args --prefix $stripped_prefix --inputs $inputs_without_prefix --output-dir $builddir/$output_folder\"\n    }\n\n    fn files(&mut self, build: &mut impl FilesHandle) {\n        let inputs = build.expand_inputs(&self.inputs);\n        build.add_inputs_vec(\"\", inputs.clone());\n        let output_folder = Utf8Path::new(self.target_folder);\n        let (prefix, inputs_without_prefix) = if self.strip_prefix.is_empty() {\n            (\".\", inputs)\n        } else {\n            let stripped_inputs = inputs\n                .iter()\n                .map(|p| {\n                    Utf8Path::new(p)\n                        .strip_prefix(self.strip_prefix)\n                        .unwrap_or_else(|_| {\n                            panic!(\"expected {} to start with {}\", p, self.strip_prefix)\n                        })\n                        .to_string()\n                })\n                .collect();\n            (self.strip_prefix, stripped_inputs)\n        };\n        build.add_variable(\n            \"inputs_without_prefix\",\n            space_separated(&inputs_without_prefix),\n        );\n        build.add_variable(\"stripped_prefix\", prefix);\n        build.add_variable(\"output_folder\", self.target_folder);\n        if !self.extra_args.is_empty() {\n            build.add_variable(\n                \"extra_args\",\n                format!(\"--extra-args {}\", self.extra_args.replace(' ', \",\")),\n            );\n        }\n\n        let outputs = inputs_without_prefix\n            .iter()\n            .map(|p| output_folder.join(p).to_string());\n        build.add_outputs(\"\", outputs);\n    }\n\n    fn check_output_timestamps(&self) -> bool {\n        true\n    }\n}\n"
  },
  {
    "path": "build/ninja_gen/src/sass.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anyhow::Result;\n\nuse crate::action::BuildAction;\nuse crate::cargo::CargoInstall;\nuse crate::input::space_separated;\nuse crate::input::BuildInput;\nuse crate::inputs;\nuse crate::Build;\n\npub struct CompileSassWithGrass {\n    pub input: BuildInput,\n    pub output: &'static str,\n    pub deps: BuildInput,\n    pub load_paths: Vec<&'static str>,\n}\n\nimpl BuildAction for CompileSassWithGrass {\n    fn command(&self) -> &str {\n        \"$grass $args -s compressed $in -- $out\"\n    }\n\n    fn files(&mut self, build: &mut impl crate::build::FilesHandle) {\n        let args = space_separated(self.load_paths.iter().map(|path| format!(\"-I {path}\")));\n\n        build.add_inputs(\"grass\", inputs![\":grass\"]);\n        build.add_inputs(\"in\", &self.input);\n        build.add_inputs(\"\", &self.deps);\n        build.add_variable(\"args\", args);\n        build.add_outputs(\"out\", vec![self.output]);\n    }\n\n    fn on_first_instance(&self, build: &mut Build) -> Result<()> {\n        build.add_action(\n            \"grass\",\n            CargoInstall {\n                binary_name: \"grass\",\n                args: \"grass --version 0.11.2\",\n            },\n        )?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "build/runner/Cargo.toml",
    "content": "[package]\nname = \"runner\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanki_io.workspace = true\nanki_process.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\nclap.workspace = true\nflate2.workspace = true\njunction.workspace = true\nsha2.workspace = true\ntar.workspace = true\ntermcolor.workspace = true\ntokio.workspace = true\nwhich.workspace = true\nxz2.workspace = true\nzip.workspace = true\nzstd.workspace = true\n\n[target.'cfg(windows)'.dependencies]\nreqwest = { workspace = true, features = [\"native-tls\"] }\n\n[target.'cfg(not(windows))'.dependencies]\nreqwest = { workspace = true, features = [\"rustls-tls\", \"rustls-tls-native-roots\"] }\n"
  },
  {
    "path": "build/runner/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfn main() {\n    println!(\n        \"cargo:rustc-env=TARGET={}\",\n        if std::env::var(\"MAC_X86\").is_ok() {\n            \"x86_64-apple-darwin\".into()\n        } else {\n            std::env::var(\"TARGET\").unwrap()\n        }\n    );\n}\n"
  },
  {
    "path": "build/runner/src/archive.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::io::Read;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::read_file;\nuse anki_io::write_file;\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse clap::Args;\nuse clap::Subcommand;\nuse sha2::Digest;\n\n#[derive(Subcommand)]\npub enum ArchiveArgs {\n    Download(DownloadArgs),\n    Extract(ExtractArgs),\n}\n\n#[derive(Args)]\npub struct DownloadArgs {\n    archive_url: String,\n    checksum: String,\n    output_path: PathBuf,\n}\n\n#[derive(Args)]\npub struct ExtractArgs {\n    archive_path: String,\n    output_folder: String,\n}\n\n#[tokio::main]\npub async fn archive_command(args: ArchiveArgs) -> Result<()> {\n    match args {\n        ArchiveArgs::Download(args) => {\n            download_and_check(&args.archive_url, &args.checksum, &args.output_path).await\n        }\n        ArchiveArgs::Extract(args) => extract_archive(&args.archive_path, &args.output_folder),\n    }\n}\n\nasync fn download_and_check(archive_url: &str, checksum: &str, output_path: &Path) -> Result<()> {\n    // skip download if we already have a valid file\n    if output_path.exists() && sha2_data(&read_file(output_path)?) == checksum {\n        return Ok(());\n    }\n\n    let response = reqwest::get(archive_url).await?.error_for_status()?;\n    let data = response.bytes().await?.to_vec();\n    let actual_checksum = sha2_data(&data);\n    if actual_checksum != checksum {\n        println!(\"expected {checksum}, got {actual_checksum}\");\n        std::process::exit(1);\n    }\n    fs::write(output_path, data)?;\n\n    Ok(())\n}\n\nfn sha2_data(data: &[u8]) -> String {\n    let mut digest = sha2::Sha256::new();\n    digest.update(data);\n    let result = digest.finalize();\n    format!(\"{result:x}\")\n}\n\nenum CompressionKind {\n    Zstd,\n    Gzip,\n    Lzma,\n    /// handled by archive\n    Internal,\n}\n\nenum ArchiveKind {\n    Tar,\n    Zip,\n}\n\nfn extract_archive(archive_path: &str, output_folder: &str) -> Result<()> {\n    let archive_path = Utf8Path::new(archive_path);\n    let archive_filename = archive_path.file_name().unwrap();\n    let mut components = archive_filename.rsplit('.');\n    let last_component = components.next().unwrap();\n    let (compression, archive_suffix) = match last_component {\n        \"zst\" | \"zstd\" => (CompressionKind::Zstd, components.next().unwrap()),\n        \"gz\" => (CompressionKind::Gzip, components.next().unwrap()),\n        \"xz\" => (CompressionKind::Lzma, components.next().unwrap()),\n        \"tgz\" => (CompressionKind::Gzip, last_component),\n        \"zip\" => (CompressionKind::Internal, last_component),\n        other => panic!(\"unexpected compression: {other}\"),\n    };\n    let archive = match archive_suffix {\n        \"tar\" | \"tgz\" => ArchiveKind::Tar,\n        \"zip\" => ArchiveKind::Zip,\n        other => panic!(\"unexpected archive kind: {other}\"),\n    };\n\n    let reader = fs::File::open(archive_path)?;\n    let uncompressed_data = match compression {\n        CompressionKind::Zstd => zstd::decode_all(&reader)?,\n        CompressionKind::Gzip => {\n            let mut buf = Vec::new();\n            let mut decoder = flate2::read::GzDecoder::new(&reader);\n            decoder.read_to_end(&mut buf)?;\n            buf\n        }\n        CompressionKind::Lzma => {\n            let mut buf = Vec::new();\n            let mut decoder = xz2::read::XzDecoder::new(&reader);\n            decoder.read_to_end(&mut buf)?;\n            buf\n        }\n        CompressionKind::Internal => {\n            vec![]\n        }\n    };\n\n    let output_folder = Utf8Path::new(output_folder);\n    if output_folder.exists() {\n        fs::remove_dir_all(output_folder)?;\n    }\n    // extract into a temporary folder\n    let output_tmp =\n        output_folder.with_file_name(format!(\"{}.tmp\", output_folder.file_name().unwrap()));\n    match archive {\n        ArchiveKind::Tar => {\n            let mut archive = tar::Archive::new(&uncompressed_data[..]);\n            archive.set_preserve_mtime(false);\n            archive.unpack(&output_tmp)?;\n        }\n        ArchiveKind::Zip => {\n            let mut archive = zip::ZipArchive::new(reader)?;\n            archive.extract(&output_tmp)?;\n        }\n    }\n    // if the output folder contains a single folder (eg foo-1.2), move it up a\n    // level\n    let mut entries: Vec<_> = output_tmp.read_dir_utf8()?.take(2).collect();\n    let first_entry = entries.pop().unwrap()?;\n    if entries.is_empty() && first_entry.metadata()?.is_dir() {\n        fs::rename(first_entry.path(), output_folder)?;\n        fs::remove_dir_all(output_tmp)?;\n    } else {\n        fs::rename(output_tmp, output_folder)?;\n    }\n    write_file(output_folder.with_extension(\"marker\"), \"\")?;\n    Ok(())\n}\n"
  },
  {
    "path": "build/runner/src/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::fs;\nuse std::io::Write;\nuse std::process::Command;\nuse std::time::Instant;\n\nuse anki_process::CommandExt;\nuse anyhow::Context;\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\nuse clap::Args;\nuse termcolor::Color;\nuse termcolor::ColorChoice;\nuse termcolor::ColorSpec;\nuse termcolor::StandardStream;\nuse termcolor::WriteColor;\n\n#[derive(Args)]\npub struct BuildArgs {\n    #[arg(trailing_var_arg = true)]\n    args: Vec<String>,\n}\n\npub fn run_build(args: BuildArgs) {\n    let build_root = &setup_build_root();\n\n    let path = if cfg!(windows) {\n        format!(\n            \"out\\\\bin;out\\\\extracted\\\\node;node_modules\\\\.bin;{};\\\\msys64\\\\usr\\\\bin\",\n            env::var(\"PATH\").unwrap()\n        )\n    } else {\n        format!(\n            \"{br}/bin:{br}/extracted/node/bin:{path}\",\n            br = build_root\n                .canonicalize_utf8()\n                .expect(\"resolving build root\")\n                .as_str(),\n            path = env::var(\"PATH\").unwrap()\n        )\n    };\n\n    maybe_update_env_file(build_root);\n    maybe_update_buildhash(build_root);\n\n    // Ensure build file is up to date\n    let build_file = build_root.join(\"build.ninja\");\n    if !build_file.exists() {\n        bootstrap_build();\n    }\n\n    // automatically convert foo:bar references to foo_bar, as Ninja can not\n    // represent the former\n    let ninja_args = args.args.into_iter().map(|a| a.replace(':', \"_\"));\n\n    let start_time = Instant::now();\n    let mut command = Command::new(get_ninja_command());\n    command\n        .arg(\"-f\")\n        .arg(&build_file)\n        .args(ninja_args)\n        .env(\"PATH\", &path)\n        .env(\n            \"MYPY_CACHE_DIR\",\n            build_root.join(\"tests\").join(\"mypy\").into_string(),\n        )\n        .env(\n            \"PYTHONPYCACHEPREFIX\",\n            std::path::absolute(build_root.join(\"pycache\")).unwrap(),\n        )\n        // commands will not show colors by default, as we do not provide a tty\n        .env(\"FORCE_COLOR\", \"1\")\n        .env(\"MYPY_FORCE_COLOR\", \"1\")\n        .env(\"TERM\", std::env::var(\"TERM\").unwrap_or_default());\n    if env::var(\"NINJA_STATUS\").is_err() {\n        command.env(\"NINJA_STATUS\", \"[%f/%t; %r active; %es] \");\n    }\n\n    // run build\n    let Ok(mut status) = command.status() else {\n        panic!(\"\\nn2 and ninja missing/failed. did you forget 'bash tools/install-n2'?\");\n    };\n    if !status.success() && Instant::now().duration_since(start_time).as_secs() < 3 {\n        // if the build fails quickly, there's a reasonable chance that build.ninja\n        // references a file that has been renamed/deleted. We currently don't\n        // capture stderr, so we can't confirm, but in case that's the case, we\n        // regenerate the build.ninja file then try again.\n        bootstrap_build();\n        status = command.status().expect(\"ninja missing\");\n    }\n    let mut stdout = StandardStream::stdout(ColorChoice::Always);\n    if status.success() {\n        stdout\n            .set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))\n            .unwrap();\n        writeln!(\n            &mut stdout,\n            \"\\nBuild succeeded in {:.2}s.\",\n            start_time.elapsed().as_secs_f32()\n        )\n        .unwrap();\n        stdout.reset().unwrap();\n    } else {\n        stdout\n            .set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))\n            .unwrap();\n        writeln!(&mut stdout, \"\\nBuild failed.\").unwrap();\n        stdout.reset().unwrap();\n\n        std::process::exit(1);\n    }\n}\n\nfn get_ninja_command() -> &'static str {\n    if which::which(\"n2\").is_ok() {\n        \"n2\"\n    } else {\n        \"ninja\"\n    }\n}\n\nfn setup_build_root() -> Utf8PathBuf {\n    let build_root = Utf8Path::new(\"out\");\n\n    #[cfg(unix)]\n    if let Ok(new_target) = env::var(\"BUILD_ROOT\").map(camino::Utf8PathBuf::from) {\n        let create = if let Ok(existing_target) = build_root.read_link_utf8() {\n            if existing_target != new_target {\n                fs::remove_file(build_root).unwrap();\n                true\n            } else {\n                false\n            }\n        } else {\n            true\n        };\n        if create {\n            println!(\"Switching build root to {new_target}\");\n            std::os::unix::fs::symlink(new_target, build_root).unwrap();\n        }\n    }\n\n    fs::create_dir_all(build_root).unwrap();\n    if cfg!(windows) {\n        build_root.to_owned()\n    } else {\n        build_root.canonicalize_utf8().unwrap()\n    }\n}\n\nfn bootstrap_build() {\n    let status = Command::new(\"cargo\")\n        .args([\"run\", \"-p\", \"configure\"])\n        .status();\n    assert!(status.expect(\"ninja\").success());\n}\n\nfn maybe_update_buildhash(build_root: &Utf8Path) {\n    // only updated on release builds\n    let path = build_root.join(\"buildhash\");\n    if (env::var(\"RELEASE\").is_ok() && env::var(\"OFFLINE_BUILD\").is_err()) || !path.exists() {\n        write_if_changed(&path, &get_buildhash())\n    }\n}\n\nfn get_buildhash() -> String {\n    let output = Command::new(\"git\")\n        .args([\"rev-parse\", \"--short=8\", \"HEAD\"])\n        .utf8_output()\n        .context(\n            \"Make sure you're building from a clone of the git repo, and that 'git' is installed.\",\n        )\n        .unwrap();\n    output.stdout.trim().into()\n}\n\nfn write_if_changed(path: &Utf8Path, contents: &str) {\n    if let Ok(old_contents) = fs::read_to_string(path) {\n        if old_contents == contents {\n            return;\n        }\n    }\n    fs::write(path, contents).unwrap();\n}\n\n/// Trigger reconfigure when our env vars change\nfn maybe_update_env_file(build_root: &Utf8Path) {\n    let env_file = build_root.join(\"env\");\n    let build_root_env = env::var(\"BUILD_ROOT\").unwrap_or_default();\n    let release = env::var(\"RELEASE\").unwrap_or_default();\n    let other_watched_env = env::var(\"RECONFIGURE_KEY\").unwrap_or_default();\n    let current_env = format!(\"{build_root_env};{release};{other_watched_env}\");\n\n    write_if_changed(&env_file, &current_env);\n}\n"
  },
  {
    "path": "build/runner/src/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! A helper for invoking one or more commands in a cross-platform way,\n//! silencing their output when they succeed. Most build actions implicitly use\n//! the 'run' command; we define separate commands for more complicated actions.\n\nmod archive;\nmod build;\nmod paths;\nmod pyenv;\nmod rsync;\nmod run;\nmod yarn;\n\nuse anyhow::Result;\nuse archive::archive_command;\nuse archive::ArchiveArgs;\nuse build::run_build;\nuse build::BuildArgs;\nuse clap::Parser;\nuse clap::Subcommand;\nuse pyenv::setup_pyenv;\nuse pyenv::PyenvArgs;\nuse rsync::rsync_files;\nuse rsync::RsyncArgs;\nuse run::run_commands;\nuse run::RunArgs;\nuse yarn::setup_yarn;\nuse yarn::YarnArgs;\n\n#[derive(Parser)]\nstruct Cli {\n    #[command(subcommand)]\n    command: Command,\n}\n\n#[derive(Subcommand)]\nenum Command {\n    Pyenv(PyenvArgs),\n    Yarn(YarnArgs),\n    Rsync(RsyncArgs),\n    Run(RunArgs),\n    Build(BuildArgs),\n    #[clap(subcommand)]\n    Archive(ArchiveArgs),\n}\n\nfn main() -> Result<()> {\n    match Cli::parse().command {\n        Command::Pyenv(args) => setup_pyenv(args),\n        Command::Run(args) => run_commands(args)?,\n        Command::Rsync(args) => rsync_files(args),\n        Command::Yarn(args) => setup_yarn(args),\n        Command::Build(args) => run_build(args),\n        Command::Archive(args) => archive_command(args)?,\n    };\n    Ok(())\n}\n"
  },
  {
    "path": "build/runner/src/paths.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse camino::Utf8Path;\n\n/// On Unix, just a normal path. On Windows, c:\\foo\\bar.txt becomes\n/// /c/foo/bar.txt, which msys rsync expects.\npub fn absolute_msys_path(path: &Utf8Path) -> String {\n    let path = path.canonicalize_utf8().unwrap().into_string();\n    if !cfg!(windows) {\n        return path;\n    }\n\n    // strip off \\\\? verbatim prefix, which things like rsync/ninja choke on\n    let drive = &path.chars().nth(4).unwrap();\n    // and \\ -> /\n    format!(\"/{drive}/{}\", path[7..].replace('\\\\', \"/\"))\n}\n"
  },
  {
    "path": "build/runner/src/pyenv.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::process::Command;\n\nuse camino::Utf8Path;\nuse clap::Args;\n\nuse crate::run::run_command;\n\n#[derive(Args)]\npub struct PyenvArgs {\n    uv_bin: String,\n    pyenv_folder: String,\n    python: String,\n    #[arg(trailing_var_arg = true)]\n    extra_args: Vec<String>,\n}\n\n/// Set up a venv if one doesn't already exist, and then sync packages with\n/// provided requirements file.\npub fn setup_pyenv(args: PyenvArgs) {\n    let pyenv_folder = Utf8Path::new(&args.pyenv_folder);\n\n    // On first run, ninja creates an empty bin/ folder which breaks the initial\n    // install. But we don't want to indiscriminately remove the folder, or\n    // macOS Gatekeeper needs to rescan the files each time.\n    if pyenv_folder.exists() {\n        let cache_tag = pyenv_folder.join(\"CACHEDIR.TAG\");\n        if !cache_tag.exists() {\n            fs::remove_dir_all(pyenv_folder).expect(\"Failed to remove existing pyenv folder\");\n        }\n    }\n\n    let mut command = Command::new(args.uv_bin);\n\n    // remove UV_* environment variables to avoid interference\n    for (key, _) in std::env::vars() {\n        if key.starts_with(\"UV_\") || key == \"VIRTUAL_ENV\" {\n            command.env_remove(key);\n        }\n    }\n\n    run_command(\n        command\n            .env(\"UV_PROJECT_ENVIRONMENT\", args.pyenv_folder.clone())\n            .args([\"sync\", \"--locked\", \"--no-config\"])\n            .args([\"--python\", &args.python])\n            .args(args.extra_args),\n    );\n\n    // Write empty stamp file\n    fs::write(pyenv_folder.join(\".stamp\"), \"\").expect(\"Failed to write stamp file\");\n}\n"
  },
  {
    "path": "build/runner/src/rsync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::process::Command;\n\nuse camino::Utf8Path;\nuse clap::Args;\n\nuse crate::paths::absolute_msys_path;\nuse crate::run::run_command;\n\n#[derive(Args)]\npub struct RsyncArgs {\n    #[arg(long, value_delimiter(','), allow_hyphen_values(true))]\n    extra_args: Vec<String>,\n    #[arg(long)]\n    prefix: String,\n    #[arg(long, required(true), num_args(..))]\n    inputs: Vec<String>,\n    #[arg(long)]\n    output_dir: String,\n}\n\npub fn rsync_files(args: RsyncArgs) {\n    let output_dir = absolute_msys_path(Utf8Path::new(&args.output_dir));\n    run_command(\n        Command::new(\"rsync\")\n            .current_dir(&args.prefix)\n            .arg(\"--relative\")\n            .args(args.extra_args)\n            .args(args.inputs.iter().map(|i| {\n                if cfg!(windows) {\n                    i.replace('\\\\', \"/\")\n                } else {\n                    i.clone()\n                }\n            }))\n            .arg(output_dir),\n    );\n}\n"
  },
  {
    "path": "build/runner/src/run.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::process::Command;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file;\nuse anki_process::CommandExt;\nuse anyhow::Result;\nuse clap::Args;\n\n#[derive(Args)]\npub struct RunArgs {\n    #[arg(long)]\n    stamp: Option<String>,\n    #[arg(long, value_parser = split_env)]\n    env: Vec<(String, String)>,\n    #[arg(long)]\n    cwd: Option<String>,\n    #[arg(long)]\n    mkdir: Vec<String>,\n    #[arg(trailing_var_arg = true)]\n    args: Vec<String>,\n}\n\n/// Run one or more commands separated by `&&`, optionally stamping or setting\n/// extra env vars.\npub fn run_commands(args: RunArgs) -> Result<()> {\n    let commands = split_args(args.args);\n    for dir in args.mkdir {\n        create_dir_all(&dir)?;\n    }\n    for command in commands {\n        run_command(&mut build_command(command, &args.env, &args.cwd));\n    }\n    if let Some(stamp_file) = args.stamp {\n        write_file(stamp_file, b\"\")?;\n    }\n    Ok(())\n}\n\nfn split_env(s: &str) -> Result<(String, String), std::io::Error> {\n    if let Some((k, v)) = s.split_once('=') {\n        Ok((k.into(), v.into()))\n    } else {\n        Err(std::io::Error::other(\"invalid env var\"))\n    }\n}\n\nfn build_command(\n    command_and_args: Vec<String>,\n    env: &[(String, String)],\n    cwd: &Option<String>,\n) -> Command {\n    let mut command = Command::new(&command_and_args[0]);\n    command.args(&command_and_args[1..]);\n    for (k, v) in env {\n        command.env(k, v);\n    }\n    if let Some(cwd) = cwd {\n        command.current_dir(cwd);\n    }\n    command\n}\n\n/// If multiple commands have been provided separated by &&, split them up.\nfn split_args(args: Vec<String>) -> Vec<Vec<String>> {\n    let mut commands = vec![];\n    let mut current_command = vec![];\n    for arg in args.into_iter() {\n        if arg == \"&&\" {\n            commands.push(current_command);\n            current_command = vec![];\n        } else {\n            current_command.push(arg)\n        }\n    }\n    if !current_command.is_empty() {\n        commands.push(current_command)\n    }\n    commands\n}\n\npub fn run_command(command: &mut Command) {\n    if let Err(err) = command.ensure_success() {\n        println!(\"{err}\");\n        std::process::exit(1);\n    }\n}\n"
  },
  {
    "path": "build/runner/src/yarn.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::path::Path;\nuse std::process::Command;\n\nuse clap::Args;\n\nuse crate::run::run_command;\n\n#[derive(Args)]\npub struct YarnArgs {\n    yarn_bin: String,\n    stamp: String,\n}\n\npub fn setup_yarn(args: YarnArgs) {\n    link_node_modules();\n\n    if env::var(\"OFFLINE_BUILD\").is_ok() {\n        println!(\"OFFLINE_BUILD is set\");\n        println!(\"Running yarn with '--offline' and '--ignore-scripts'.\");\n        run_command(\n            Command::new(&args.yarn_bin)\n                .arg(\"install\")\n                .arg(\"--offline\")\n                .arg(\"--ignore-scripts\"),\n        );\n    } else {\n        run_command(\n            Command::new(&args.yarn_bin)\n                .arg(\"install\")\n                .arg(\"--immutable\"),\n        );\n    }\n\n    std::fs::write(args.stamp, b\"\").unwrap();\n}\n\n/// Unfortunately a lot of the node ecosystem expects the output folder to\n/// reside in the repo root, so we need to link in our output folder.\n#[cfg(not(windows))]\nfn link_node_modules() {\n    let target = Path::new(\"node_modules\");\n    if target.exists() {\n        if !target.is_symlink() {\n            panic!(\"please remove the node_modules folder from the repo root\");\n        }\n    } else {\n        std::os::unix::fs::symlink(\"out/node_modules\", target).unwrap();\n    }\n}\n\n/// Things are more complicated on Windows - having $root/node_modules point to\n/// $root/out/node_modules breaks our globs for some reason, so we create the\n/// junction in the opposite direction instead. Ninja will have already created\n/// some empty folders based on our declared outputs, so we move the\n/// created folder into the root.\n#[cfg(windows)]\nfn link_node_modules() {\n    let target = Path::new(\"out/node_modules\");\n    let source = Path::new(\"node_modules\");\n    if !source.exists() {\n        std::fs::rename(target, source).unwrap();\n        junction::create(source, target).unwrap()\n    }\n}\n"
  },
  {
    "path": "cargo/README.md",
    "content": "This folder contains:\n\n- a list of Rust crate licenses, which is checked/updated with ./ninja [check|fix]:minilints\n- a nightly toolchain definition for formatting\n"
  },
  {
    "path": "cargo/format/rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"nightly-2025-03-20\"\nprofile = \"minimal\"\ncomponents = [\"rustfmt\"]\n"
  },
  {
    "path": "cargo/licenses.json",
    "content": "[\n  {\n    \"authors\": null,\n    \"description\": \"A cross-platform symbolication library written in Rust, using `gimli`\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"addr2line\",\n    \"repository\": \"https://github.com/gimli-rs/addr2line\"\n  },\n  {\n    \"authors\": \"Jonas Schievink <jonasschievink@gmail.com>|oyvindln <oyvindln@users.noreply.github.com>\",\n    \"description\": \"A simple clean-room implementation of the Adler-32 checksum\",\n    \"license\": \"0BSD OR Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"adler2\",\n    \"repository\": \"https://github.com/oyvindln/adler2\"\n  },\n  {\n    \"authors\": \"Tom Kaitchuck <Tom.Kaitchuck@gmail.com>\",\n    \"description\": \"A non-cryptographic hash function using AES-NI for high performance\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ahash\",\n    \"repository\": \"https://github.com/tkaitchuck/ahash\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Fast multiple substring searching.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"aho-corasick\",\n    \"repository\": \"https://github.com/BurntSushi/aho-corasick\"\n  },\n  {\n    \"authors\": \"Zakarum <zaq.dev@icloud.com>\",\n    \"description\": \"Mirror of Rust's allocator API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"allocator-api2\",\n    \"repository\": \"https://github.com/zakarumych/allocator-api2\"\n  },\n  {\n    \"authors\": \"Michael Howell <michael@notriddle.com>\",\n    \"description\": \"HTML Sanitization\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ammonia\",\n    \"repository\": \"https://github.com/rust-ammonia/ammonia\"\n  },\n  {\n    \"authors\": \"RumovZ\",\n    \"description\": \"Parser for the Android-specific tzdata file\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"android-tzdata\",\n    \"repository\": \"https://github.com/RumovZ/android-tzdata\"\n  },\n  {\n    \"authors\": \"Nicolas Silva <nical@fastmail.com>\",\n    \"description\": \"Minimal Android system properties wrapper\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"android_system_properties\",\n    \"repository\": \"https://github.com/nical/android_system_properties\"\n  },\n  {\n    \"authors\": \"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\",\n    \"description\": \"Anki's Rust library code\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"license_file\": null,\n    \"name\": \"anki\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\",\n    \"description\": \"Anki's Rust library i18n code\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"license_file\": null,\n    \"name\": \"anki_i18n\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\",\n    \"description\": \"Utils for better I/O error reporting\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"license_file\": null,\n    \"name\": \"anki_io\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\",\n    \"description\": \"Anki's Rust library protobuf code\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"license_file\": null,\n    \"name\": \"anki_proto\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"Ankitects Pty Ltd and contributors <https://help.ankiweb.net>\",\n    \"description\": \"Helpers for interface code generation\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"license_file\": null,\n    \"name\": \"anki_proto_gen\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Flexible concrete Error type built on std::error::Error\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"anyhow\",\n    \"repository\": \"https://github.com/dtolnay/anyhow\"\n  },\n  {\n    \"authors\": \"The Rust-Fuzz Project Developers|Nick Fitzgerald <fitzgen@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>|Simonas Kazlauskas <arbitrary@kazlauskas.me>|Brian L. Troutwine <brian@troutwine.us>|Corey Farwell <coreyf@rwell.org>\",\n    \"description\": \"The trait for generating structured data from unstructured data\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"arbitrary\",\n    \"repository\": \"https://github.com/rust-fuzz/arbitrary/\"\n  },\n  {\n    \"authors\": \"David Roundy <roundyd@physics.oregonstate.edu>\",\n    \"description\": \"Macros to take array references of slices\",\n    \"license\": \"BSD-2-Clause\",\n    \"license_file\": null,\n    \"name\": \"arrayref\",\n    \"repository\": \"https://github.com/droundy/arrayref\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"A vector with fixed capacity, backed by an array (it can be stored on the stack too). Implements fixed capacity ArrayVec and ArrayString.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"arrayvec\",\n    \"repository\": \"https://github.com/bluss/arrayvec\"\n  },\n  {\n    \"authors\": \"Maik Klein <maikklein@googlemail.com>|Benjamin Saunders <ben.e.saunders@gmail.com>|Marijn Suijten <marijn@traverseresearch.nl>\",\n    \"description\": \"Vulkan bindings for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ash\",\n    \"repository\": \"https://github.com/ash-rs/ash\"\n  },\n  {\n    \"authors\": \"David Pedersen <david.pdrsn@gmail.com>\",\n    \"description\": \"Easily compare two JSON values and get great output\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"assert-json-diff\",\n    \"repository\": \"https://github.com/davidpdrsn/assert-json-diff.git\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>\",\n    \"description\": \"Async multi-producer multi-consumer channel\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"async-channel\",\n    \"repository\": \"https://github.com/smol-rs/async-channel\"\n  },\n  {\n    \"authors\": \"Wim Looman <wim@nemo157.com>|Allen Bui <fairingrey@gmail.com>\",\n    \"description\": \"Adaptors between compression crates and Rust's modern asynchronous IO types.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"async-compression\",\n    \"repository\": \"https://github.com/Nullus157/async-compression\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>\",\n    \"description\": \"Asynchronous streams using async & await notation\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"async-stream\",\n    \"repository\": \"https://github.com/tokio-rs/async-stream\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>\",\n    \"description\": \"proc macros for async-stream crate\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"async-stream-impl\",\n    \"repository\": \"https://github.com/tokio-rs/async-stream\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Type erasure for async trait methods\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"async-trait\",\n    \"repository\": \"https://github.com/dtolnay/async-trait\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>|Contributors to futures-rs\",\n    \"description\": \"A synchronization primitive for task wakeup\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"atomic-waker\",\n    \"repository\": \"https://github.com/smol-rs/atomic-waker\"\n  },\n  {\n    \"authors\": \"Thom Chiovoloni <chiovolonit@gmail.com>\",\n    \"description\": \"Floating point types which can be safely shared between threads\",\n    \"license\": \"Apache-2.0 OR MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"atomic_float\",\n    \"repository\": \"https://github.com/thomcc/atomic_float\"\n  },\n  {\n    \"authors\": \"Josh Stone <cuviper@gmail.com>\",\n    \"description\": \"Automatic cfg for Rust compiler features\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"autocfg\",\n    \"repository\": \"https://github.com/cuviper/autocfg\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Web framework that focuses on ergonomics and modularity\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"axum\",\n    \"repository\": \"https://github.com/tokio-rs/axum\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Client IP address extractors for Axum\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"axum-client-ip\",\n    \"repository\": \"https://github.com/imbolc/axum-client-ip\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Core types and traits for axum\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"axum-core\",\n    \"repository\": \"https://github.com/tokio-rs/axum\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Extra utilities for axum\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"axum-extra\",\n    \"repository\": \"https://github.com/tokio-rs/axum\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Macros for axum\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"axum-macros\",\n    \"repository\": \"https://github.com/tokio-rs/axum\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A library to acquire a stack trace (backtrace) at runtime in a Rust program.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"backtrace\",\n    \"repository\": \"https://github.com/rust-lang/backtrace-rs\"\n  },\n  {\n    \"authors\": \"Marshall Pierce <marshall@mpierce.org>\",\n    \"description\": \"encodes and decodes base64 as bytes or utf8\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"base64\",\n    \"repository\": \"https://github.com/marshallpierce/rust-base64\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Pure Rust implementation of Base64 (RFC 4648) which avoids any usages of data-dependent branches/LUTs and thereby provides portable \\\"best effort\\\" constant-time operation and embedded-friendly no_std support\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"base64ct\",\n    \"repository\": \"https://github.com/RustCrypto/formats\"\n  },\n  {\n    \"authors\": \"Ty Overby <ty@pre-alpha.com>|Zoey Riordan <zoey@dos.cafe>|Victor Koenders <bincode@trangar.com>\",\n    \"description\": \"A binary serialization / deserialization strategy for transforming structs into bytes and vice versa!\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"bincode\",\n    \"repository\": \"https://github.com/bincode-org/bincode\"\n  },\n  {\n    \"authors\": \"Alexis Beingessner <a.beingessner@gmail.com>\",\n    \"description\": \"A set of bits\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"bit-set\",\n    \"repository\": \"https://github.com/contain-rs/bit-set\"\n  },\n  {\n    \"authors\": \"Alexis Beingessner <a.beingessner@gmail.com>\",\n    \"description\": \"A vector of bits\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"bit-vec\",\n    \"repository\": \"https://github.com/contain-rs/bit-vec\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A macro to generate structures which behave like bitflags.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"bitflags\",\n    \"repository\": \"https://github.com/bitflags/bitflags\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A macro to generate structures which behave like bitflags.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"bitflags\",\n    \"repository\": \"https://github.com/bitflags/bitflags\"\n  },\n  {\n    \"authors\": \"Jack O'Connor <oconnor663@gmail.com>|Samuel Neves\",\n    \"description\": \"the BLAKE3 hash function\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR CC0-1.0\",\n    \"license_file\": null,\n    \"name\": \"blake3\",\n    \"repository\": \"https://github.com/BLAKE3-team/BLAKE3\"\n  },\n  {\n    \"authors\": \"Steven Sheldon\",\n    \"description\": \"Rust interface for Apple's C language extension of blocks.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"block\",\n    \"repository\": \"http://github.com/SSheldon/rust-block\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Buffer type for block processing of data\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"block-buffer\",\n    \"repository\": \"https://github.com/RustCrypto/utils\"\n  },\n  {\n    \"authors\": \"Nick Fitzgerald <fitzgen@gmail.com>\",\n    \"description\": \"A fast bump allocation arena for Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"bumpalo\",\n    \"repository\": \"https://github.com/fitzgen/bumpalo\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Flexible and Comprehensive Deep Learning Framework in Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn\",\n    \"repository\": \"https://github.com/tracel-ai/burn\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Automatic differentiation backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-autodiff\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-autodiff\"\n  },\n  {\n    \"authors\": \"louisfd <louisfd94@gmail.com>\",\n    \"description\": \"Candle backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-candle\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-candle\"\n  },\n  {\n    \"authors\": \"Dilshod Tadjibaev (@antimora)\",\n    \"description\": \"Common crate for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-common\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-common\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Flexible and Comprehensive Deep Learning Framework in Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-core\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-core\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Generic backend that can be compiled just-in-time to any shader language target\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-cubecl\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-cubecl\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"CUDA backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-cuda\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-cuda\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Library with simple dataset APIs for creating ML data pipelines\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-dataset\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-dataset\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Derive crate for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-derive\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-derive\"\n  },\n  {\n    \"authors\": \"laggui <lagrange.guillaume.1@gmail.com>|nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Intermediate representation for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-ir\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-ir\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Ndarray backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-ndarray\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-ndarray\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"ROCm HIP backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-rocm\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-rocm\"\n  },\n  {\n    \"authors\": \"laggui <lagrange.guillaume.1@gmail.com>|nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Multi-backend router decorator for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-router\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-router\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Tensor library with user-friendly APIs and automatic differentiation support\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-tensor\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-tensor\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Training crate for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-train\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-train\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"WGPU backend for the Burn framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"burn-wgpu\",\n    \"repository\": \"https://github.com/tracel-ai/burn/tree/main/crates/burn-wgpu\"\n  },\n  {\n    \"authors\": \"Lokathor <zefria@gmail.com>\",\n    \"description\": \"A crate for mucking around with piles of bytes.\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"bytemuck\",\n    \"repository\": \"https://github.com/Lokathor/bytemuck\"\n  },\n  {\n    \"authors\": \"Lokathor <zefria@gmail.com>\",\n    \"description\": \"derive proc-macros for `bytemuck`\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"bytemuck_derive\",\n    \"repository\": \"https://github.com/Lokathor/bytemuck\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Library for reading/writing numbers in big-endian and little-endian.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"byteorder\",\n    \"repository\": \"https://github.com/BurntSushi/byteorder\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>|Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Types and traits for working with bytes\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"bytes\",\n    \"repository\": \"https://github.com/tokio-rs/bytes\"\n  },\n  {\n    \"authors\": \"Hyunsik Choi <hyunsik.choi@gmail.com>\",\n    \"description\": \"an utility for human-readable bytes representations\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"bytesize\",\n    \"repository\": \"https://github.com/bytesize-rs/bytesize/\"\n  },\n  {\n    \"authors\": \"Without Boats <saoirse@without.boats>|Ashley Williams <ashley666ashley@gmail.com>|Steve Klabnik <steve@steveklabnik.com>|Rain <rain@sunshowers.io>\",\n    \"description\": \"UTF-8 paths\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"camino\",\n    \"repository\": \"https://github.com/camino-rs/camino\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Minimalist ML framework.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"candle-core\",\n    \"repository\": \"https://github.com/huggingface/candle\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"A build-time dependency for Cargo build scripts to assist in invoking the native C compiler to compile native C code into a static archive to be linked into Rust code.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cc\",\n    \"repository\": \"https://github.com/rust-lang/cc-rs\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"A macro to ergonomically define an item depending on a large number of #[cfg] parameters. Structured like an if-else chain, the first matching branch is the item that gets emitted.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cfg-if\",\n    \"repository\": \"https://github.com/rust-lang/cfg-if\"\n  },\n  {\n    \"authors\": \"Zicklag <zicklag@katharostech.com>\",\n    \"description\": \"A tiny utility to help save you a lot of effort with long winded `#[cfg()]` checks.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"cfg_aliases\",\n    \"repository\": \"https://github.com/katharostech/cfg_aliases\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Date and time library for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"chrono\",\n    \"repository\": \"https://github.com/chronotope/chrono\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"HTTP client IP address extractors\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"client-ip\",\n    \"repository\": \"https://github.com/imbolc/client-ip\"\n  },\n  {\n    \"authors\": \"Frank Denis <github@pureftpd.org>\",\n    \"description\": \"Time and duration crate optimized for speed\",\n    \"license\": \"ISC\",\n    \"license_file\": null,\n    \"name\": \"coarsetime\",\n    \"repository\": \"https://github.com/jedisct1/rust-coarsetime\"\n  },\n  {\n    \"authors\": \"Brendan Zabarauskas <bjzaba@yahoo.com.au>\",\n    \"description\": \"Beautiful diagnostic reporting for text-based programming languages\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"codespan-reporting\",\n    \"repository\": \"https://github.com/brendanzab/codespan\"\n  },\n  {\n    \"authors\": \"Thomas Wickham <mackwic@gmail.com>\",\n    \"description\": \"The most simple way to add colors in your terminal\",\n    \"license\": \"MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"colored\",\n    \"repository\": \"https://github.com/mackwic/colored\"\n  },\n  {\n    \"authors\": \"Wim Looman <wim@nemo157.com>|Allen Bui <fairingrey@gmail.com>\",\n    \"description\": \"Adaptors for various compression algorithms.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"compression-codecs\",\n    \"repository\": \"https://github.com/Nullus157/async-compression\"\n  },\n  {\n    \"authors\": \"Wim Looman <wim@nemo157.com>|Allen Bui <fairingrey@gmail.com>\",\n    \"description\": \"Abstractions for compression algorithms.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"compression-core\",\n    \"repository\": \"https://github.com/Nullus157/async-compression\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>|Taiki Endo <te316e89@gmail.com>|John Nunley <dev@notgull.net>\",\n    \"description\": \"Concurrent multi-producer multi-consumer queue\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"concurrent-queue\",\n    \"repository\": \"https://github.com/smol-rs/concurrent-queue\"\n  },\n  {\n    \"authors\": \"Cesar Eduardo Barros <cesarb@cesarb.eti.br>\",\n    \"description\": \"Compares two equal-sized byte strings in constant time.\",\n    \"license\": \"Apache-2.0 OR CC0-1.0 OR MIT-0\",\n    \"license_file\": null,\n    \"name\": \"constant_time_eq\",\n    \"repository\": \"https://github.com/cesarb/constant_time_eq\"\n  },\n  {\n    \"authors\": \"rutrum <dave@rutrum.net>\",\n    \"description\": \"Convert strings into any case\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"convert_case\",\n    \"repository\": \"https://github.com/rutrum/convert-case\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"Bindings to Core Foundation for macOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"core-foundation\",\n    \"repository\": \"https://github.com/servo/core-foundation-rs\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"Bindings to Core Foundation for macOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"core-foundation\",\n    \"repository\": \"https://github.com/servo/core-foundation-rs\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"Bindings to Core Foundation for macOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"core-foundation-sys\",\n    \"repository\": \"https://github.com/servo/core-foundation-rs\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"Bindings for some fundamental Core Graphics types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"core-graphics-types\",\n    \"repository\": \"https://github.com/servo/core-foundation-rs\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Lightweight runtime CPU feature detection for aarch64, loongarch64, and x86/x86_64 targets,  with no_std support and support for mobile targets including Android and iOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cpufeatures\",\n    \"repository\": \"https://github.com/RustCrypto/utils\"\n  },\n  {\n    \"authors\": \"Sam Rijs <srijs@airpost.net>|Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Fast, SIMD-accelerated CRC32 (IEEE) checksum computation\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crc32fast\",\n    \"repository\": \"https://github.com/srijs/rust-crc32fast\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Multi-producer multi-consumer channels for message passing\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crossbeam-channel\",\n    \"repository\": \"https://github.com/crossbeam-rs/crossbeam\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Concurrent work-stealing deque\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crossbeam-deque\",\n    \"repository\": \"https://github.com/crossbeam-rs/crossbeam\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Epoch-based garbage collection\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crossbeam-epoch\",\n    \"repository\": \"https://github.com/crossbeam-rs/crossbeam\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Utilities for concurrent programming\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crossbeam-utils\",\n    \"repository\": \"https://github.com/crossbeam-rs/crossbeam\"\n  },\n  {\n    \"authors\": \"Eira Fransham <jackefransham@gmail.com>\",\n    \"description\": \"Crunchy unroller: deterministically unroll constant loops\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"crunchy\",\n    \"repository\": \"https://github.com/eira-fransham/crunchy\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Common cryptographic traits\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"crypto-common\",\n    \"repository\": \"https://github.com/RustCrypto/traits\"\n  },\n  {\n    \"authors\": \"Simon Sapin <simon.sapin@exyr.org>\",\n    \"description\": \"Rust implementation of CSS Syntax Level 3\",\n    \"license\": \"MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"cssparser\",\n    \"repository\": \"https://github.com/servo/rust-cssparser\"\n  },\n  {\n    \"authors\": \"Simon Sapin <simon.sapin@exyr.org>\",\n    \"description\": \"Procedural macros for cssparser\",\n    \"license\": \"MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"cssparser-macros\",\n    \"repository\": \"https://github.com/servo/rust-cssparser\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Fast CSV parsing with support for serde.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"csv\",\n    \"repository\": \"https://github.com/BurntSushi/rust-csv\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Bare bones CSV parsing with no_std support.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"csv-core\",\n    \"repository\": \"https://github.com/BurntSushi/rust-csv\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"Multi-platform high-performance compute language extension for Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl\"\n  },\n  {\n    \"authors\": \"Dilshod Tadjibaev (@antimora)|Nathaniel Simard (@nathanielsimard)\",\n    \"description\": \"Common crate for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-common\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-common\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com>\",\n    \"description\": \"CubeCL core create\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-core\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-core\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"CPP transpiler for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-cpp\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-cpp\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"CUDA runtime for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-cuda\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-cuda\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"AMD ROCm HIP runtime for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-hip\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-hip\"\n  },\n  {\n    \"authors\": \"Tracel Technologies Inc.\",\n    \"description\": \"Rust bindings for AMD ROCm HIP runtime libraries used by CubeCL.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-hip-sys\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl-hip/tree/main/crates/cubecl-hip-sys\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com\",\n    \"description\": \"Intermediate representation for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-ir\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-ir\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com>\",\n    \"description\": \"CubeCL Linear Algebra Library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-linalg\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-linalg\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com\",\n    \"description\": \"Procedural macros for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-macros\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-macros\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com\",\n    \"description\": \"Internal procedural macros for CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-macros-internal\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-macros-internal\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com>|maxtremblay <t.maxime@pm.me>\",\n    \"description\": \"CubeCL Reduce Algorithms.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-reduce\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-reduce\"\n  },\n  {\n    \"authors\": \"louisfd <louisfd94@gmail.com>|Nathaniel Simard\",\n    \"description\": \"Crate that helps creating high performance async runtimes for CubeCL.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-runtime\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-runtime\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>|louisfd <louisfd94@gmail.com>|maxtremblay <t.maxime@pm.me>\",\n    \"description\": \"CubeCL Standard Library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-std\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-std\"\n  },\n  {\n    \"authors\": \"nathanielsimard <nathaniel.simard.42@gmail.com>\",\n    \"description\": \"WGPU runtime for the CubeCL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cubecl-wgpu\",\n    \"repository\": \"https://github.com/tracel-ai/cubecl/tree/main/crates/cubecl-wgpu\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Safe wrappers around CUDA apis\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"cudarc\",\n    \"repository\": \"https://github.com/coreylowman/cudarc\"\n  },\n  {\n    \"authors\": \"Ted Driggs <ted.driggs@outlook.com>\",\n    \"description\": \"A proc-macro library for reading attributes into structs when implementing custom derives.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"darling\",\n    \"repository\": \"https://github.com/TedDriggs/darling\"\n  },\n  {\n    \"authors\": \"Ted Driggs <ted.driggs@outlook.com>\",\n    \"description\": \"Helper crate for proc-macro library for reading attributes into structs when implementing custom derives. Use https://crates.io/crates/darling in your code.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"darling_core\",\n    \"repository\": \"https://github.com/TedDriggs/darling\"\n  },\n  {\n    \"authors\": \"Ted Driggs <ted.driggs@outlook.com>\",\n    \"description\": \"Internal support for a proc-macro library for reading attributes into structs when implementing custom derives. Use https://crates.io/crates/darling in your code.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"darling_macro\",\n    \"repository\": \"https://github.com/TedDriggs/darling\"\n  },\n  {\n    \"authors\": \"Julien Cretin <git@ia0.eu>\",\n    \"description\": \"Efficient and customizable data-encoding functions like base64, base32, and hex\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"data-encoding\",\n    \"repository\": \"https://github.com/ia0/data-encoding\"\n  },\n  {\n    \"authors\": \"Michael P. Jung <michael.jung@terreon.de>\",\n    \"description\": \"Dead simple async pool\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"deadpool\",\n    \"repository\": \"https://github.com/bikeshedder/deadpool\"\n  },\n  {\n    \"authors\": \"Michael P. Jung <michael.jung@terreon.de>\",\n    \"description\": \"Dead simple async pool utitities for sync managers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"deadpool-runtime\",\n    \"repository\": \"https://github.com/bikeshedder/deadpool\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <jacob@jhpratt.dev>\",\n    \"description\": \"Ranged integers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"deranged\",\n    \"repository\": \"https://github.com/jhpratt/deranged\"\n  },\n  {\n    \"authors\": \"Nick Cameron <nrc@ncameron.org>\",\n    \"description\": \"`#[derive(new)]` implements simple constructor functions for structs and enums.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"derive-new\",\n    \"repository\": \"https://github.com/nrc/derive-new\"\n  },\n  {\n    \"authors\": \"Nick Cameron <nrc@ncameron.org>\",\n    \"description\": \"`#[derive(new)]` implements simple constructor functions for structs and enums.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"derive-new\",\n    \"repository\": \"https://github.com/nrc/derive-new\"\n  },\n  {\n    \"authors\": \"The Rust-Fuzz Project Developers|Nick Fitzgerald <fitzgen@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>|Andre Bogus <bogusandre@gmail.com>|Corey Farwell <coreyf@rwell.org>\",\n    \"description\": \"Derives arbitrary traits\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"derive_arbitrary\",\n    \"repository\": \"https://github.com/rust-fuzz/arbitrary\"\n  },\n  {\n    \"authors\": \"Jelte Fennema <github-tech@jeltef.nl>\",\n    \"description\": \"Adds #[derive(x)] macros for more traits\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"derive_more\",\n    \"repository\": \"https://github.com/JelteF/derive_more\"\n  },\n  {\n    \"authors\": \"Jelte Fennema <github-tech@jeltef.nl>\",\n    \"description\": \"Internal implementation of `derive_more` crate\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"derive_more-impl\",\n    \"repository\": \"https://github.com/JelteF/derive_more\"\n  },\n  {\n    \"authors\": \"Dima Kudosh <dimakudosh@gmail.com>\",\n    \"description\": \"Port of Python's difflib library to Rust.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"difflib\",\n    \"repository\": \"https://github.com/DimaKudosh/difflib\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Traits for cryptographic hash functions and message authentication codes\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"digest\",\n    \"repository\": \"https://github.com/RustCrypto/traits\"\n  },\n  {\n    \"authors\": \"Simon Ochsenreither <simon@ochsenreither.de>\",\n    \"description\": \"A tiny low-level library that provides platform-specific standard locations of directories for config, cache and other data on Linux, Windows, macOS and Redox by leveraging the mechanisms defined by the XDG base/user directory specifications on Linux, the Known Folder API on Windows, and the Standard Directory guidelines on macOS.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"dirs\",\n    \"repository\": \"https://github.com/soc/dirs-rs\"\n  },\n  {\n    \"authors\": \"Simon Ochsenreither <simon@ochsenreither.de>\",\n    \"description\": \"A tiny low-level library that provides platform-specific standard locations of directories for config, cache and other data on Linux, Windows, macOS and Redox by leveraging the mechanisms defined by the XDG base/user directory specifications on Linux, the Known Folder API on Windows, and the Standard Directory guidelines on macOS.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"dirs\",\n    \"repository\": \"https://github.com/soc/dirs-rs\"\n  },\n  {\n    \"authors\": \"Simon Ochsenreither <simon@ochsenreither.de>\",\n    \"description\": \"System-level helper functions for the dirs and directories crates.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"dirs-sys\",\n    \"repository\": \"https://github.com/dirs-dev/dirs-sys-rs\"\n  },\n  {\n    \"authors\": \"Simon Ochsenreither <simon@ochsenreither.de>\",\n    \"description\": \"System-level helper functions for the dirs and directories crates.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"dirs-sys\",\n    \"repository\": \"https://github.com/dirs-dev/dirs-sys-rs\"\n  },\n  {\n    \"authors\": \"Jane Lusby <jlusby@yaah.dev>\",\n    \"description\": \"A derive macro for implementing the display Trait via a doc comment and string interpolation\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"displaydoc\",\n    \"repository\": \"https://github.com/yaahc/displaydoc\"\n  },\n  {\n    \"authors\": \"Slint Developers <info@slint.dev>\",\n    \"description\": \"Extract documentation for the feature flags from comments in Cargo.toml\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"document-features\",\n    \"repository\": \"https://github.com/slint-ui/document-features\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Fast floating point primitive to string conversion\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"dtoa\",\n    \"repository\": \"https://github.com/dtolnay/dtoa\"\n  },\n  {\n    \"authors\": \"Xidorn Quan <me@upsuper.org>\",\n    \"description\": \"Serialize float number and truncate to certain precision\",\n    \"license\": \"MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"dtoa-short\",\n    \"repository\": \"https://github.com/upsuper/dtoa-short\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Dynamic stack wrapper for unsized allocations\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"dyn-stack\",\n    \"repository\": \"https://github.com/kitegi/dynstack/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Dynamic stack wrapper for unsized allocations\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"dyn-stack\",\n    \"repository\": \"https://github.com/kitegi/dynstack/\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"The enum `Either` with variants `Left` and `Right` is a general purpose sum type with two cases.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"either\",\n    \"repository\": \"https://github.com/rayon-rs/either\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"no-std, no-alloc utilities for working with futures\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"embassy-futures\",\n    \"repository\": \"https://github.com/embassy-rs/embassy\"\n  },\n  {\n    \"authors\": \"Henri Sivonen <hsivonen@hsivonen.fi>\",\n    \"description\": \"A Gecko-oriented implementation of the Encoding Standard\",\n    \"license\": \"(Apache-2.0 OR MIT) AND BSD-3-Clause\",\n    \"license_file\": null,\n    \"name\": \"encoding_rs\",\n    \"repository\": \"https://github.com/hsivonen/encoding_rs\"\n  },\n  {\n    \"authors\": \"Benjamin Fry <benjaminfry@me.com>\",\n    \"description\": \"A proc-macro for deriving inner field accessor functions on enums.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"enum-as-inner\",\n    \"repository\": \"https://github.com/bluejekyll/enum-as-inner\"\n  },\n  {\n    \"authors\": \"softprops <d.tangren@gmail.com>\",\n    \"description\": \"deserialize env vars into typesafe structs\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"envy\",\n    \"repository\": \"https://github.com/softprops/envy\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Traits for key comparison in maps.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"equivalent\",\n    \"repository\": \"https://github.com/indexmap-rs/equivalent\"\n  },\n  {\n    \"authors\": \"Chris Wong <lambda.fairy@gmail.com>|Dan Gohman <dev@sunfishcode.online>\",\n    \"description\": \"Cross-platform interface to the `errno` variable.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"errno\",\n    \"repository\": \"https://github.com/lambda-fairy/rust-errno\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>|John Nunley <dev@notgull.net>\",\n    \"description\": \"Notify async tasks or threads\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"event-listener\",\n    \"repository\": \"https://github.com/smol-rs/event-listener\"\n  },\n  {\n    \"authors\": \"John Nunley <dev@notgull.net>\",\n    \"description\": \"Block or poll on event_listener easily\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"event-listener-strategy\",\n    \"repository\": \"https://github.com/smol-rs/event-listener-strategy\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Fallible iterator traits\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fallible-iterator\",\n    \"repository\": \"https://github.com/sfackler/rust-fallible-iterator\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Fallible streaming iteration\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fallible-streaming-iterator\",\n    \"repository\": \"https://github.com/sfackler/fallible-streaming-iterator\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>\",\n    \"description\": \"A simple and fast random number generator\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fastrand\",\n    \"repository\": \"https://github.com/smol-rs/fastrand\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"FixedBitSet is a simple bitset collection\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fixedbitset\",\n    \"repository\": \"https://github.com/petgraph/fixedbitset\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>|Josh Triplett <josh@joshtriplett.org>\",\n    \"description\": \"DEFLATE compression and decompression exposed as Read/BufRead/Write streams. Supports miniz_oxide and multiple zlib implementations. Supports zlib, gzip, and raw deflate streams.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"flate2\",\n    \"repository\": \"https://github.com/rust-lang/flate2-rs\"\n  },\n  {\n    \"authors\": \"Michael Howell <michael@notriddle.com>\",\n    \"description\": \"A total ordering for floating-point numbers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"float-ord\",\n    \"repository\": \"https://github.com/notriddle/rust-float-ord\"\n  },\n  {\n    \"authors\": \"Caleb Maclennan <caleb@alerque.com>|Bruce Mitchener <bruce.mitchener@gmail.com|Zibi Braniecki <zibi@unicode.org>|Staś Małolepszy <stas@mozilla.com>\",\n    \"description\": \"An umbrella crate exposing the combined features of fluent-rs crates with additional convenience macros for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fluent\",\n    \"repository\": \"https://github.com/projectfluent/fluent-rs\"\n  },\n  {\n    \"authors\": \"Caleb Maclennan <caleb@alerque.com>|Bruce Mitchener <bruce.mitchener@gmail.com|Zibi Braniecki <zibi@unicode.org>|Staś Małolepszy <stas@mozilla.com>\",\n    \"description\": \"A low-level implementation of a collection of localization messages for a single locale for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fluent-bundle\",\n    \"repository\": \"https://github.com/projectfluent/fluent-rs\"\n  },\n  {\n    \"authors\": \"Zibi Braniecki <gandalf@mozilla.com>\",\n    \"description\": \"A library for language and locale negotiation.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"fluent-langneg\",\n    \"repository\": \"https://github.com/projectfluent/fluent-langneg-rs\"\n  },\n  {\n    \"authors\": \"Caleb Maclennan <caleb@alerque.com>|Bruce Mitchener <bruce.mitchener@gmail.com|Zibi Braniecki <zibi@unicode.org>|Staś Małolepszy <stas@mozilla.com>\",\n    \"description\": \"A low-level parser, AST, and serializer API for the syntax used by Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fluent-syntax\",\n    \"repository\": \"https://github.com/projectfluent/fluent-rs\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Fowler–Noll–Vo hash function\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"fnv\",\n    \"repository\": \"https://github.com/servo/rust-fnv\"\n  },\n  {\n    \"authors\": \"Orson Peters <orsonpeters@gmail.com>\",\n    \"description\": \"A fast, non-cryptographic, minimally DoS-resistant hashing algorithm.\",\n    \"license\": \"Zlib\",\n    \"license_file\": null,\n    \"name\": \"foldhash\",\n    \"repository\": \"https://github.com/orlp/foldhash\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"A framework for Rust wrappers over C APIs\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"foreign-types\",\n    \"repository\": \"https://github.com/sfackler/foreign-types\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"A framework for Rust wrappers over C APIs\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"foreign-types\",\n    \"repository\": \"https://github.com/sfackler/foreign-types\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"An internal crate used by foreign-types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"foreign-types-macros\",\n    \"repository\": \"https://github.com/sfackler/foreign-types\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"An internal crate used by foreign-types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"foreign-types-shared\",\n    \"repository\": \"https://github.com/sfackler/foreign-types\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"An internal crate used by foreign-types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"foreign-types-shared\",\n    \"repository\": \"https://github.com/sfackler/foreign-types\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"Parser and serializer for the application/x-www-form-urlencoded syntax, as used by HTML forms.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"form_urlencoded\",\n    \"repository\": \"https://github.com/servo/rust-url\"\n  },\n  {\n    \"authors\": \"Open Spaced Repetition\",\n    \"description\": \"FSRS for Rust, including Optimizer and Scheduler\",\n    \"license\": \"BSD-3-Clause\",\n    \"license_file\": null,\n    \"name\": \"fsrs\",\n    \"repository\": \"https://github.com/open-spaced-repetition/fsrs-rs\"\n  },\n  {\n    \"authors\": \"Keegan McAllister <kmcallister@mozilla.com>\",\n    \"description\": \"Handling fragments of UTF-8\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futf\",\n    \"repository\": \"https://github.com/servo/futf\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"An implementation of futures and streams featuring zero allocations, composability, and iterator-like interfaces.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Channels for asynchronous communication using futures-rs.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-channel\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"The core traits and types in for the `futures` library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-core\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Executors for asynchronous tasks based on the futures-rs library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-executor\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"The `AsyncRead`, `AsyncWrite`, `AsyncSeek`, and `AsyncBufRead` traits for the futures-rs library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-io\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>|Contributors to futures-rs\",\n    \"description\": \"Futures, streams, and async I/O combinators\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-lite\",\n    \"repository\": \"https://github.com/smol-rs/futures-lite\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"The futures-rs procedural macro implementations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-macro\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"The asynchronous `Sink` trait for the futures-rs library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-sink\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Tools for working with tasks.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-task\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Timeouts for futures.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-timer\",\n    \"repository\": \"https://github.com/async-rs/futures-timer\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Common utilities and extension traits for the futures-rs library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"futures-util\",\n    \"repository\": \"https://github.com/rust-lang/futures-rs\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-c32\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-c32\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-c64\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-c64\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-common\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-common\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f16\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f16\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f32\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f32\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f64\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Playground for matrix multiplication algorithms\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"gemm-f64\",\n    \"repository\": \"https://github.com/sarah-ek/gemm/\"\n  },\n  {\n    \"authors\": \"Bartłomiej Kamiński <fizyk20@gmail.com>|Aaron Trent <novacrazy@gmail.com>\",\n    \"description\": \"Generic types implementing functionality of arrays\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"generic-array\",\n    \"repository\": \"https://github.com/fizyk20/generic-array.git\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"getopts-like option parsing\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"getopts\",\n    \"repository\": \"https://github.com/rust-lang/getopts\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers\",\n    \"description\": \"A small cross-platform library for retrieving random data from system source\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"getrandom\",\n    \"repository\": \"https://github.com/rust-random/getrandom\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers\",\n    \"description\": \"A small cross-platform library for retrieving random data from system source\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"getrandom\",\n    \"repository\": \"https://github.com/rust-random/getrandom\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A library for reading and writing the DWARF debugging format.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gimli\",\n    \"repository\": \"https://github.com/gimli-rs/gimli\"\n  },\n  {\n    \"authors\": \"Brendan Zabarauskas <bjzaba@yahoo.com.au>|Corey Richardson|Arseny Kapoulkine\",\n    \"description\": \"Code generators for creating bindings to the Khronos OpenGL APIs.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"gl_generator\",\n    \"repository\": \"https://github.com/brendanzab/gl-rs/\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Support for matching file paths against Unix shell style patterns.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"glob\",\n    \"repository\": \"https://github.com/rust-lang/glob\"\n  },\n  {\n    \"authors\": \"Joshua Groves <josh@joshgroves.com>|Dzmitry Malyshau <kvarkus@gmail.com>\",\n    \"description\": \"GL on Whatever: a set of bindings to run GL (Open GL, OpenGL ES, and WebGL) anywhere, and avoid target-specific code.\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"glow\",\n    \"repository\": \"https://github.com/grovesNL/glow\"\n  },\n  {\n    \"authors\": \"Kirill Chibisov <contact@kchibisov.com>\",\n    \"description\": \"The wgl bindings for glutin\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"glutin_wgl_sys\",\n    \"repository\": \"https://github.com/rust-windowing/glutin\"\n  },\n  {\n    \"authors\": \"Zakarum <zakarumych@ya.ru>\",\n    \"description\": \"Implementation agnostic memory allocator for Vulkan like APIs\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gpu-alloc\",\n    \"repository\": \"https://github.com/zakarumych/gpu-alloc\"\n  },\n  {\n    \"authors\": \"Zakarum <zakarumych@ya.ru>\",\n    \"description\": \"Core types of gpu-alloc crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gpu-alloc-types\",\n    \"repository\": \"https://github.com/zakarumych/gpu-alloc\"\n  },\n  {\n    \"authors\": \"Traverse Research <opensource@traverseresearch.nl>\",\n    \"description\": \"Memory allocator for GPU memory in Vulkan and DirectX 12\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gpu-allocator\",\n    \"repository\": \"https://github.com/Traverse-Research/gpu-allocator\"\n  },\n  {\n    \"authors\": \"Zakarum <zakarumych@ya.ru>\",\n    \"description\": \"Implementation agnostic descriptor allocator for Vulkan like APIs\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gpu-descriptor\",\n    \"repository\": \"https://github.com/zakarumych/gpu-descriptor\"\n  },\n  {\n    \"authors\": \"Zakarum <zakarumych@ya.ru>\",\n    \"description\": \"Core types of gpu-descriptor crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"gpu-descriptor-types\",\n    \"repository\": \"https://github.com/zakarumych/gpu-descriptor\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>|Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"An HTTP/2 client and server\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"h2\",\n    \"repository\": \"https://github.com/hyperium/h2\"\n  },\n  {\n    \"authors\": \"Kathryn Long <squeeself@gmail.com>\",\n    \"description\": \"Half-precision floating point f16 and bf16 types for Rust implementing the IEEE 754-2008 standard binary16 and bfloat16 types.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"half\",\n    \"repository\": \"https://github.com/VoidStarKat/half-rs\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"A Rust port of Google's SwissTable hash map\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hashbrown\",\n    \"repository\": \"https://github.com/rust-lang/hashbrown\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"A Rust port of Google's SwissTable hash map\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hashbrown\",\n    \"repository\": \"https://github.com/rust-lang/hashbrown\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"A Rust port of Google's SwissTable hash map\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hashbrown\",\n    \"repository\": \"https://github.com/rust-lang/hashbrown\"\n  },\n  {\n    \"authors\": \"kyren <kerriganw@gmail.com>\",\n    \"description\": \"HashMap-like containers that hold their key-value pairs in a user controllable order\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hashlink\",\n    \"repository\": \"https://github.com/kyren/hashlink\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"typed HTTP headers\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"headers\",\n    \"repository\": \"https://github.com/hyperium/headers\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"typed HTTP headers core trait\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"headers-core\",\n    \"repository\": \"https://github.com/hyperium/headers\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"heck is a case conversion library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"heck\",\n    \"repository\": \"https://github.com/withoutboats/heck\"\n  },\n  {\n    \"authors\": \"Stefan Lankes\",\n    \"description\": \"Hermit system calls definitions.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hermit-abi\",\n    \"repository\": \"https://github.com/hermit-os/hermit-rs\"\n  },\n  {\n    \"authors\": \"KokaKiwi <kokakiwi@kokakiwi.net>\",\n    \"description\": \"Encoding and decoding data into/from hexadecimal representation.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hex\",\n    \"repository\": \"https://github.com/KokaKiwi/rust-hex\"\n  },\n  {\n    \"authors\": \"Kang Seonghoon <public+rust@mearie.org>\",\n    \"description\": \"Parses hexadecimal floats (see also hexf)\",\n    \"license\": \"CC0-1.0\",\n    \"license_file\": null,\n    \"name\": \"hexf-parse\",\n    \"repository\": \"https://github.com/lifthrasiir/hexf\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Generic implementation of Hash-based Message Authentication Code (HMAC)\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hmac\",\n    \"repository\": \"https://github.com/RustCrypto/MACs\"\n  },\n  {\n    \"authors\": \"The html5ever Project Developers\",\n    \"description\": \"High-performance browser-grade HTML5 parser\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"html5ever\",\n    \"repository\": \"https://github.com/servo/html5ever\"\n  },\n  {\n    \"authors\": \"Viktor Dahl <pazaconyoman@gmail.com>\",\n    \"description\": \"A library for HTML entity encoding and decoding\",\n    \"license\": \"Apache-2.0 OR MIT OR MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"htmlescape\",\n    \"repository\": \"https://github.com/veddan/rust-htmlescape\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>|Carl Lerche <me@carllerche.com>|Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"A set of types for representing HTTP requests and responses.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"http\",\n    \"repository\": \"https://github.com/hyperium/http\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>|Lucio Franco <luciofranco14@gmail.com>|Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Trait representing an asynchronous, streaming, HTTP request or response body.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"http-body\",\n    \"repository\": \"https://github.com/hyperium/http-body\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>|Lucio Franco <luciofranco14@gmail.com>|Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Combinators and adapters for HTTP request or response bodies.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"http-body-util\",\n    \"repository\": \"https://github.com/hyperium/http-body\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"No-dep range header parser\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"http-range-header\",\n    \"repository\": \"https://github.com/MarcusGrass/parse-range-headers\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"A tiny, safe, speedy, zero-copy HTTP/1.x parser.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"httparse\",\n    \"repository\": \"https://github.com/seanmonstar/httparse\"\n  },\n  {\n    \"authors\": \"Pyfisch <pyfisch@posteo.org>\",\n    \"description\": \"HTTP date parsing and formatting\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"httpdate\",\n    \"repository\": \"https://github.com/pyfisch/httpdate\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"A protective and efficient HTTP library for all.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"hyper\",\n    \"repository\": \"https://github.com/hyperium/hyper\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Rustls+hyper integration for pure rust HTTPS\",\n    \"license\": \"Apache-2.0 OR ISC OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hyper-rustls\",\n    \"repository\": \"https://github.com/rustls/hyper-rustls\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Default TLS implementation for use with hyper\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"hyper-tls\",\n    \"repository\": \"https://github.com/hyperium/hyper-tls\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"hyper utilities\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"hyper-util\",\n    \"repository\": \"https://github.com/hyperium/hyper-util\"\n  },\n  {\n    \"authors\": \"Andrew Straw <strawman@astraw.com>|René Kijewski <rene.kijewski@fu-berlin.de>|Ryan Lopopolo <rjl@hyperbo.la>\",\n    \"description\": \"get the IANA time zone for the current system\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"iana-time-zone\",\n    \"repository\": \"https://github.com/strawlab/iana-time-zone\"\n  },\n  {\n    \"authors\": \"René Kijewski <crates.io@k6i.de>\",\n    \"description\": \"iana-time-zone support crate for Haiku OS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"iana-time-zone-haiku\",\n    \"repository\": \"https://github.com/strawlab/iana-time-zone\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Collection of API for use in ICU libraries.\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_collections\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"API for managing Unicode Language and Locale Identifiers\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_locale_core\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"API for normalizing text into Unicode Normalization Forms\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_normalizer\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Data for the icu_normalizer crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_normalizer_data\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Definitions for Unicode properties\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_properties\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Data for the icu_properties crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_properties_data\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Trait and struct definitions for the ICU data provider\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"icu_provider\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Ian Burns <iwburns8@gmail.com>\",\n    \"description\": \"A library for creating and modifying Tree structures.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"id_tree\",\n    \"repository\": \"https://github.com/iwburns/id-tree\"\n  },\n  {\n    \"authors\": \"Ted Driggs <ted.driggs@outlook.com>\",\n    \"description\": \"Utility for applying case rules to Rust identifiers.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ident_case\",\n    \"repository\": \"https://github.com/TedDriggs/ident_case\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"IDNA (Internationalizing Domain Names in Applications) and Punycode.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"idna\",\n    \"repository\": \"https://github.com/servo/rust-url/\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"Back end adapter for idna\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"idna_adapter\",\n    \"repository\": \"https://github.com/hsivonen/idna_adapter\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A hash table with consistent order and fast iteration.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"indexmap\",\n    \"repository\": \"https://github.com/indexmap-rs/indexmap\"\n  },\n  {\n    \"authors\": \"Caleb Meredith <calebmeredith8@gmail.com>\",\n    \"description\": \"High performance inflection transformation library for changing properties of words like the case.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"inflections\",\n    \"repository\": \"https://docs.rs/inflections\"\n  },\n  {\n    \"authors\": \"Caleb Maclennan <caleb@alerque.com>|Bruce Mitchener <bruce.mitchener@gmail.com|Zibi Braniecki <zibi@unicode.org>|Staś Małolepszy <stas@mozilla.com>\",\n    \"description\": \"A memoizer specifically tailored for storing lazy-initialized intl formatters for Project Fluent, a localization system designed to unleash the entire expressive power of natural language translations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"intl-memoizer\",\n    \"repository\": \"https://github.com/projectfluent/fluent-rs\"\n  },\n  {\n    \"authors\": \"Kekoa Riggin <kekoariggin@gmail.com>|Zibi Braniecki <zbraniecki@mozilla.com>\",\n    \"description\": \"Unicode Plural Rules categorizer for numeric input.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"intl_pluralrules\",\n    \"repository\": \"https://github.com/zbraniecki/pluralrules\"\n  },\n  {\n    \"authors\": \"quininer <quininer@live.com>\",\n    \"description\": \"The low-level `io_uring` userspace interface for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"io-uring\",\n    \"repository\": \"https://github.com/tokio-rs/io-uring\"\n  },\n  {\n    \"authors\": \"Kris Price <kris@krisprice.nz>\",\n    \"description\": \"Provides types and useful methods for working with IPv4 and IPv6 network addresses, commonly called IP prefixes. The new `IpNet`, `Ipv4Net`, and `Ipv6Net` types build on the existing `IpAddr`, `Ipv4Addr`, and `Ipv6Addr` types already provided in Rust's standard library and align to their design to stay consistent. The module also provides useful traits that extend `Ipv4Addr` and `Ipv6Addr` with methods for `Add`, `Sub`, `BitAnd`, and `BitOr` operations. The module only uses stable feature so it is guaranteed to compile using the stable toolchain.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ipnet\",\n    \"repository\": \"https://github.com/krisprice/ipnet\"\n  },\n  {\n    \"authors\": \"YOSHIOKA Takuma <nop_thread@nops.red>\",\n    \"description\": \"IRI as string types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"iri-string\",\n    \"repository\": \"https://github.com/lo48576/iri-string\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"Extra iterator adaptors, iterator methods, free functions, and macros.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"itertools\",\n    \"repository\": \"https://github.com/rust-itertools/itertools\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Fast integer primitive to string conversion\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"itoa\",\n    \"repository\": \"https://github.com/dtolnay/itoa\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Rust definitions corresponding to jni.h\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"jni-sys\",\n    \"repository\": \"https://github.com/sfackler/rust-jni-sys\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"An implementation of the GNU Make jobserver for Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"jobserver\",\n    \"repository\": \"https://github.com/rust-lang/jobserver-rs\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Bindings for all JS global objects and functions in all JS environments like Node.js and browsers, built on `#[wasm_bindgen]` using the `wasm-bindgen` crate.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"js-sys\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/js-sys\"\n  },\n  {\n    \"authors\": \"Timothée Haudebourg <author@haudebourg.net>|Sean Kerr <sean@metatomic.io>\",\n    \"description\": \"Rust bindings for EGL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"khronos-egl\",\n    \"repository\": \"https://github.com/timothee-haudebourg/khronos-egl\"\n  },\n  {\n    \"authors\": \"Brendan Zabarauskas <bjzaba@yahoo.com.au>|Corey Richardson|Arseny Kapoulkine|Pierre Krieger <pierre.krieger1708@gmail.com>\",\n    \"description\": \"The Khronos XML API Registry, exposed as byte string constants.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"khronos_api\",\n    \"repository\": \"https://github.com/brendanzab/gl-rs/\"\n  },\n  {\n    \"authors\": \"Marvin Löbel <loebel.marvin@gmail.com>\",\n    \"description\": \"A macro for declaring lazily evaluated statics in Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"lazy_static\",\n    \"repository\": \"https://github.com/rust-lang-nursery/lazy-static.rs\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Raw FFI bindings to platform libraries like libc.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"libc\",\n    \"repository\": \"https://github.com/rust-lang/libc\"\n  },\n  {\n    \"authors\": \"Simonas Kazlauskas <libloading@kazlauskas.me>\",\n    \"description\": \"Bindings around the platform's dynamic library loading primitives with greatly improved memory safety.\",\n    \"license\": \"ISC\",\n    \"license_file\": null,\n    \"name\": \"libloading\",\n    \"repository\": \"https://github.com/nagisa/rust_libloading/\"\n  },\n  {\n    \"authors\": \"Jorge Aparicio <jorge@japaric.io>\",\n    \"description\": \"libm in pure Rust\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"libm\",\n    \"repository\": \"https://github.com/rust-lang/compiler-builtins\"\n  },\n  {\n    \"authors\": \"4lDO2 <4lDO2@protonmail.com>\",\n    \"description\": \"Redox stable ABI\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"libredox\",\n    \"repository\": \"https://gitlab.redox-os.org/redox-os/libredox.git\"\n  },\n  {\n    \"authors\": \"The rusqlite developers\",\n    \"description\": \"Native bindings to the libsqlite3 library\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"libsqlite3-sys\",\n    \"repository\": \"https://github.com/rusqlite/rusqlite\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A memory-safe zlib implementation written in rust\",\n    \"license\": \"Zlib\",\n    \"license_file\": null,\n    \"name\": \"libz-rs-sys\",\n    \"repository\": \"https://github.com/trifectatechfoundation/zlib-rs\"\n  },\n  {\n    \"authors\": \"Dan Gohman <dev@sunfishcode.online>\",\n    \"description\": \"Generated bindings for Linux's userspace API\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"linux-raw-sys\",\n    \"repository\": \"https://github.com/sunfishcode/linux-raw-sys\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"A key-value Map implementation based on a flat, sorted Vec.\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"litemap\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Lukas Kalbertodt <lukas.kalbertodt@gmail.com>\",\n    \"description\": \"Parse and inspect Rust literals (i.e. tokens in the Rust programming language representing fixed values). Particularly useful for proc macros, but can also be used outside of a proc-macro context.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"litrs\",\n    \"repository\": \"https://github.com/LukasKalbertodt/litrs/\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"Wrappers to create fully-featured Mutex and RwLock types. Compatible with no_std.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"lock_api\",\n    \"repository\": \"https://github.com/Amanieu/parking_lot\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A lightweight logging facade for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"log\",\n    \"repository\": \"https://github.com/rust-lang/log\"\n  },\n  {\n    \"authors\": \"Benjamin Saunders <ben.e.saunders@gmail.com>\",\n    \"description\": \"Pre-allocated storage with constant-time LRU tracking\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"lru-slab\",\n    \"repository\": \"https://github.com/Ralith/lru-slab\"\n  },\n  {\n    \"authors\": \"Jonathan Reem <jonathan.reem@gmail.com>\",\n    \"description\": \"A collection of great and ubiqutitous macros.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"mac\",\n    \"repository\": \"https://github.com/reem/rust-mac.git\"\n  },\n  {\n    \"authors\": \"Genna Wingert\",\n    \"description\": \"Type and target-generic SIMD\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"macerator\",\n    \"repository\": \"https://github.com/wingertge/macerator\"\n  },\n  {\n    \"authors\": \"Genna Wingert\",\n    \"description\": \"proc-macros for macerator\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"macerator-macros\",\n    \"repository\": \"https://github.com/wingertge/macerator\"\n  },\n  {\n    \"authors\": \"Steven Sheldon\",\n    \"description\": \"Structs for handling malloc'd memory passed to Rust.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"malloc_buf\",\n    \"repository\": \"https://github.com/SSheldon/malloc_buf\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"Collection “literal” macros for HashMap, HashSet, BTreeMap, and BTreeSet.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"maplit\",\n    \"repository\": \"https://github.com/bluss/maplit\"\n  },\n  {\n    \"authors\": \"The html5ever Project Developers\",\n    \"description\": \"Common code for xml5ever and html5ever\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"markup5ever\",\n    \"repository\": \"https://github.com/servo/html5ever\"\n  },\n  {\n    \"authors\": \"The html5ever Project Developers\",\n    \"description\": \"Procedural macro for html5ever.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"match_token\",\n    \"repository\": \"https://github.com/servo/html5ever\"\n  },\n  {\n    \"authors\": \"Eliza Weisman <eliza@buoyant.io>\",\n    \"description\": \"Regex matching on character and byte streams.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"matchers\",\n    \"repository\": \"https://github.com/hawkw/matchers\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A macro to evaluate, as a boolean, whether an expression matches a pattern.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"matches\",\n    \"repository\": \"https://github.com/SimonSapin/rust-std-candidates\"\n  },\n  {\n    \"authors\": \"Ibraheem Ahmed <ibraheem@ibraheem.ca>\",\n    \"description\": \"A high performance, zero-copy URL router.\",\n    \"license\": \"BSD-3-Clause AND MIT\",\n    \"license_file\": null,\n    \"name\": \"matchit\",\n    \"repository\": \"https://github.com/ibraheemdev/matchit\"\n  },\n  {\n    \"authors\": \"bluss|R. Janis Goldschmidt\",\n    \"description\": \"General matrix multiplication for f32 and f64 matrices. Operates on matrices with general layout (they can use arbitrary row and column stride). Detects and uses AVX or SSE2 on x86 platforms transparently for higher performance. Uses a microkernel strategy, so that the implementation is easy to parallelize and optimize.  Supports multithreading.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"matrixmultiply\",\n    \"repository\": \"https://github.com/bluss/matrixmultiply/\"\n  },\n  {\n    \"authors\": \"Ivan Ukhov <ivan.ukhov@gmail.com>|Kamal Ahmad <shibe@openmailbox.org>|Konstantin Stepanov <milezv@gmail.com>|Lukas Kalbertodt <lukas.kalbertodt@gmail.com>|Nathan Musoke <nathan.musoke@gmail.com>|Scott Mabin <scott@mabez.dev>|Tony Arcieri <bascule@gmail.com>|Wim de With <register@dewith.io>|Yosef Dinerstein <yosefdi@gmail.com>\",\n    \"description\": \"The package provides the MD5 hash function.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"md5\",\n    \"repository\": \"https://github.com/stainless-steel/md5\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>|bluss\",\n    \"description\": \"Provides extremely fast (uses SIMD on x86_64, aarch64 and wasm32) routines for 1, 2 or 3 byte search and single substring search.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"memchr\",\n    \"repository\": \"https://github.com/BurntSushi/memchr\"\n  },\n  {\n    \"authors\": \"Dan Burkert <dan@danburkert.com>|Yevhenii Reizner <razrfalcon@gmail.com>\",\n    \"description\": \"Cross-platform Rust API for memory-mapped file IO\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"memmap2\",\n    \"repository\": \"https://github.com/RazrFalcon/memmap2-rs\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Rust bindings for Metal\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"metal\",\n    \"repository\": \"https://github.com/gfx-rs/metal-rs\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Strongly Typed Mimes\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"mime\",\n    \"repository\": \"https://github.com/hyperium/mime\"\n  },\n  {\n    \"authors\": \"Austin Bonander <austin.bonander@gmail.com>\",\n    \"description\": \"A simple crate for detection of a file's MIME type by its extension.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"mime_guess\",\n    \"repository\": \"https://github.com/abonander/mime_guess\"\n  },\n  {\n    \"authors\": \"Alex Huszagh <ahuszagh@gmail.com>\",\n    \"description\": \"Fast float parsing conversion routines.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"minimal-lexical\",\n    \"repository\": \"https://github.com/Alexhuszagh/minimal-lexical\"\n  },\n  {\n    \"authors\": \"Frommi <daniil.liferenko@gmail.com>|oyvindln <oyvindln@users.noreply.github.com>|Rich Geldreich richgel99@gmail.com\",\n    \"description\": \"DEFLATE compression and decompression library rewritten in Rust based on miniz\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"miniz_oxide\",\n    \"repository\": \"https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>|Thomas de Zeeuw <thomasdezeeuw@gmail.com>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Lightweight non-blocking I/O.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"mio\",\n    \"repository\": \"https://github.com/tokio-rs/mio\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Macro for convenient module declaration. Each module can be put in a group, and visibility can be applied to the whole group with ease.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"moddef\",\n    \"repository\": \"https://github.com/sigurd4/moddef\"\n  },\n  {\n    \"authors\": \"Rousan Ali <hello@rousan.io>\",\n    \"description\": \"An async parser for `multipart/form-data` content-type in Rust.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"multer\",\n    \"repository\": \"https://github.com/rwf2/multer\"\n  },\n  {\n    \"authors\": \"Håvar Nøvik <havar.novik@gmail.com>\",\n    \"description\": \"A multimap implementation.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"multimap\",\n    \"repository\": \"https://github.com/havarnov/multimap\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Shader translator and validator. Part of the wgpu project\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"naga\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu/tree/trunk/naga\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"A wrapper over a platform's native TLS implementation\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"native-tls\",\n    \"repository\": \"https://github.com/sfackler/rust-native-tls\"\n  },\n  {\n    \"authors\": \"Ulrik Sverdrup \\\"bluss\\\"|Jim Turner\",\n    \"description\": \"An n-dimensional array for general elements and for numerics. Lightweight array views and slicing; views support chunking and splitting.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ndarray\",\n    \"repository\": \"https://github.com/rust-ndarray/ndarray\"\n  },\n  {\n    \"authors\": \"The Rust Windowing contributors\",\n    \"description\": \"FFI bindings for the Android NDK\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ndk-sys\",\n    \"repository\": \"https://github.com/rust-mobile/ndk\"\n  },\n  {\n    \"authors\": \"Matt Brubeck <mbrubeck@limpet.net>|Jonathan Reem <jonathan.reem@gmail.com>\",\n    \"description\": \"panic in debug, intrinsics::unreachable() in release (fork of debug_unreachable)\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"new_debug_unreachable\",\n    \"repository\": \"https://github.com/mbrubeck/rust-debug-unreachable\"\n  },\n  {\n    \"authors\": \"contact@geoffroycouprie.com\",\n    \"description\": \"A byte-oriented, zero-copy, parser combinators library\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"nom\",\n    \"repository\": \"https://github.com/Geal/nom\"\n  },\n  {\n    \"authors\": \"contact@geoffroycouprie.com\",\n    \"description\": \"A byte-oriented, zero-copy, parser combinators library\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"nom\",\n    \"repository\": \"https://github.com/rust-bakery/nom\"\n  },\n  {\n    \"authors\": \"MSxDOS <melcodos@gmail.com>\",\n    \"description\": \"FFI bindings for Native API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ntapi\",\n    \"repository\": \"https://github.com/MSxDOS/ntapi\"\n  },\n  {\n    \"authors\": \"ogham@bsago.me|Ryan Scheel (Havvy) <ryan.havvy@gmail.com>|Josh Triplett <josh@joshtriplett.org>|The Nushell Project Developers\",\n    \"description\": \"Library for ANSI terminal colors and styles (bold, underline)\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"nu-ansi-term\",\n    \"repository\": \"https://github.com/nushell/nu-ansi-term\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A collection of numeric types and traits for Rust, including bigint, complex, rational, range iterators, generic integers, and more!\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num\",\n    \"repository\": \"https://github.com/rust-num/num\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Big integer implementation for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-bigint\",\n    \"repository\": \"https://github.com/rust-num/num-bigint\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Complex numbers implementation for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-complex\",\n    \"repository\": \"https://github.com/rust-num/num-complex\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <jacob@jhpratt.dev>\",\n    \"description\": \"`num_conv` is a crate to convert between integer types without using `as` casts. This provides better certainty when refactoring, makes the exact behavior of code more explicit, and allows using turbofish syntax.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-conv\",\n    \"repository\": \"https://github.com/jhpratt/num-conv\"\n  },\n  {\n    \"authors\": \"Brian Myers <brian.carl.myers@gmail.com>\",\n    \"description\": \"A Rust crate for producing string-representations of numbers, formatted according to international standards\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-format\",\n    \"repository\": \"https://github.com/bcmyers/num-format\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Integer traits and functions\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-integer\",\n    \"repository\": \"https://github.com/rust-num/num-integer\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"External iterators for generic mathematics\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-iter\",\n    \"repository\": \"https://github.com/rust-num/num-iter\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Rational numbers implementation for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-rational\",\n    \"repository\": \"https://github.com/rust-num/num-rational\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"Numeric traits for generic mathematics\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num-traits\",\n    \"repository\": \"https://github.com/rust-num/num-traits\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Get the number of CPUs on a machine.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num_cpus\",\n    \"repository\": \"https://github.com/seanmonstar/num_cpus\"\n  },\n  {\n    \"authors\": \"Daniel Wagner-Hall <dawagner@gmail.com>|Daniel Henry-Mantilla <daniel.henry.mantilla@gmail.com>|Vincent Esche <regexident@gmail.com>\",\n    \"description\": \"Procedural macros to make inter-operation between primitives and enums easier.\",\n    \"license\": \"Apache-2.0 OR BSD-3-Clause OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num_enum\",\n    \"repository\": \"https://github.com/illicitonion/num_enum\"\n  },\n  {\n    \"authors\": \"Daniel Wagner-Hall <dawagner@gmail.com>|Daniel Henry-Mantilla <daniel.henry.mantilla@gmail.com>|Vincent Esche <regexident@gmail.com>\",\n    \"description\": \"Internal implementation details for ::num_enum (Procedural macros to make inter-operation between primitives and enums easier)\",\n    \"license\": \"Apache-2.0 OR BSD-3-Clause OR MIT\",\n    \"license_file\": null,\n    \"name\": \"num_enum_derive\",\n    \"repository\": \"https://github.com/illicitonion/num_enum\"\n  },\n  {\n    \"authors\": \"Cldfire\",\n    \"description\": \"A safe and ergonomic Rust wrapper for the NVIDIA Management Library\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"nvml-wrapper\",\n    \"repository\": \"https://github.com/Cldfire/nvml-wrapper\"\n  },\n  {\n    \"authors\": \"Cldfire\",\n    \"description\": \"Generated bindings to the NVIDIA Management Library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"nvml-wrapper-sys\",\n    \"repository\": \"https://github.com/Cldfire/nvml-wrapper\"\n  },\n  {\n    \"authors\": \"Steven Sheldon\",\n    \"description\": \"Objective-C Runtime bindings and wrapper for Rust.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"objc\",\n    \"repository\": \"http://github.com/SSheldon/rust-objc\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A unified interface for reading and writing object file formats.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"object\",\n    \"repository\": \"https://github.com/gimli-rs/object\"\n  },\n  {\n    \"authors\": \"Aleksey Kladov <aleksey.kladov@gmail.com>\",\n    \"description\": \"Single assignment cells and lazy values.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"once_cell\",\n    \"repository\": \"https://github.com/matklad/once_cell\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"OpenSSL bindings\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"openssl\",\n    \"repository\": \"https://github.com/sfackler/rust-openssl\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Internal macros used by the openssl crate.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"openssl-macros\",\n    \"repository\": null\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Tool for helping to find SSL certificate locations on the system for OpenSSL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"openssl-probe\",\n    \"repository\": \"https://github.com/alexcrichton/openssl-probe\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>|Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"FFI bindings to OpenSSL\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"openssl-sys\",\n    \"repository\": \"https://github.com/sfackler/rust-openssl\"\n  },\n  {\n    \"authors\": \"Simon Ochsenreither <simon@ochsenreither.de>\",\n    \"description\": \"Extends `Option` with additional operations\",\n    \"license\": \"MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"option-ext\",\n    \"repository\": \"https://github.com/soc/option-ext.git\"\n  },\n  {\n    \"authors\": \"Jonathan Reem <jonathan.reem@gmail.com>|Matt Brubeck <mbrubeck@limpet.net>\",\n    \"description\": \"Wrappers for total ordering on floats\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"ordered-float\",\n    \"repository\": \"https://github.com/reem/rust-ordered-float\"\n  },\n  {\n    \"authors\": \"Jonathan Reem <jonathan.reem@gmail.com>|Matt Brubeck <mbrubeck@limpet.net>\",\n    \"description\": \"Wrappers for total ordering on floats\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"ordered-float\",\n    \"repository\": \"https://github.com/reem/rust-ordered-float\"\n  },\n  {\n    \"authors\": \"Stjepan Glavina <stjepang@gmail.com>|The Rust Project Developers\",\n    \"description\": \"Thread parking and unparking\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"parking\",\n    \"repository\": \"https://github.com/smol-rs/parking\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"More compact and efficient implementations of the standard synchronization primitives.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"parking_lot\",\n    \"repository\": \"https://github.com/Amanieu/parking_lot\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"An advanced API for creating custom synchronization primitives.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"parking_lot_core\",\n    \"repository\": \"https://github.com/Amanieu/parking_lot\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Traits which describe the functionality of password hashing algorithms, as well as a `no_std`-friendly implementation of the PHC string format (a well-defined subset of the Modular Crypt Format a.k.a. MCF)\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"password-hash\",\n    \"repository\": \"https://github.com/RustCrypto/traits/tree/master/password-hash\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Macros for all your token pasting needs\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"paste\",\n    \"repository\": \"https://github.com/dtolnay/paste\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Generic implementation of PBKDF2\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pbkdf2\",\n    \"repository\": \"https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"Percent encoding and decoding\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"percent-encoding\",\n    \"repository\": \"https://github.com/servo/rust-url/\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"Percent encoding and decoding, preserving non-Latin characters.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"percent-encoding-iri\",\n    \"repository\": \"https://github.com/servo/rust-url/\"\n  },\n  {\n    \"authors\": \"Jeremy Salwen <jeremysalwen@gmail.com>\",\n    \"description\": \"Small utility for creating, manipulating, and applying permutations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"permutation\",\n    \"repository\": \"https://github.com/jeremysalwen/rust-permutations\"\n  },\n  {\n    \"authors\": \"bluss|mitchmindtree\",\n    \"description\": \"Graph data structure library. Provides graph types and graph algorithms.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"petgraph\",\n    \"repository\": \"https://github.com/petgraph/petgraph\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Runtime support for perfect hash function data structures\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"phf\",\n    \"repository\": \"https://github.com/rust-phf/rust-phf\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Codegen library for PHF types\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"phf_codegen\",\n    \"repository\": \"https://github.com/rust-phf/rust-phf\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"PHF generation logic\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"phf_generator\",\n    \"repository\": \"https://github.com/rust-phf/rust-phf\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Macros to generate types in the phf crate\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"phf_macros\",\n    \"repository\": \"https://github.com/rust-phf/rust-phf\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>\",\n    \"description\": \"Support code shared by PHF libraries\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"phf_shared\",\n    \"repository\": \"https://github.com/rust-phf/rust-phf\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A crate for safe and ergonomic pin-projection.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pin-project\",\n    \"repository\": \"https://github.com/taiki-e/pin-project\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Implementation detail of the `pin-project` crate.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pin-project-internal\",\n    \"repository\": \"https://github.com/taiki-e/pin-project\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A lightweight version of pin-project written with declarative macros.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pin-project-lite\",\n    \"repository\": \"https://github.com/taiki-e/pin-project-lite\"\n  },\n  {\n    \"authors\": \"Josef Brandl <mail@josefbrandl.de>\",\n    \"description\": \"Utilities for pinning\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pin-utils\",\n    \"repository\": \"https://github.com/rust-lang-nursery/pin-utils\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"A library to run the pkg-config system tool at build time in order to be used in Cargo build scripts.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"pkg-config\",\n    \"repository\": \"https://github.com/rust-lang/pkg-config-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Portable atomic types including support for 128-bit atomics, atomic float, etc.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"portable-atomic\",\n    \"repository\": \"https://github.com/taiki-e/portable-atomic\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Synchronization primitives built with portable-atomic.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"portable-atomic-util\",\n    \"repository\": \"https://github.com/taiki-e/portable-atomic\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Unvalidated string and character types\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"potential_utf\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <jacob@jhpratt.dev>\",\n    \"description\": \"`powerfmt` is a library that provides utilities for formatting values. This crate makes it     significantly easier to support filling to a minimum width with alignment, avoid heap     allocation, and avoid repetitive calculations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"powerfmt\",\n    \"repository\": \"https://github.com/jhpratt/powerfmt\"\n  },\n  {\n    \"authors\": \"The CryptoCorrosion Contributors\",\n    \"description\": \"Cross-platform cryptography-oriented low-level SIMD library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ppv-lite86\",\n    \"repository\": \"https://github.com/cryptocorrosion/cryptocorrosion\"\n  },\n  {\n    \"authors\": \"Emilio Cobos Álvarez <emilio@crisal.io>\",\n    \"description\": \"A library intending to be a base dependency to expose a precomputed hash\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"precomputed-hash\",\n    \"repository\": \"https://github.com/emilio/precomputed-hash\"\n  },\n  {\n    \"authors\": \"Embark <opensource@embark-studios.com>|Gray Olson <gray@grayolson.com\",\n    \"description\": \"A crate to help you copy things into raw buffers without invoking spooky action at a distance (undefined behavior).\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"presser\",\n    \"repository\": \"https://github.com/EmbarkStudios/presser\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"A minimal `syn` syntax tree pretty-printer\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"prettyplease\",\n    \"repository\": \"https://github.com/dtolnay/prettyplease\"\n  },\n  {\n    \"authors\": \"Gianmarco Garrisi <gianmarcogarrisi@tutanota.com>\",\n    \"description\": \"A Priority Queue implemented as a heap with a function to efficiently change the priority of an item.\",\n    \"license\": \"LGPL-3.0-or-later OR MPL-2.0\",\n    \"license_file\": null,\n    \"name\": \"priority-queue\",\n    \"repository\": \"https://github.com/garro95/priority-queue\"\n  },\n  {\n    \"authors\": \"Bastian Köcher <git@kchr.de>\",\n    \"description\": \"Replacement for crate (macro_rules keyword) in proc-macros\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"proc-macro-crate\",\n    \"repository\": \"https://github.com/bkchr/proc-macro-crate\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Procedural macros in expression position\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"proc-macro-hack\",\n    \"repository\": \"https://github.com/dtolnay/proc-macro-hack\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>|Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"A substitute implementation of the compiler's `proc_macro` API to decouple token-based libraries from the procedural macro use case.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"proc-macro2\",\n    \"repository\": \"https://github.com/dtolnay/proc-macro2\"\n  },\n  {\n    \"authors\": \"Philip Degarmo <aclysma@gmail.com>\",\n    \"description\": \"This crate provides a very thin abstraction over other profiler crates.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"profiling\",\n    \"repository\": \"https://github.com/aclysma/profiling\"\n  },\n  {\n    \"authors\": \"Dan Burkert <dan@danburkert.com>|Lucio Franco <luciofranco14@gmail.com>|Casper Meijn <casper@meijn.net>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"A Protocol Buffers implementation for the Rust Language.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"prost\",\n    \"repository\": \"https://github.com/tokio-rs/prost\"\n  },\n  {\n    \"authors\": \"Dan Burkert <dan@danburkert.com>|Lucio Franco <luciofranco14@gmail.com>|Casper Meijn <casper@meijn.net>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Generate Prost annotated Rust types from Protocol Buffers files.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"prost-build\",\n    \"repository\": \"https://github.com/tokio-rs/prost\"\n  },\n  {\n    \"authors\": \"Dan Burkert <dan@danburkert.com>|Lucio Franco <luciofranco14@gmail.com>|Casper Meijn <casper@meijn.net>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Generate encoding and decoding implementations for Prost annotated types.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"prost-derive\",\n    \"repository\": \"https://github.com/tokio-rs/prost\"\n  },\n  {\n    \"authors\": \"Andrew Hickman <andrew.hickman1@sky.com>\",\n    \"description\": \"A protobuf library extending prost with reflection support and dynamic messages.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"prost-reflect\",\n    \"repository\": \"https://github.com/andrewhickman/prost-reflect\"\n  },\n  {\n    \"authors\": \"Dan Burkert <dan@danburkert.com>|Lucio Franco <luciofranco14@gmail.com>|Casper Meijn <casper@meijn.net>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Prost definitions of Protocol Buffers well known types.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"prost-types\",\n    \"repository\": \"https://github.com/tokio-rs/prost\"\n  },\n  {\n    \"authors\": \"Raph Levien <raph.levien@gmail.com>|Marcus Klaas de Vries <mail@marcusklaas.nl>\",\n    \"description\": \"A pull parser for CommonMark\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"pulldown-cmark\",\n    \"repository\": \"https://github.com/raphlinus/pulldown-cmark\"\n  },\n  {\n    \"authors\": \"Raph Levien <raph.levien@gmail.com>|Marcus Klaas de Vries <mail@marcusklaas.nl>\",\n    \"description\": \"An escape library for HTML created in the pulldown-cmark project\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"pulldown-cmark-escape\",\n    \"repository\": \"https://github.com/raphlinus/pulldown-cmark\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Safe generic simd\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"pulp\",\n    \"repository\": \"https://github.com/sarah-ek/pulp/\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Safe generic simd\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"pulp\",\n    \"repository\": \"https://github.com/sarah-ek/pulp/\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Versatile QUIC transport protocol implementation\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"quinn\",\n    \"repository\": \"https://github.com/quinn-rs/quinn\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"State machine for the QUIC transport protocol\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"quinn-proto\",\n    \"repository\": \"https://github.com/quinn-rs/quinn\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"UDP sockets with ECN information for the QUIC transport protocol\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"quinn-udp\",\n    \"repository\": \"https://github.com/quinn-rs/quinn\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Quasi-quoting macro quote!(...)\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"quote\",\n    \"repository\": \"https://github.com/dtolnay/quote\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"UEFI Reference Specification Protocol Constants and Definitions\",\n    \"license\": \"Apache-2.0 OR LGPL-2.1-or-later OR MIT\",\n    \"license_file\": null,\n    \"name\": \"r-efi\",\n    \"repository\": \"https://github.com/r-efi/r-efi\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers\",\n    \"description\": \"Random number generators and other randomness functionality.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers\",\n    \"description\": \"Random number generators and other randomness functionality.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers|The CryptoCorrosion Contributors\",\n    \"description\": \"ChaCha random number generator\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand_chacha\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers|The CryptoCorrosion Contributors\",\n    \"description\": \"ChaCha random number generator\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand_chacha\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers\",\n    \"description\": \"Core random number generator traits and tools for implementation.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand_core\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers|The Rust Project Developers\",\n    \"description\": \"Core random number generator traits and tools for implementation.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand_core\",\n    \"repository\": \"https://github.com/rust-random/rand\"\n  },\n  {\n    \"authors\": \"The Rand Project Developers\",\n    \"description\": \"Sampling from random number distributions\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rand_distr\",\n    \"repository\": \"https://github.com/rust-random/rand_distr\"\n  },\n  {\n    \"authors\": \"the gfx-rs Developers\",\n    \"description\": \"Generic range allocator\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"range-alloc\",\n    \"repository\": \"https://github.com/gfx-rs/range-alloc\"\n  },\n  {\n    \"authors\": \"Gerd Zellweger <mail@gerdzellweger.com>\",\n    \"description\": \"A library to parse the x86 CPUID instruction, written in rust with no external dependencies. The implementation closely resembles the Intel CPUID manual description. The library does only depend on libcore.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"raw-cpuid\",\n    \"repository\": \"https://github.com/gz/rust-cpuid\"\n  },\n  {\n    \"authors\": \"Gerd Zellweger <mail@gerdzellweger.com>\",\n    \"description\": \"A library to parse the x86 CPUID instruction, written in rust with no external dependencies. The implementation closely resembles the Intel CPUID manual description. The library does only depend on libcore.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"raw-cpuid\",\n    \"repository\": \"https://github.com/gz/rust-cpuid\"\n  },\n  {\n    \"authors\": \"Osspial <osspial@gmail.com>\",\n    \"description\": \"Interoperability library for Rust Windowing applications.\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"raw-window-handle\",\n    \"repository\": \"https://github.com/rust-windowing/raw-window-handle\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"Extra methods for raw pointers and `NonNull<T>`.  For example `.post_inc()` and `.pre_dec()` (c.f. `ptr++` and `--ptr`), `offset` and `add` for `NonNull<T>`, and the function `ptrdistance`.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rawpointer\",\n    \"repository\": \"https://github.com/bluss/rawpointer/\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Simple work-stealing parallelism for Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rayon\",\n    \"repository\": \"https://github.com/rayon-rs/rayon\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Core APIs for Rayon\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rayon-core\",\n    \"repository\": \"https://github.com/rayon-rs/rayon\"\n  },\n  {\n    \"authors\": \"sarah <>\",\n    \"description\": \"Emulate reborrowing for user types.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"reborrow\",\n    \"repository\": \"https://github.com/sarah-ek/reborrow/\"\n  },\n  {\n    \"authors\": \"Jeremy Soller <jackpot51@gmail.com>\",\n    \"description\": \"A Rust library to access raw Redox system calls\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"redox_syscall\",\n    \"repository\": \"https://gitlab.redox-os.org/redox-os/syscall\"\n  },\n  {\n    \"authors\": \"Jose Narvaez <goyox86@gmail.com>|Wesley Hershberger <mggmugginsmc@gmail.com>\",\n    \"description\": \"A Rust library to access Redox users and groups functionality\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"redox_users\",\n    \"repository\": \"https://gitlab.redox-os.org/redox-os/users\"\n  },\n  {\n    \"authors\": \"Jose Narvaez <goyox86@gmail.com>|Wesley Hershberger <mggmugginsmc@gmail.com>\",\n    \"description\": \"A Rust library to access Redox users and groups functionality\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"redox_users\",\n    \"repository\": \"https://gitlab.redox-os.org/redox-os/users\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers|Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"An implementation of regular expressions for Rust. This implementation uses finite automata and guarantees linear time matching on all inputs.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"regex\",\n    \"repository\": \"https://github.com/rust-lang/regex\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers|Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Automata construction and matching using regular expressions.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"regex-automata\",\n    \"repository\": \"https://github.com/rust-lang/regex\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers|Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"A regular expression parser.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"regex-syntax\",\n    \"repository\": \"https://github.com/rust-lang/regex\"\n  },\n  {\n    \"authors\": \"John-John Tedro <udoprog@tedro.se>\",\n    \"description\": \"Portable, relative paths for Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"relative-path\",\n    \"repository\": \"https://github.com/udoprog/relative-path\"\n  },\n  {\n    \"authors\": \"Eyal Kalderon <ebkalderon@gmail.com>\",\n    \"description\": \"Low-level bindings to the RenderDoc API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"renderdoc-sys\",\n    \"repository\": \"https://github.com/ebkalderon/renderdoc-rs\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"higher level HTTP client library\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"reqwest\",\n    \"repository\": \"https://github.com/seanmonstar/reqwest\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"An experiment.\",\n    \"license\": \"Apache-2.0 AND ISC\",\n    \"license_file\": null,\n    \"name\": \"ring\",\n    \"repository\": \"https://github.com/briansmith/ring\"\n  },\n  {\n    \"authors\": \"Evgeny Safronov <division494@gmail.com>\",\n    \"description\": \"Pure Rust MessagePack serialization implementation\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"rmp\",\n    \"repository\": \"https://github.com/3Hren/msgpack-rust\"\n  },\n  {\n    \"authors\": \"Evgeny Safronov <division494@gmail.com>\",\n    \"description\": \"Serde bindings for RMP\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"rmp-serde\",\n    \"repository\": \"https://github.com/3Hren/msgpack-rust\"\n  },\n  {\n    \"authors\": \"Michele d'Amico <michele.damico@gmail.com>\",\n    \"description\": \"Rust fixture based test framework. It use procedural macro to implement fixtures and table based tests.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rstest\",\n    \"repository\": \"https://github.com/la10736/rstest\"\n  },\n  {\n    \"authors\": \"Michele d'Amico <michele.damico@gmail.com>\",\n    \"description\": \"Rust fixture based test framework. It use procedural macro to implement fixtures and table based tests.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rstest_macros\",\n    \"repository\": \"https://github.com/la10736/rstest\"\n  },\n  {\n    \"authors\": \"The rusqlite developers\",\n    \"description\": \"Ergonomic wrapper for SQLite\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"rusqlite\",\n    \"repository\": \"https://github.com/rusqlite/rusqlite\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Rust compiler symbol demangling.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustc-demangle\",\n    \"repository\": \"https://github.com/rust-lang/rustc-demangle\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"speed, non-cryptographic hash used in rustc\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustc-hash\",\n    \"repository\": \"https://github.com/rust-lang-nursery/rustc-hash\"\n  },\n  {\n    \"authors\": \"The Rust Project Developers\",\n    \"description\": \"A speedy, non-cryptographic hashing algorithm used by rustc\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustc-hash\",\n    \"repository\": \"https://github.com/rust-lang/rustc-hash\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A library for querying the version of a installed rustc compiler\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustc_version\",\n    \"repository\": \"https://github.com/djc/rustc-version-rs\"\n  },\n  {\n    \"authors\": \"Dan Gohman <dev@sunfishcode.online>|Jakub Konka <kubkon@jakubkonka.com>\",\n    \"description\": \"Safe Rust bindings to POSIX/Unix/Linux/Winsock-like syscalls\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustix\",\n    \"repository\": \"https://github.com/bytecodealliance/rustix\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Rustls is a modern TLS library written in Rust.\",\n    \"license\": \"Apache-2.0 OR ISC OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustls\",\n    \"repository\": \"https://github.com/rustls/rustls\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"rustls-native-certs allows rustls to use the platform native certificate store\",\n    \"license\": \"Apache-2.0 OR ISC OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustls-native-certs\",\n    \"repository\": \"https://github.com/rustls/rustls-native-certs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Basic .pem file parser for keys and certificates\",\n    \"license\": \"Apache-2.0 OR ISC OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustls-pemfile\",\n    \"repository\": \"https://github.com/rustls/pemfile\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Shared types for the rustls PKI ecosystem\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustls-pki-types\",\n    \"repository\": \"https://github.com/rustls/pki-types\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Web PKI X.509 Certificate Verification.\",\n    \"license\": \"ISC\",\n    \"license_file\": null,\n    \"name\": \"rustls-webpki\",\n    \"repository\": \"https://github.com/rustls/webpki\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Conditional compilation according to rustc compiler version\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"rustversion\",\n    \"repository\": \"https://github.com/dtolnay/rustversion\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Fast floating point to string conversion\",\n    \"license\": \"Apache-2.0 OR BSL-1.0\",\n    \"license_file\": null,\n    \"name\": \"ryu\",\n    \"repository\": \"https://github.com/dtolnay/ryu\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Provides functions to read and write safetensors which aim to be safer than their PyTorch counterpart. The format is 8 bytes which is an unsized int, being the size of a JSON header, the JSON header refers the `dtype` the `shape` and `data_offsets` which are the offsets for the values in the rest of the file.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"safetensors\",\n    \"repository\": \"https://github.com/huggingface/safetensors\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"A simple crate for determining whether two file paths point to the same file.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"same-file\",\n    \"repository\": \"https://github.com/BurntSushi/same-file\"\n  },\n  {\n    \"authors\": \"Jacob Brown <kardeiz@gmail.com>\",\n    \"description\": \"A simple filename sanitizer, based on Node's sanitize-filename\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sanitize-filename\",\n    \"repository\": \"https://github.com/kardeiz/sanitize-filename\"\n  },\n  {\n    \"authors\": \"Jacob Brown <kardeiz@gmail.com>\",\n    \"description\": \"A simple filename sanitizer, based on Node's sanitize-filename\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sanitize-filename\",\n    \"repository\": \"https://github.com/kardeiz/sanitize-filename\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>|Steffen Butzer <steffen.butzer@outlook.com>\",\n    \"description\": \"Schannel bindings for rust, allowing SSL/TLS (e.g. https) without openssl\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"schannel\",\n    \"repository\": \"https://github.com/steffengy/schannel-rs\"\n  },\n  {\n    \"authors\": \"bluss\",\n    \"description\": \"A RAII scope guard that will run a given closure when it goes out of scope, even if the code between panics (assuming unwinding panic).  Defines the macros `defer!`, `defer_on_unwind!`, `defer_on_success!` as shorthands for guards with one of the implemented strategies.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"scopeguard\",\n    \"repository\": \"https://github.com/bluss/scopeguard\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>|Kornel <kornel@geekhood.net>\",\n    \"description\": \"Security.framework bindings for macOS and iOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"security-framework\",\n    \"repository\": \"https://github.com/kornelski/rust-security-framework\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>|Kornel <kornel@geekhood.net>\",\n    \"description\": \"Security.framework bindings for macOS and iOS\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"security-framework\",\n    \"repository\": \"https://github.com/kornelski/rust-security-framework\"\n  },\n  {\n    \"authors\": \"Steven Fackler <sfackler@gmail.com>|Kornel <kornel@geekhood.net>\",\n    \"description\": \"Apple `Security.framework` low-level FFI bindings\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"security-framework-sys\",\n    \"repository\": \"https://github.com/kornelski/rust-security-framework\"\n  },\n  {\n    \"authors\": \"Lukas Bergdoll <lukas.bergdoll@gmail.com>\",\n    \"description\": \"Safe-to-use proc-macro-free self-referential structs in stable Rust.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"self_cell\",\n    \"repository\": \"https://github.com/Voultapher/self_cell\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Parser and evaluator for Cargo's flavor of Semantic Versioning\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"semver\",\n    \"repository\": \"https://github.com/dtolnay/semver\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Macro to repeat sequentially indexed copies of a fragment of code.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"seq-macro\",\n    \"repository\": \"https://github.com/dtolnay/seq-macro\"\n  },\n  {\n    \"authors\": \"Erick Tryzelaar <erick.tryzelaar@gmail.com>|David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"A generic serialization/deserialization framework\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde\",\n    \"repository\": \"https://github.com/serde-rs/serde\"\n  },\n  {\n    \"authors\": \"Victor Polevoy <maintainer@vpolevoy.com>\",\n    \"description\": \"A serde crate's auxiliary library\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"serde-aux\",\n    \"repository\": \"https://github.com/iddm/serde-aux\"\n  },\n  {\n    \"authors\": \"arcnmx\",\n    \"description\": \"Serialization value trees\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"serde-value\",\n    \"repository\": \"https://github.com/arcnmx/serde-value\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Optimized handling of `&[u8]` and `Vec<u8>` for Serde\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_bytes\",\n    \"repository\": \"https://github.com/serde-rs/bytes\"\n  },\n  {\n    \"authors\": \"Erick Tryzelaar <erick.tryzelaar@gmail.com>|David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Serde traits only, with no support for derive -- use the `serde` crate instead\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_core\",\n    \"repository\": \"https://github.com/serde-rs/serde\"\n  },\n  {\n    \"authors\": \"Erick Tryzelaar <erick.tryzelaar@gmail.com>|David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Macros 1.1 implementation of #[derive(Serialize, Deserialize)]\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_derive\",\n    \"repository\": \"https://github.com/serde-rs/serde\"\n  },\n  {\n    \"authors\": \"Erick Tryzelaar <erick.tryzelaar@gmail.com>|David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"A JSON serialization file format\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_json\",\n    \"repository\": \"https://github.com/serde-rs/json\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Path to the element that failed to deserialize\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_path_to_error\",\n    \"repository\": \"https://github.com/dtolnay/path-to-error\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Derive Serialize and Deserialize that delegates to the underlying repr of a C-like enum.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_repr\",\n    \"repository\": \"https://github.com/dtolnay/serde-repr\"\n  },\n  {\n    \"authors\": \"Jacob Brown <kardeiz@gmail.com>\",\n    \"description\": \"De/serialize structs with named fields as array of values\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_tuple\",\n    \"repository\": \"https://github.com/kardeiz/serde_tuple\"\n  },\n  {\n    \"authors\": \"Jacob Brown <kardeiz@gmail.com>\",\n    \"description\": \"Internal proc-macro crate for serde_tuple\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_tuple_macros\",\n    \"repository\": \"https://github.com/kardeiz/serde_tuple\"\n  },\n  {\n    \"authors\": \"Anthony Ramine <n.oxyde@gmail.com>\",\n    \"description\": \"`x-www-form-urlencoded` meets Serde\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"serde_urlencoded\",\n    \"repository\": \"https://github.com/nox/serde_urlencoded\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"SHA-1 hash function\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"sha1\",\n    \"repository\": \"https://github.com/RustCrypto/hashes\"\n  },\n  {\n    \"authors\": \"RustCrypto Developers\",\n    \"description\": \"Pure Rust implementation of the SHA-2 hash function family including SHA-224, SHA-256, SHA-384, and SHA-512.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"sha2\",\n    \"repository\": \"https://github.com/RustCrypto/hashes\"\n  },\n  {\n    \"authors\": \"Eliza Weisman <eliza@buoyant.io>\",\n    \"description\": \"A lock-free concurrent slab.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sharded-slab\",\n    \"repository\": \"https://github.com/hawkw/sharded-slab\"\n  },\n  {\n    \"authors\": \"comex <comexk@gmail.com>|Fenhl <fenhl@fenhl.net>|Adrian Taylor <adetaylor@chromium.org>|Alex Touchet <alextouchet@outlook.com>|Daniel Parks <dp+git@oxidized.org>|Garrett Berg <googberg@gmail.com>\",\n    \"description\": \"Split a string into shell words, like Python's shlex.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"shlex\",\n    \"repository\": \"https://github.com/comex/rust-shlex\"\n  },\n  {\n    \"authors\": \"Michal 'vorner' Vaner <vorner@vorner.cz>|Masaki Hara <ackie.h.gmai@gmail.com>\",\n    \"description\": \"Backend crate for signal-hook\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"signal-hook-registry\",\n    \"repository\": \"https://github.com/vorner/signal-hook\"\n  },\n  {\n    \"authors\": \"Marvin Countryman <me@maar.vin>\",\n    \"description\": \"A SIMD-accelerated Adler-32 hash algorithm implementation.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"simd-adler32\",\n    \"repository\": \"https://github.com/mcountryman/simd-adler32\"\n  },\n  {\n    \"authors\": \"Frank Denis <github@pureftpd.org>\",\n    \"description\": \"SipHash-2-4, SipHash-1-3 and 128-bit variants in pure Rust\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"siphasher\",\n    \"repository\": \"https://github.com/jedisct1/rust-siphash\"\n  },\n  {\n    \"authors\": \"Carl Lerche <me@carllerche.com>\",\n    \"description\": \"Pre-allocated storage for a uniform data type\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"slab\",\n    \"repository\": \"https://github.com/tokio-rs/slab\"\n  },\n  {\n    \"authors\": \"Orson Peters <orsonpeters@gmail.com>\",\n    \"description\": \"Slotmap data structure\",\n    \"license\": \"Zlib\",\n    \"license_file\": null,\n    \"name\": \"slotmap\",\n    \"repository\": \"https://github.com/orlp/slotmap\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"'Small vector' optimization: store up to a small number of items on the stack\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"smallvec\",\n    \"repository\": \"https://github.com/servo/rust-smallvec\"\n  },\n  {\n    \"authors\": \"Jake Goulding <jake.goulding@gmail.com>\",\n    \"description\": \"An ergonomic error handling library\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"snafu\",\n    \"repository\": \"https://github.com/shepmaster/snafu\"\n  },\n  {\n    \"authors\": \"Jake Goulding <jake.goulding@gmail.com>\",\n    \"description\": \"An ergonomic error handling library\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"snafu-derive\",\n    \"repository\": \"https://github.com/shepmaster/snafu\"\n  },\n  {\n    \"authors\": \"Steven Allen <steven@stebalien.com>\",\n    \"description\": \"A module for generating guaranteed process unique IDs.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"snowflake\",\n    \"repository\": \"https://github.com/Stebalien/snowflake\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>|Thomas de Zeeuw <thomasdezeeuw@gmail.com>\",\n    \"description\": \"Utilities for handling networking sockets with a maximal amount of configuration possible intended.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"socket2\",\n    \"repository\": \"https://github.com/rust-lang/socket2\"\n  },\n  {\n    \"authors\": \"Mathijs van de Nes <git@mathijs.vd-nes.nl>|John Ericson <git@JohnEricson.me>|Joshua Barretto <joshua.s.barretto@gmail.com>\",\n    \"description\": \"Spin-based synchronization primitives\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"spin\",\n    \"repository\": \"https://github.com/mvdnes/spin-rs.git\"\n  },\n  {\n    \"authors\": \"Mathijs van de Nes <git@mathijs.vd-nes.nl>|John Ericson <git@JohnEricson.me>|Joshua Barretto <joshua.s.barretto@gmail.com>\",\n    \"description\": \"Spin-based synchronization primitives\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"spin\",\n    \"repository\": \"https://github.com/mvdnes/spin-rs.git\"\n  },\n  {\n    \"authors\": \"Lei Zhang <antiagainst@gmail.com>\",\n    \"description\": \"Rust definition of SPIR-V structs and enums\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"spirv\",\n    \"repository\": \"https://github.com/gfx-rs/rspirv\"\n  },\n  {\n    \"authors\": \"Robert Grosse <n210241048576@gmail.com>\",\n    \"description\": \"An unsafe marker trait for types like Box and Rc that dereference to a stable address even when moved, and hence can be used with libraries such as owning_ref and rental.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"stable_deref_trait\",\n    \"repository\": \"https://github.com/storyyeller/stable_deref_trait\"\n  },\n  {\n    \"authors\": \"Nikolai Vazquez\",\n    \"description\": \"Compile-time assertions to ensure that invariants are met.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"static_assertions\",\n    \"repository\": \"https://github.com/nvzqz/static-assertions-rs\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"A string interning library for Rust, developed as part of the Servo project.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"string_cache\",\n    \"repository\": \"https://github.com/servo/string-cache\"\n  },\n  {\n    \"authors\": \"The Servo Project Developers\",\n    \"description\": \"A codegen library for string-cache, developed as part of the Servo project.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"string_cache_codegen\",\n    \"repository\": \"https://github.com/servo/string-cache\"\n  },\n  {\n    \"authors\": \"Danny Guo <danny@dannyguo.com>|maxbachmann <oss@maxbachmann.de>\",\n    \"description\": \"Implementations of string similarity metrics. Includes Hamming, Levenshtein, OSA, Damerau-Levenshtein, Jaro, Jaro-Winkler, and Sørensen-Dice.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"strsim\",\n    \"repository\": \"https://github.com/rapidfuzz/strsim-rs\"\n  },\n  {\n    \"authors\": \"Peter Glotfelty <peter.glotfelty@microsoft.com>\",\n    \"description\": \"Helpful macros for working with enums and strings\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"strum\",\n    \"repository\": \"https://github.com/Peternator7/strum\"\n  },\n  {\n    \"authors\": \"Peter Glotfelty <peter.glotfelty@microsoft.com>\",\n    \"description\": \"Helpful macros for working with enums and strings\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"strum\",\n    \"repository\": \"https://github.com/Peternator7/strum\"\n  },\n  {\n    \"authors\": \"Peter Glotfelty <peter.glotfelty@microsoft.com>\",\n    \"description\": \"Helpful macros for working with enums and strings\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"strum_macros\",\n    \"repository\": \"https://github.com/Peternator7/strum\"\n  },\n  {\n    \"authors\": \"Peter Glotfelty <peter.glotfelty@microsoft.com>\",\n    \"description\": \"Helpful macros for working with enums and strings\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"strum_macros\",\n    \"repository\": \"https://github.com/Peternator7/strum\"\n  },\n  {\n    \"authors\": \"Isis Lovecruft <isis@patternsinthevoid.net>|Henry de Valence <hdevalence@hdevalence.ca>\",\n    \"description\": \"Pure-Rust traits and utilities for constant-time cryptographic implementations.\",\n    \"license\": \"BSD-3-Clause\",\n    \"license_file\": null,\n    \"name\": \"subtle\",\n    \"repository\": \"https://github.com/dalek-cryptography/subtle\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Parser for Rust source code\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"syn\",\n    \"repository\": \"https://github.com/dtolnay/syn\"\n  },\n  {\n    \"authors\": \"Actyx AG <developer@actyx.io>\",\n    \"description\": \"A tool for enlisting the compiler's help in proving the absence of concurrency\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"sync_wrapper\",\n    \"repository\": \"https://github.com/Actyx/sync_wrapper\"\n  },\n  {\n    \"authors\": \"Nika Layzell <nika@thelayzells.com>\",\n    \"description\": \"Helper methods and macros for custom derives\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"synstructure\",\n    \"repository\": \"https://github.com/mystor/synstructure\"\n  },\n  {\n    \"authors\": \"Johannes Lundberg <johalun0@gmail.com>|Ivan Temchenko <ivan.temchenko@yandex.ua>|Fabian Freyer <fabian.freyer@physik.tu-berlin.de>\",\n    \"description\": \"Simplified interface to libc::sysctl\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sysctl\",\n    \"repository\": \"https://github.com/johalun/sysctl-rs\"\n  },\n  {\n    \"authors\": \"Johannes Lundberg <johalun0@gmail.com>|Ivan Temchenko <ivan.temchenko@yandex.ua>|Fabian Freyer <fabian.freyer@physik.tu-berlin.de>\",\n    \"description\": \"Simplified interface to libc::sysctl\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sysctl\",\n    \"repository\": \"https://github.com/johalun/sysctl-rs\"\n  },\n  {\n    \"authors\": \"Guillaume Gomez <guillaume1.gomez@gmail.com>\",\n    \"description\": \"Library to get system information such as processes, CPUs, disks, components and networks\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"sysinfo\",\n    \"repository\": \"https://github.com/GuillaumeGomez/sysinfo\"\n  },\n  {\n    \"authors\": \"Val Packett <val@packett.cool>\",\n    \"description\": \"Get system information/statistics in a cross-platform way\",\n    \"license\": \"Unlicense\",\n    \"license_file\": null,\n    \"name\": \"systemstat\",\n    \"repository\": \"https://github.com/valpackett/systemstat\"\n  },\n  {\n    \"authors\": \"Steven Allen <steven@stebalien.com>|The Rust Project Developers|Ashley Mannix <ashleymannix@live.com.au>|Jason White <me@jasonwhite.io>\",\n    \"description\": \"A library for managing temporary files and directories.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"tempfile\",\n    \"repository\": \"https://github.com/Stebalien/tempfile\"\n  },\n  {\n    \"authors\": \"Keegan McAllister <mcallister.keegan@gmail.com>|Simon Sapin <simon.sapin@exyr.org>|Chris Morgan <me@chrismorgan.info>\",\n    \"description\": \"Compact buffer/string type for zero-copy parsing\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"tendril\",\n    \"repository\": \"https://github.com/servo/tendril\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"A simple cross platform library for writing colored text to a terminal.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"termcolor\",\n    \"repository\": \"https://github.com/BurntSushi/termcolor\"\n  },\n  {\n    \"authors\": \"Bernardo Araujo <bernardo.amc@gmail.com>\",\n    \"description\": \"A flexible text template engine\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"text_placeholder\",\n    \"repository\": \"https://github.com/bernardoamc/text-placeholder\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"derive(Error)\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thiserror\",\n    \"repository\": \"https://github.com/dtolnay/thiserror\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"derive(Error)\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thiserror\",\n    \"repository\": \"https://github.com/dtolnay/thiserror\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Implementation detail of the `thiserror` crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thiserror-impl\",\n    \"repository\": \"https://github.com/dtolnay/thiserror\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Implementation detail of the `thiserror` crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thiserror-impl\",\n    \"repository\": \"https://github.com/dtolnay/thiserror\"\n  },\n  {\n    \"authors\": \"bluss <>\",\n    \"description\": \"A tree-structured thread pool for splitting jobs hierarchically on worker threads.  The tree structure means that there is no contention between workers when delivering jobs.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thread-tree\",\n    \"repository\": \"https://github.com/bluss/thread-tree\"\n  },\n  {\n    \"authors\": \"Amanieu d'Antras <amanieu@gmail.com>\",\n    \"description\": \"Per-object thread-local storage\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"thread_local\",\n    \"repository\": \"https://github.com/Amanieu/thread_local-rs\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <open-source@jhpratt.dev>|Time contributors\",\n    \"description\": \"Date and time library. Fully interoperable with the standard library. Mostly compatible with #![no_std].\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"time\",\n    \"repository\": \"https://github.com/time-rs/time\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <open-source@jhpratt.dev>|Time contributors\",\n    \"description\": \"This crate is an implementation detail and should not be relied upon directly.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"time-core\",\n    \"repository\": \"https://github.com/time-rs/time\"\n  },\n  {\n    \"authors\": \"Jacob Pratt <open-source@jhpratt.dev>|Time contributors\",\n    \"description\": \"Procedural macros for the time crate.     This crate is an implementation detail and should not be relied upon directly.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"time-macros\",\n    \"repository\": \"https://github.com/time-rs/time\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"A small ASCII-only bounded length string representation.\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"tinystr\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Lokathor <zefria@gmail.com>\",\n    \"description\": \"`tinyvec` provides 100% safe vec-like data structures.\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"tinyvec\",\n    \"repository\": \"https://github.com/Lokathor/tinyvec\"\n  },\n  {\n    \"authors\": \"Soveu <marx.tomasz@gmail.com>\",\n    \"description\": \"Some macros for tiny containers\",\n    \"license\": \"Apache-2.0 OR MIT OR Zlib\",\n    \"license_file\": null,\n    \"name\": \"tinyvec_macros\",\n    \"repository\": \"https://github.com/Soveu/tinyvec_macros\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"An event-driven, non-blocking I/O platform for writing asynchronous I/O backed applications.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio\",\n    \"repository\": \"https://github.com/tokio-rs/tokio\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Tokio's proc macros.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio-macros\",\n    \"repository\": \"https://github.com/tokio-rs/tokio\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"An implementation of TLS/SSL streams for Tokio using native-tls giving an implementation of TLS for nonblocking I/O streams.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio-native-tls\",\n    \"repository\": \"https://github.com/tokio-rs/tls\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Asynchronous TLS/SSL streams for Tokio using Rustls.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio-rustls\",\n    \"repository\": \"https://github.com/rustls/tokio-rustls\"\n  },\n  {\n    \"authors\": \"Daniel Abramov <dabramov@snapview.de>|Alexey Galakhov <agalakhov@snapview.de>\",\n    \"description\": \"Tokio binding for Tungstenite, the Lightweight stream-based WebSocket implementation\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio-tungstenite\",\n    \"repository\": \"https://github.com/snapview/tokio-tungstenite\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Additional utilities for working with Tokio.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tokio-util\",\n    \"repository\": \"https://github.com/tokio-rs/tokio\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A TOML-compatible datetime type\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"toml_datetime\",\n    \"repository\": \"https://github.com/toml-rs/toml\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Yet another format-preserving TOML parser.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"toml_edit\",\n    \"repository\": \"https://github.com/toml-rs/toml\"\n  },\n  {\n    \"authors\": \"Tower Maintainers <team@tower-rs.com>\",\n    \"description\": \"Tower is a library of modular and reusable components for building robust clients and servers.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tower\",\n    \"repository\": \"https://github.com/tower-rs/tower\"\n  },\n  {\n    \"authors\": \"Tower Maintainers <team@tower-rs.com>\",\n    \"description\": \"Tower middleware and utilities for HTTP clients and servers\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tower-http\",\n    \"repository\": \"https://github.com/tower-rs/tower-http\"\n  },\n  {\n    \"authors\": \"Tower Maintainers <team@tower-rs.com>\",\n    \"description\": \"Decorates a `Service` to allow easy composition between `Service`s.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tower-layer\",\n    \"repository\": \"https://github.com/tower-rs/tower\"\n  },\n  {\n    \"authors\": \"Tower Maintainers <team@tower-rs.com>\",\n    \"description\": \"Trait representing an asynchronous, request / response based, client or server.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tower-service\",\n    \"repository\": \"https://github.com/tower-rs/tower\"\n  },\n  {\n    \"authors\": \"Eliza Weisman <eliza@buoyant.io>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Application-level tracing for Rust.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Zeki Sherif <zekshi@amazon.com>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Provides utilities for file appenders and making non-blocking writers.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing-appender\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>|Eliza Weisman <eliza@buoyant.io>|David Barsky <dbarsky@amazon.com>\",\n    \"description\": \"Procedural macro attributes for automatically instrumenting functions.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing-attributes\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Core primitives for application-level tracing.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing-core\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Provides compatibility between `tracing` and the `log` crate.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing-log\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Eliza Weisman <eliza@buoyant.io>|David Barsky <me@davidbarsky.com>|Tokio Contributors <team@tokio.rs>\",\n    \"description\": \"Utilities for implementing and composing `tracing` subscribers.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"tracing-subscriber\",\n    \"repository\": \"https://github.com/tokio-rs/tracing\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"A lightweight atomic lock.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"try-lock\",\n    \"repository\": \"https://github.com/seanmonstar/try-lock\"\n  },\n  {\n    \"authors\": \"Alexey Galakhov|Daniel Abramov\",\n    \"description\": \"Lightweight stream-based WebSocket implementation\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"tungstenite\",\n    \"repository\": \"https://github.com/snapview/tungstenite-rs\"\n  },\n  {\n    \"authors\": \"Jacob Brown <kardeiz@gmail.com>\",\n    \"description\": \"Provides a typemap container with FxHashMap\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"type-map\",\n    \"repository\": \"https://github.com/kardeiz/type-map\"\n  },\n  {\n    \"authors\": \"Paho Lurie-Gregg <paho@paholg.com>|Andre Bogus <bogusandre@gmail.com>\",\n    \"description\": \"Typenum is a Rust library for type-level numbers evaluated at     compile time. It currently supports bits, unsigned integers, and signed     integers. It also provides a type-level array of type-level numbers, but its     implementation is incomplete.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"typenum\",\n    \"repository\": \"https://github.com/paholg/typenum\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Micro compiler for tensor operations.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"ug\",\n    \"repository\": \"https://github.com/LaurentMazare/ug\"\n  },\n  {\n    \"authors\": \"The UNIC Project Developers\",\n    \"description\": \"UNIC — Unicode Character Tools — Character Property taxonomy, contracts and build macros\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-char-property\",\n    \"repository\": \"https://github.com/open-i18n/rust-unic/\"\n  },\n  {\n    \"authors\": \"The UNIC Project Developers\",\n    \"description\": \"UNIC — Unicode Character Tools — Character Range and Iteration\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-char-range\",\n    \"repository\": \"https://github.com/open-i18n/rust-unic/\"\n  },\n  {\n    \"authors\": \"The UNIC Project Developers\",\n    \"description\": \"UNIC — Common Utilities\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-common\",\n    \"repository\": \"https://github.com/open-i18n/rust-unic/\"\n  },\n  {\n    \"authors\": \"Zibi Braniecki <gandalf@mozilla.com>\",\n    \"description\": \"API for managing Unicode Language Identifiers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-langid\",\n    \"repository\": \"https://github.com/zbraniecki/unic-locale\"\n  },\n  {\n    \"authors\": \"Zibi Braniecki <gandalf@mozilla.com>\",\n    \"description\": \"API for managing Unicode Language Identifiers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-langid-impl\",\n    \"repository\": \"https://github.com/zbraniecki/unic-locale\"\n  },\n  {\n    \"authors\": \"Zibi Braniecki <gandalf@mozilla.com>\",\n    \"description\": \"API for managing Unicode Language Identifiers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-langid-macros\",\n    \"repository\": \"https://github.com/zbraniecki/unic-locale\"\n  },\n  {\n    \"authors\": \"Zibi Braniecki <gandalf@mozilla.com>\",\n    \"description\": \"API for managing Unicode Language Identifiers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-langid-macros-impl\",\n    \"repository\": \"https://github.com/zbraniecki/unic-locale\"\n  },\n  {\n    \"authors\": \"The UNIC Project Developers\",\n    \"description\": \"UNIC — Unicode Character Database — General Category\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-ucd-category\",\n    \"repository\": \"https://github.com/open-i18n/rust-unic/\"\n  },\n  {\n    \"authors\": \"The UNIC Project Developers\",\n    \"description\": \"UNIC — Unicode Character Database — Version\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unic-ucd-version\",\n    \"repository\": \"https://github.com/open-i18n/rust-unic/\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"A case-insensitive wrapper around strings.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unicase\",\n    \"repository\": \"https://github.com/seanmonstar/unicase\"\n  },\n  {\n    \"authors\": \"David Tolnay <dtolnay@gmail.com>\",\n    \"description\": \"Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31\",\n    \"license\": \"(Apache-2.0 OR MIT) AND Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"unicode-ident\",\n    \"repository\": \"https://github.com/dtolnay/unicode-ident\"\n  },\n  {\n    \"authors\": \"kwantam <kwantam@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"This crate provides functions for normalization of Unicode strings, including Canonical and Compatible Decomposition and Recomposition, as described in Unicode Standard Annex #15.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unicode-normalization\",\n    \"repository\": \"https://github.com/unicode-rs/unicode-normalization\"\n  },\n  {\n    \"authors\": \"kwantam <kwantam@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"This crate provides Grapheme Cluster, Word and Sentence boundaries according to Unicode Standard Annex #29 rules.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unicode-segmentation\",\n    \"repository\": \"https://github.com/unicode-rs/unicode-segmentation\"\n  },\n  {\n    \"authors\": \"kwantam <kwantam@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Determine displayed width of `char` and `str` types according to Unicode Standard Annex #11 rules.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unicode-width\",\n    \"repository\": \"https://github.com/unicode-rs/unicode-width\"\n  },\n  {\n    \"authors\": \"erick.tryzelaar <erick.tryzelaar@gmail.com>|kwantam <kwantam@gmail.com>|Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unicode-xid\",\n    \"repository\": \"https://github.com/unicode-rs/unicode-xid\"\n  },\n  {\n    \"authors\": \"Brian Smith <brian@briansmith.org>\",\n    \"description\": \"Safe, fast, zero-panic, zero-crashing, zero-allocation parsing of untrusted inputs in Rust.\",\n    \"license\": \"ISC\",\n    \"license_file\": null,\n    \"name\": \"untrusted\",\n    \"repository\": \"https://github.com/briansmith/untrusted\"\n  },\n  {\n    \"authors\": \"Victor Koenders <bincode@trang.ar>\",\n    \"description\": \"Explicitly types your generics\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"unty\",\n    \"repository\": \"https://github.com/bincode-org/unty\"\n  },\n  {\n    \"authors\": \"The rust-url developers\",\n    \"description\": \"URL library for Rust, based on the WHATWG URL Standard\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"url\",\n    \"repository\": \"https://github.com/servo/rust-url\"\n  },\n  {\n    \"authors\": \"Simon Sapin <simon.sapin@exyr.org>\",\n    \"description\": \"Incremental, zero-copy UTF-8 decoding with error handling\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"utf-8\",\n    \"repository\": \"https://github.com/SimonSapin/rust-utf8\"\n  },\n  {\n    \"authors\": \"Henri Sivonen <hsivonen@hsivonen.fi>\",\n    \"description\": \"Iterator by char over potentially-invalid UTF-8 in &[u8]\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"utf8_iter\",\n    \"repository\": \"https://github.com/hsivonen/utf8_iter\"\n  },\n  {\n    \"authors\": \"Ashley Mannix<ashleymannix@live.com.au>|Dylan DPC<dylan.dpc@gmail.com>|Hunar Roop Kahlon<hunar.roop@gmail.com>\",\n    \"description\": \"A library to generate and parse UUIDs.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"uuid\",\n    \"repository\": \"https://github.com/uuid-rs/uuid\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Object-safe value inspection, used to pass un-typed structured data across trait-object boundaries.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"valuable\",\n    \"repository\": \"https://github.com/tokio-rs/valuable\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Implement things as if rust had variadics\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"variadics_please\",\n    \"repository\": \"https://github.com/bevyengine/variadics_please\"\n  },\n  {\n    \"authors\": \"Jim McGrath <jimmc2@gmail.com>\",\n    \"description\": \"A library to find native dependencies in a vcpkg tree at build time in order to be used in Cargo build scripts.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"vcpkg\",\n    \"repository\": \"https://github.com/mcgoo/vcpkg-rs\"\n  },\n  {\n    \"authors\": \"Sergio Benitez <sb@sergio.bz>\",\n    \"description\": \"Tiny crate to check the version of the installed/running rustc.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"version_check\",\n    \"repository\": \"https://github.com/SergioBenitez/version_check\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"Recursively walk a directory.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"walkdir\",\n    \"repository\": \"https://github.com/BurntSushi/walkdir\"\n  },\n  {\n    \"authors\": \"Sean McArthur <sean@seanmonstar.com>\",\n    \"description\": \"Detect when another Future wants a result.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"want\",\n    \"repository\": \"https://github.com/seanmonstar/want\"\n  },\n  {\n    \"authors\": \"The Cranelift Project Developers\",\n    \"description\": \"Experimental WASI API bindings for Rust\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasi\",\n    \"repository\": \"https://github.com/bytecodealliance/wasi\"\n  },\n  {\n    \"authors\": \"The Cranelift Project Developers\",\n    \"description\": \"WASI API bindings for Rust\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasi\",\n    \"repository\": \"https://github.com/bytecodealliance/wasi-rs\"\n  },\n  {\n    \"authors\": \"The Cranelift Project Developers|john-sharratt\",\n    \"description\": \"WASIX API bindings for Rust\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasix\",\n    \"repository\": \"https://github.com/wasix-org/wasix-abi-rust\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Easy support for interacting between JS and Rust.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Backend code generation of the wasm-bindgen tool\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen-backend\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/backend\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Bridging the gap between Rust Futures and JavaScript Promises\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen-futures\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/futures\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Definition of the `#[wasm_bindgen]` attribute, an internal dependency\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen-macro\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/macro\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"The part of the implementation of the `#[wasm_bindgen]` attribute that is not in the shared backend crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen-macro-support\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/macro-support\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Shared support between wasm-bindgen and wasm-bindgen cli, an internal dependency.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-bindgen-shared\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/shared\"\n  },\n  {\n    \"authors\": \"Mattias Buelens <mattias@buelens.com>\",\n    \"description\": \"Bridging between web streams and Rust streams using WebAssembly\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wasm-streams\",\n    \"repository\": \"https://github.com/MattiasBuelens/wasm-streams/\"\n  },\n  {\n    \"authors\": \"The wasm-bindgen Developers\",\n    \"description\": \"Bindings for all Web APIs, a procedurally generated crate from WebIDL\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"web-sys\",\n    \"repository\": \"https://github.com/rustwasm/wasm-bindgen/tree/master/crates/web-sys\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Drop-in replacement for std::time for Wasm in browsers\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"web-time\",\n    \"repository\": \"https://github.com/daxpedda/web-time\"\n  },\n  {\n    \"authors\": \"The html5ever Project Developers\",\n    \"description\": \"Atoms for xml5ever and html5ever\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"web_atoms\",\n    \"repository\": \"https://github.com/servo/html5ever\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Mozilla's CA root certificates for use with webpki\",\n    \"license\": \"CDLA-Permissive-2.0\",\n    \"license_file\": null,\n    \"name\": \"webpki-roots\",\n    \"repository\": \"https://github.com/rustls/webpki-roots\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Cross-platform, safe, pure-rust graphics API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Core implementation logic of wgpu, the cross-platform, safe, pure-rust graphics API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-core\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Feature unification helper crate for Apple platforms\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-core-deps-apple\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Feature unification helper crate for the Emscripten platform\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-core-deps-emscripten\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Feature unification helper crate for the Windows/Linux/Android platforms\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-core-deps-windows-linux-android\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Hardware abstraction layer for wgpu, the cross-platform, safe, pure-rust graphics API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-hal\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"gfx-rs developers\",\n    \"description\": \"Common types and utilities for wgpu, the cross-platform, safe, pure-rust graphics API\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wgpu-types\",\n    \"repository\": \"https://github.com/gfx-rs/wgpu\"\n  },\n  {\n    \"authors\": \"Peter Atashian <retep998@gmail.com>\",\n    \"description\": \"Raw FFI bindings for all of Windows API.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"winapi\",\n    \"repository\": \"https://github.com/retep998/winapi-rs\"\n  },\n  {\n    \"authors\": \"Peter Atashian <retep998@gmail.com>\",\n    \"description\": \"Import libraries for the i686-pc-windows-gnu target. Please don't use this crate directly, depend on winapi instead.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"winapi-i686-pc-windows-gnu\",\n    \"repository\": \"https://github.com/retep998/winapi-rs\"\n  },\n  {\n    \"authors\": \"Andrew Gallant <jamslam@gmail.com>\",\n    \"description\": \"A dumping ground for high level safe wrappers over windows-sys.\",\n    \"license\": \"MIT OR Unlicense\",\n    \"license_file\": null,\n    \"name\": \"winapi-util\",\n    \"repository\": \"https://github.com/BurntSushi/winapi-util\"\n  },\n  {\n    \"authors\": \"Peter Atashian <retep998@gmail.com>\",\n    \"description\": \"Import libraries for the x86_64-pc-windows-gnu target. Please don't use this crate directly, depend on winapi instead.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"winapi-x86_64-pc-windows-gnu\",\n    \"repository\": \"https://github.com/retep998/winapi-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Windows collection types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-collections\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-core\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-core\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Core type support for COM and Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-core\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Windows async types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-future\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The implement macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-implement\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The implement macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-implement\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The implement macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-implement\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The interface macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-interface\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The interface macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-interface\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"The interface macro for the windows crate\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-interface\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Linking for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-link\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"Windows numeric types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-numerics\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Windows error handling\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-result\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Windows error handling\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-result\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Windows error handling\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-result\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-strings\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Windows string types\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-strings\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-sys\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-sys\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-sys\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Rust for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-sys\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import libs for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-targets\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import libs for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-targets\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import libs for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-targets\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Windows threading\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows-threading\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_aarch64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_i686_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnu\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_gnullvm\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": \"Microsoft\",\n    \"description\": \"Import lib for Windows\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"windows_x86_64_msvc\",\n    \"repository\": \"https://github.com/microsoft/windows-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A byte-oriented, zero-copy, parser combinators library\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"winnow\",\n    \"repository\": \"https://github.com/winnow-rs/winnow\"\n  },\n  {\n    \"authors\": \"Luca Palmieri <rust@lpalmieri.com>\",\n    \"description\": \"HTTP mocking to test Rust applications.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wiremock\",\n    \"repository\": \"https://github.com/LukeMathWalker/wiremock-rs\"\n  },\n  {\n    \"authors\": \"Alex Crichton <alex@alexcrichton.com>\",\n    \"description\": \"Rust bindings generator and runtime support for WIT and the component model. Used when compiling Rust programs to the component model.\",\n    \"license\": \"Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wit-bindgen\",\n    \"repository\": \"https://github.com/bytecodealliance/wit-bindgen\"\n  },\n  {\n    \"authors\": \"Cldfire\",\n    \"description\": \"Derive macro for nvml-wrapper, not for general use\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"wrapcenum-derive\",\n    \"repository\": \"https://github.com/Cldfire/wrapcenum-derive\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"A more efficient alternative to fmt::Display\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"writeable\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Vladimir Matveev <vmatveev@citrine.cc>\",\n    \"description\": \"An XML library in pure Rust\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"xml-rs\",\n    \"repository\": \"https://github.com/kornelski/xml-rs\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Abstraction allowing borrowed data to be carried along with the backing data it borrows from\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"yoke\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Abstraction allowing borrowed data to be carried along with the backing data it borrows from\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"yoke\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Custom derive for the yoke crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"yoke-derive\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Custom derive for the yoke crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"yoke-derive\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Joshua Liebow-Feeser <joshlf@google.com>|Jack Wrenn <jswrenn@amazon.com>\",\n    \"description\": \"Zerocopy makes zero-cost memory manipulation effortless. We write \\\"unsafe\\\" so you don't have to.\",\n    \"license\": \"Apache-2.0 OR BSD-2-Clause OR MIT\",\n    \"license_file\": null,\n    \"name\": \"zerocopy\",\n    \"repository\": \"https://github.com/google/zerocopy\"\n  },\n  {\n    \"authors\": \"Joshua Liebow-Feeser <joshlf@google.com>|Jack Wrenn <jswrenn@amazon.com>\",\n    \"description\": \"Custom derive for traits from the zerocopy crate\",\n    \"license\": \"Apache-2.0 OR BSD-2-Clause OR MIT\",\n    \"license_file\": null,\n    \"name\": \"zerocopy-derive\",\n    \"repository\": \"https://github.com/google/zerocopy\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"ZeroFrom trait for constructing\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"zerofrom\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Custom derive for the zerofrom crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"zerofrom-derive\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The RustCrypto Project Developers\",\n    \"description\": \"Securely clear secrets from memory with a simple trait built on stable Rust primitives which guarantee memory is zeroed using an operation will not be 'optimized away' by the compiler. Uses a portable pure Rust implementation that works everywhere, even WASM!\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"zeroize\",\n    \"repository\": \"https://github.com/RustCrypto/utils/tree/master/zeroize\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"A data structure that efficiently maps strings to integers\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"zerotrie\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"The ICU4X Project Developers\",\n    \"description\": \"Zero-copy vector backed by a byte array\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"zerovec\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Manish Goregaokar <manishsmail@gmail.com>\",\n    \"description\": \"Custom derive for the zerovec crate\",\n    \"license\": \"Unicode-3.0\",\n    \"license_file\": null,\n    \"name\": \"zerovec-derive\",\n    \"repository\": \"https://github.com/unicode-org/icu4x\"\n  },\n  {\n    \"authors\": \"Mathijs van de Nes <git@mathijs.vd-nes.nl>|Marli Frost <marli@frost.red>|Ryan Levick <ryan.levick@gmail.com>|Chris Hennick <hennickc@amazon.com>\",\n    \"description\": \"Library to support the reading and writing of zip files.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"zip\",\n    \"repository\": \"https://github.com/zip-rs/zip2.git\"\n  },\n  {\n    \"authors\": \"Mathijs van de Nes <git@mathijs.vd-nes.nl>|Marli Frost <marli@frost.red>|Ryan Levick <ryan.levick@gmail.com>|Chris Hennick <hennickc@amazon.com>\",\n    \"description\": \"Library to support the reading and writing of zip files.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"zip\",\n    \"repository\": \"https://github.com/zip-rs/zip2.git\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A memory-safe zlib implementation written in rust\",\n    \"license\": \"Zlib\",\n    \"license_file\": null,\n    \"name\": \"zlib-rs\",\n    \"repository\": \"https://github.com/trifectatechfoundation/zlib-rs\"\n  },\n  {\n    \"authors\": null,\n    \"description\": \"A Rust implementation of the Zopfli compression algorithm.\",\n    \"license\": \"Apache-2.0\",\n    \"license_file\": null,\n    \"name\": \"zopfli\",\n    \"repository\": \"https://github.com/zopfli-rs/zopfli\"\n  },\n  {\n    \"authors\": \"Alexandre Bury <alexandre.bury@gmail.com>\",\n    \"description\": \"Binding for the zstd compression library.\",\n    \"license\": \"MIT\",\n    \"license_file\": null,\n    \"name\": \"zstd\",\n    \"repository\": \"https://github.com/gyscos/zstd-rs\"\n  },\n  {\n    \"authors\": \"Alexandre Bury <alexandre.bury@gmail.com>\",\n    \"description\": \"Safe low-level bindings for the zstd compression library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"zstd-safe\",\n    \"repository\": \"https://github.com/gyscos/zstd-rs\"\n  },\n  {\n    \"authors\": \"Alexandre Bury <alexandre.bury@gmail.com>\",\n    \"description\": \"Low-level bindings for the zstd compression library.\",\n    \"license\": \"Apache-2.0 OR MIT\",\n    \"license_file\": null,\n    \"name\": \"zstd-sys\",\n    \"repository\": \"https://github.com/gyscos/zstd-rs\"\n  }\n]"
  },
  {
    "path": "check",
    "content": "#!/bin/bash\n\n./ninja format && ./ninja check\n"
  },
  {
    "path": "docs/architecture.md",
    "content": "# Anki Architecture\n\nVery brief notes for now.\n\n## Backend/GUI\n\nAt the highest level, Anki is logically separated into two parts.\n\nA neat visualization of the file layout is available here:\n<https://mango-dune-07a8b7110.1.azurestaticapps.net/?repo=ankitects%2Fanki>\n(or go to <https://githubnext.com/projects/repo-visualization#explore-for-yourself> and enter `ankitects/anki`).\n\n### Library (rslib & pylib)\n\nThe Python library (pylib) exports \"backend\" methods - opening collections,\nfetching and answering cards, and so on. It is used by Anki’s GUI, and can also\nbe included in command line programs to access Anki decks without the GUI.\n\nThe library is accessible in Python with \"import anki\". Its code lives in\nthe `pylib/anki/` folder.\n\nThese days, the majority of backend logic lives in a Rust library (rslib, located in `rslib/`). Calls to pylib proxy requests to rslib, and return the results.\n\npylib contains a private Python module called rsbridge (`pylib/rsbridge/`) that wraps the Rust code, making it accessible in Python.\n\n### GUI (aqt & ts)\n\nAnki's _GUI_ is a mix of Qt (via the PyQt Python bindings for Qt), and\nTypeScript/HTML/CSS. The Qt code lives in `qt/aqt/`, and is importable in Python\nwith \"import aqt\". The web code is split between `qt/aqt/data/web/` and `ts/`,\nwith the majority of new code being placed in the latter, and copied into the\nformer at build time.\n\n## Protobuf\n\nAnki uses Protocol Buffers to define backend methods, and the storage format of\nsome items in a collection file. The definitions live in `proto/anki/`.\n\nThe Python/Rust bridge uses them to pass data back and forth, and some of the\nTypeScript code also makes use of them, allowing data to be communicated in a\ntype-safe manner between the different languages.\n\nAt the moment, the protobuf is not considered public API. Some pylib methods\nexpose a protobuf object directly to callers, but when they do so, they use a\ntype alias, so callers outside pylib should never need to import a generated\n\\_pb2.py file.\n"
  },
  {
    "path": "docs/build.md",
    "content": "# The build system\n\n## Basic use\n\nBasic use is described in [development.md](./development.md).\n\n## Architecture\n\nThe build/ folder is made up of 4 packages:\n\n- build/configure defines the actions and inputs/outputs of the build graph -\n  this is where you add new build steps or modify existing ones. The defined\n  actions are converted at build time to a build.ninja file that Ninja executes.\n- build/ninja_gen is a library for writing a build.ninja file, and includes\n  various rules like \"build a Rust crate\" or \"run a command\".\n- build/archives is a helper to download/checksum/extract a dependency as part\n  of the build process.\n- build/runner serves a number of purposes:\n  - it's the entrypoint to the build process, taking care of generating\n    the build file and then invoking Ninja\n  - it wraps executable invocations in the build file, swallowing their output\n    if they exit successfully\n  - it provides a few helpers for multi-step processes that can't be easily\n    described in a cross-platform manner thanks to differences on Windows.\n\n## Tracing build problems\n\nIf you run into trouble with the build process:\n\n- You can see the executed commands with e.g. `./ninja pylib -v`\n- You can see the output of successful commands by defining OUTPUT_SUCCESS=1\n- You can see what's triggering a rebuild of a target with e.g.\n  `./ninja qt/anki -d explain`.\n- You can browse the build graph via e.g. `./ninja -- -t browse wheels`\n- You can profile build performance with\n  https://discourse.cmake.org/t/profiling-build-performance/2443/3.\n\n## Packaging considerations\n\nSee [this page](./linux.md).\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "# Contributing Code\n\nFor info on contributing things other than code, such as translations, decks\nand add-ons, please see https://docs.ankiweb.net/contrib\n\n## Help wanted\n\nIf you'd like to contribute but don't know what to work on, please take a look\nat the [issues tab](https://github.com/ankitects/anki/issues) of the Anki repo\non GitHub.\n\n## Larger changes\n\nBefore starting work on larger changes, especially ones that aren't listed on the\nissue tracker, please reach out on the forums before you begin work, so we can let\nyou know whether they're likely to be accepted or not. When you spent a bunch of time\non a PR that ends up getting rejected, it's no fun for either you or us.\n\n## Refactoring\n\nPlease avoid PRs that focus on refactoring. Every PR has a cost to review, and a chance\nof introducing accidental regressions, and often these costs are not worth it for\nslightly more elegant code.\n\nThat's not to say there's no value in refactoring. But such changes are usually better done\nin a PR that happens to be working in the same area - for example, making small changes\nto the code as part of fixing a bug, or a larger refactor when introducing a new feature.\n\n## Type hints\n\nMost of Anki's Python code now has type hints, which improve code completion,\nand make it easier to discover errors during development. When adding new\ncode, please make sure you add type hints as well, or the tests will fail.\n\nQt's stubs are not perfect, so you may sometimes need to use cast(), or silence\na type error. When connecting signals, there's a qconnect() helper in aqt.utils\nthat can be used to work around the type warnings without obscuring other errors\nsuch as a mistyped variable.\n\nIn cases where you have two modules that reference each other, you can fix the\nimport cycle by using fully qualified names in the types, and enabling\nannotations. For example, instead of\n\n```\nfrom aqt.browser import Browser\n\ndef myfunc(b: Browser) -> None:\n  pass\n```\n\nuse the following instead:\n\n```\nfrom __future__ import annotations\n\nimport aqt\n\ndef myfunc(b: aqt.browser.Browser) -> None:\n  pass\n```\n\n## Hooks\n\nIf you're writing an add-on and would like to extend a function that doesn't\ncurrently have a hook, a pull request that adds the required hooks would be\nwelcome. If you could mention your use case in the pull request, that would be\nappreciated.\n\nThe hooks try to follow one of two formats:\n\n[subject] [verb] - eg, note_type_added, card_will_render\n\n[module] [verb] [subject] - eg, browser_did_change_row, editor_did_update_tags\n\nThe qt code tends to use the second form, as the hooks tend to focus on\nparticular screens. The pylib code tends to use the first form, as the focus\nis usually subjects like cards, notes, etc.\n\nUsing \"did change\" instead of the past tense \"changed\" can seem awkward, but\nmakes it consistent with \"will\", and is similar to the naming style used in\niOS's libraries.\n\nIn most cases, hooks are better added in the GUI code than in pylib.\n\nThe hook code is automatically generated using the definitions in\npylib/tools/genhooks.py and qt/tools/genhooks_gui.py. Adding a new definition\nin one of those files will update the generated files.\n\nIf you want to change an existing hook to, for example, receive an additional\nargument, you must leave the existing hook unchanged to preserve backwards\ncompatibility. Create a new definition for your hook with a similar name and\ninclude the properties `replaces=\"name_of_old_hook\"` and\n`replaced_hook_args=[\"...\"]` in the definition of the new hook. If the old hook\nhas a legacy hook, you must not add the legacy hook to the definition of the\nnew hook.\n\n## Translations\n\nFor information on adding new translatable strings to Anki, please see\nhttps://translating.ankiweb.net/anki/developers\n\n## Tests Must Pass\n\nPlease make sure 'ninja check' completes successfully before submitting code.\nYou can do this automatically by adding the following into\n.git/hooks/pre-commit or .git/hooks/pre-push and making it executable.\n\n```sh\n#!/bin/bash\n./ninja check\n```\n\nYou may want to explicitly set PATH to your normal shell PATH in that script,\nas pre-commit does not use a login shell, and if your path differs Bazel will\nend up recompiling things unnecessarily.\n\nIf your change is non-trivial and not covered by the existing unit tests, please\nconsider adding a unit test at the same time.\n\n## Code Style\n\nPlease use standard Python snake_case variable names and functions in newly\nintroduced code. Because add-ons often rely on existing function names, if\nrenaming an existing function, please add a legacy alias to the old function.\n\n## Do One Thing\n\nA patch or pull request should be the minimum necessary to address one issue.\nPlease don't make a pull request for a bunch of unrelated changes, as they are\ndifficult to review and will be rejected - split them up into separate\nrequests instead.\n\n## License\n\nPlease add yourself to the CONTRIBUTORS file in your first pull request.\n"
  },
  {
    "path": "docs/development.md",
    "content": "# Anki development\n\n## Packaged betas\n\nFor non-developers who want to try beta versions, the easiest way is to use a\npackaged version - please see:\n\nhttps://betas.ankiweb.net/\n\n## Pre-built Python wheels\n\nPre-built Python packages are available on PyPI. They are useful if you wish to:\n\n- Run Anki from a local Python installation without building it yourself\n- Get code completion when developing add-ons\n- Make command line scripts that modify .anki2 files via Anki's Python libraries\n\nYou will need the 64 bit version of Python 3.9 or later installed. 3.9 is\nrecommended, as Anki has only received minimal testing on 3.10+ so far, and some\ndependencies have not been fully updated yet. You can install Python from python.org\nor from your distro.\n\nFor further instructions, please see https://betas.ankiweb.net/#via-pypipip. Note that\nin the provided commands, `--pre` tells pip to fetch alpha/beta versions. If you remove\n`--pre`, it will download the latest stable version instead.\n\n## Building from source\n\nClone the git repo into a folder of your choosing. The folder path must not\ncontain spaces, and should not be too long if you are on Windows.\n\nOn all platforms, you will need to install:\n\n- Rustup (https://rustup.rs/). The Rust version pinned in rust-toolchain.toml\n  will be automatically downloaded if not yet installed. If removing that file\n  to use a distro-provided Rust, newer Rust versions will typically work for\n  building but may fail tests; older Rust versions may not work at all.\n- N2 or Ninja. N2 gives better status output. You can install it with `tools/install-n2`,\n  or `bash tools\\install-n2` on Windows. If you want to use Ninja, it can be downloaded\n  from https://github.com/ninja-build/ninja/releases/tag/v1.11.1 and\n  placed on your path, or from your distro/homebrew if it's 1.10+.\n\nPlatform-specific requirements:\n\n- [Windows](./windows.md)\n- [Mac](./mac.md)\n- [Linux](./linux.md)\n\n## Running Anki during development\n\nFrom the top level of Anki's source folder:\n\n```\n./run\n```\n\n(`.\\run` on Windows)\n\nThis will build Anki and run it in place.\n\nThe first build will take a while, as it downloads and builds a bunch of\ndependencies. When the build is complete, Anki will automatically start.\n\nIf Anki fails to start, you may need to install [extra libraries](https://docs.ankiweb.net/platform/linux/missing-libraries.html).\n\n## Running tests/checks\n\nTo run all tests at once, from the top-level folder:\n\n```\n./ninja check\n```\n\n(`tools\\ninja check` on Windows).\n\nYou can also run specific checks. For example, if you see during the checks\nthat `check:svelte:editor` is failing, you can use `./ninja check:svelte:editor`\nto re-run that check, or `./ninja check:svelte` to re-run all Svelte checks.\n\n## Fixing formatting\n\nWhen formatting issues are reported, they can be fixed with\n\n```\n./ninja format\n```\n\n## Fixing ruff/eslint/copyright header issues\n\n```\n./ninja fix\n```\n\n## Fixing clippy issues\n\n```\ncargo clippy --fix\n```\n\n## Excluding your own untracked files from formatting and checks\n\nIf you want to add files or folders to the project tree that should be excluded from version tracking and not be matched by formatters and checks, place them in an `extra` folder and they will automatically be ignored.\n\n## Optimized builds\n\nThe `./run` command will create a non-optimized build by default. This is faster\nto compile, but will mean Anki will run slower.\n\nTo run Anki in optimized mode, use:\n\n```\n./tools/runopt\n```\n\nOr set RELEASE=1 or RELEASE=2. The latter will further optimize the output, but make\nthe build much slower.\n\n## Building redistributable wheels\n\nThe `./run` method described in the platform-specific instructions is a shortcut\nfor starting Anki directly from the build folder. For regular study, it's recommended\nyou build Python wheels and then install them into your own python venv. This is also\na good idea if you wish to install extra tools from PyPi that Anki's build process\ndoes not use.\n\nTo build wheels on Mac/Linux:\n\n```\n./tools/build\n```\n\n(on Windows, `\\tools\\build.bat`)\n\nThe generated wheels are in out/wheels. You can then install them by copying the paths into a pip install command.\nFollow the steps [on the beta site](https://betas.ankiweb.net/#via-pypipip), but replace the\n`pip install --upgrade --pre aqt` line with something like:\n\n```\n/my/pyenv/bin/pip install --upgrade out/wheels/*.whl\n```\n\n(On Windows you'll need to list out the filenames manually instead of using a wildcard).\n\n## Cleaning up build files\n\nApart from submodule checkouts, most build files go into the `out/` folder (and\n`node_modules` on Windows). You can delete that folder for a clean build, or\nto free space.\n\nCargo, yarn and pip all cache downloads of dependencies in a shared cache that\nother builds on your system may use as well. If you wish to clear up those caches,\nthey can be found in `~/.rustup`, `~/.cargo` and `~/.cache/{yarn,pip}`.\n\nIf you invoke Rust outside of the build scripts (eg by running cargo, or\nwith Rust Analyzer), output files will go into `target/` unless you have\noverriden the default output location.\n\n## IDEs\n\nPlease see [this separate page](./editing.md) for setting up an editor/IDE.\n\n## Making changes to the build\n\nSee [this page](./build.md)\n\n## Generating documentation\n\nFor Rust:\n\n```\ncargo doc --open\n```\n\nFor Python:\n\n```\n./ninja python:sphinx && open out/python/sphinx/html/py-modindex.html\n```\n\n## Environmental Variables\n\nIf ANKIDEV is set before starting Anki, some extra log messages will be printed on stdout,\nand automatic backups will be disabled - so please don't use this except on a test profile.\nIt is automatically enabled when using ./run.\n\nIf TRACESQL is set, all SQL statements will be printed as they are executed.\n\nIf LOGTERM is set before starting Anki, warnings and error messages that are normally placed\nin the collection2.log file will also be printed on stdout.\n\nIf ANKI_PROFILE_CODE is set, Python profiling data will be written on exit.\n\n# Installer/launcher\n\n- The anki-release package is created/published with the scripts in qt/release.\n- The installer/launcher is created with the build scripts in qt/launcher/{platform}.\n\n## Building\n\nThe steps to build the launcher vary slightly depending on your operating\nsystem. First, you have to navigate to the appropriate folder:\n\n| Operating System | Path               | Env variables |\n| ---------------- | ------------------ | ------------- |\n| Linux            | ./qt/launcher/lin/ | -             |\n| MacOS            | ./qt/launcher/mac/ | `NODMG=1`     |\n| Windows          | .\\qt\\launcher\\win\\ | `NOCOMP=1`    |\n\nIf you are on Windows or MacOS, you will now have to set the environment\nvariables as outlined in the table above. `NOCOMP=1` skips code signing\nand compression, whereas `NODMG=1` skips the slow bundling / code signing.\n\nNext, run the `build.sh` script (on Linux and MacOS) or the `build.bat` script\n(on Windows).\n\nFor example, on Linux, you can build the launcher by following these steps:\n\n```\ncd ./qt/launcher/lin/\n./build.sh\n```\n\n## Issues During Building\n\nIf you are experiencing issues building the launcher, make sure that all dependencies\nare installed. See [Building from source](#building-from-source) for more info.\n\n## Running\n\nOnce the launcher is built, you can find the executable under `out/launcher`\n(located in the project root). In that folder, you will find the binary file of\nthe launcher.\n\nOn linux, you will find a `launcher.amd64` and a `launcher.arm64` binary file.\nSelect the one matching your architecture and run it to test your changes.\n\nFor example, on Linux, after following the build steps above, you can run the\namd64 launcher via this command:\n\n```\n../../../out/launcher/anki-launcher-25.09.2-linux/launcher.amd64\n```\n\n# Mixing development and study\n\nYou may wish to create a separate profile with File>Switch Profile for use\nduring development. You can pass the arguments \"-p [profile name]\" when starting\nAnki to load a specific profile.\n\nIf you're using PyCharm:\n\n- right click on the \"run\" file in the root of the PyCharm Anki folder\n- click \"Edit 'run'...\" - in Script options and enter:\n  \"-p [dev profile name]\" without the quotes\n- click \"Ok\"\n"
  },
  {
    "path": "docs/docker/Dockerfile",
    "content": "# This is a user-contributed Dockerfile. No official support is available.\n\nARG DEBIAN_FRONTEND=\"noninteractive\"\n\nFROM ubuntu:24.04 AS build\nWORKDIR /opt/anki\nENV PYTHON_VERSION=\"3.13\"\n\n\n# System deps\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    curl \\\n    git \\\n    build-essential \\\n    pkg-config \\\n    libssl-dev \\\n    libbz2-dev \\\n    libreadline-dev \\\n    libsqlite3-dev \\\n    libffi-dev \\\n    zlib1g-dev \\\n    liblzma-dev \\\n    ca-certificates \\\n    ninja-build \\\n    rsync \\\n    libglib2.0-0 \\\n    libgl1 \\\n    libx11-6 \\\n    libxext6 \\\n    libxrender1 \\\n    libxkbcommon0 \\\n    libxkbcommon-x11-0 \\\n    libxcb1 \\\n    libxcb-render0 \\\n    libxcb-shm0 \\\n    libxcb-icccm4 \\\n    libxcb-image0 \\\n    libxcb-keysyms1 \\\n    libxcb-randr0 \\\n    libxcb-shape0 \\\n    libxcb-xfixes0 \\\n    libxcb-xinerama0 \\\n    libxcb-xinput0 \\\n    libsm6 \\\n    libice6 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# install rust with rustup\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nENV PATH=\"/root/.cargo/bin:${PATH}\"\n\n# Install uv and Python 3.13 with uv\nRUN curl -LsSf https://astral.sh/uv/install.sh | sh \\\n    && ln -s /root/.local/bin/uv /usr/local/bin/uv\nENV PATH=\"/root/.local/bin:${PATH}\"\n\nRUN uv python install ${PYTHON_VERSION} --default\n\nCOPY . . \n\nRUN ./tools/build\n\n\n# Install pre-compiled Anki.\nFROM python:3.13-slim AS installer\nWORKDIR /opt/anki/\nCOPY --from=build /opt/anki/out/wheels/ wheels/\n# Use virtual environment.\nRUN python -m venv venv \\\n    && ./venv/bin/python -m pip install --no-cache-dir setuptools wheel \\\n    && ./venv/bin/python -m pip install --no-cache-dir /opt/anki/wheels/*.whl\n\n\n# We use another build stage here so we don't include the wheels in the final image.\nFROM python:3.13-slim AS final\nCOPY --from=installer /opt/anki/venv /opt/anki/venv\nENV PATH=/opt/anki/venv/bin:$PATH\n# Install run-time dependencies.\nRUN apt-get update \\\n    && apt-get install --yes --no-install-recommends \\\n    libasound2 \\\n    libdbus-1-3 \\\n    libfontconfig1 \\\n    libfreetype6 \\\n    libgl1 \\\n    libglib2.0-0 \\\n    libnss3 \\\n    libxcb-icccm4 \\\n    libxcb-image0 \\\n    libxcb-keysyms1 \\\n    libxcb-randr0 \\\n    libxcb-render-util0 \\\n    libxcb-shape0 \\\n    libxcb-xinerama0 \\\n    libxcb-xkb1 \\\n    libxcomposite1 \\\n    libxcursor1 \\\n    libxi6 \\\n    libxkbcommon0 \\\n    libxkbcommon-x11-0 \\\n    libxrandr2 \\\n    libxrender1 \\\n    libxtst6 \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Add non-root user.\nRUN useradd --create-home anki\nUSER anki\nWORKDIR /work\nENTRYPOINT [\"/opt/anki/venv/bin/anki\"]"
  },
  {
    "path": "docs/docker/README.md",
    "content": "# Building and running Anki in Docker\n\nThis is an example Dockerfile contributed by an Anki user, which shows how Anki\ncan be both built and run from within a container. It works by streaming the GUI\nover an X11 socket.\n\nBuilding and running Anki within a container has the advantage of fully isolating\nthe build products and runtime dependencies from the rest of your system, but it is\na somewhat niche approach, with some downsides such as an inability to display natively\non Wayland, and a lack of integration with desktop icons/filetypes. But even if you\ndo not use this Dockerfile as-is, you may find it useful as a reference.\n\nAnki's Linux CI is also implemented with Docker, and the Dockerfiles for that may\nalso be useful for reference - they can be found in `.buildkite/linux/docker`.\n\n# Build the Docker image\n\nFor best results, enable BuildKit (`export DOCKER_BUILDKIT=1`).\n\nWhen in this current directory, one can build the Docker image like this:\n\n```bash\ndocker build --tag anki --file Dockerfile ../../\n```\n\nWhen this is done, run `docker image ls` to see that the image has been created.\n\nIf one wants to build from the project's root directory, use this command:\n\n```bash\ndocker build --tag anki --file docs/docker/Dockerfile .\n```\n\n# Run the Docker image\n\nAnki starts a graphical user interface, and this requires some extra setup on the user's\nend. These instructions were tested on Linux (Debian 11) and will have to be adapted for\nother operating systems.\n\nTo allow the Docker container to pull up a graphical user interface, we need to run the\nfollowing:\n\n```bash\nxhost +local:root\n```\n\nOnce done using Anki, undo this with\n\n```bash\nxhost -local:root\n```\n\nThen, we will construct our `docker run` command:\n\n```bash\ndocker run --rm -it \\\n    --name anki \\\n    --volume $HOME/.local/share:$HOME/.local/share:rw \\\n    --volume /etc/passwd:/etc/passwd:ro \\\n    --user $(id -u):$(id -g) \\\n    --volume /tmp/.X11-unix:/tmp/.X11-unix:rw \\\n    --env DISPLAY=$DISPLAY \\\n    anki\n```\n\nHere is a breakdown of some of the arguments:\n\n- Mount the current user's `~/.local/share` directory onto the container. Anki saves things\n  into this directory, and if we don't mount it, we will lose any changes once the\n  container exits. We mount this as read-write (`rw`) because we want to make changes here.\n\n  ```bash\n  --volume $HOME/.local/share:$HOME/.local/share:rw\n  ```\n\n- Mount `/etc/passwd` so we can enter the container as ourselves. We mount this as\n  read-only because we definitely do not want to modify this.\n\n  ```bash\n  --volume /etc/passwd:/etc/passwd:ro\n  ```\n\n- Enter the container with our user ID and group ID, so we stay as ourselves.\n\n  ```bash\n  --user $(id -u):$(id -g)\n  ```\n\n- Mount the X11 directory that allows us to open displays.\n\n  ```bash\n  --volume /tmp/.X11-unix:/tmp/.X11-unix:rw\n  ```\n\n- Pass the `DISPLAY` variable to the container, so it knows where to display graphics.\n\n  ```bash\n  --env DISPLAY=$DISPLAY\n  ```\n\n# Running Dockerized Anki easily from the command line\n\nOne can create a shell function that executes the `docker run` command. Then one can\nsimply run `anki` on the command line, and Anki will open in Docker. Make sure to change\nthe image name to whatever you used when building Anki.\n\n```bash\nanki() {\n    docker run --rm -it \\\n        --name anki \\\n        --volume $HOME/.local/share:$HOME/.local/share:rw \\\n        --volume /etc/passwd:/etc/passwd:ro \\\n        --user $(id -u):$(id -g) \\\n        --volume /tmp/.X11-unix:/tmp/.X11-unix:rw \\\n        --env DISPLAY=$DISPLAY \\\n        anki \"$@\"\n}\n```\n"
  },
  {
    "path": "docs/editing.md",
    "content": "# Editing/IDEs\n\nVisual Studio Code is recommended, since it provides decent support for all the languages\nAnki uses. To set up the recommended workspace settings for VS Code, please see below.\n\nFor editing Python, PyCharm/IntelliJ's type checking/completion is a bit nicer than\nVS Code, but VS Code has improved considerably in a short span of time.\n\nThere are a few steps you'll want to take before you start using an IDE.\n\n## Initial Setup\n\n### Python Environment\n\nFor code completion of external Python modules, you can use the venv that is\ngenerated as part of the build process. After building Anki, the venv will be in\n`out/pyenv`. In VS Code, use ctrl/cmd+shift+p, then 'python: select\ninterpreter'.\n\n### Rust\n\nYou'll need Rust to be installed, which is required as part of the build process.\n\n### Build First\n\nCode completion partly depends on files that are generated as part of the\nregular build process, so for things to work correctly, use './run' or\n'tools/build' prior to using code completion.\n\n## Visual Studio Code\n\n### Setting up Recommended Workspace Settings\n\nTo start off with some default workspace settings that are optimized for Anki\ndevelopment, please head to the project root and then run:\n\n```\nmkdir .vscode && cd .vscode\nln -sf ../.vscode.dist/* .\n```\n\n### Installing Recommended Extensions\n\nOnce the workspace settings are set up, open the root of the repo in VS Code to\nsee and install a number of recommended extensions.\n\n## PyCharm/IntelliJ\n\n### Setting up Python environment\n\nTo make PyCharm recognize `anki` and `aqt` imports, you need to add source paths to _Settings > Project Structure_.\nYou can copy the provided .idea.dist directory to set up the paths automatically:\n\n```\nmkdir .idea && cd .idea\nln -sf ../.idea.dist/* .\n```\n\nYou also need to add a new Python interpreter under _Settings > Python > Interpreter_ pointing to the Python executable under `out/pyenv` (available after building Anki).\n"
  },
  {
    "path": "docs/language_bridge.md",
    "content": "Anki's codebase uses three layers.\n\n1. The web frontend, created in Svelte and typescript,\n2. The Python layer and\n3. The core Rust layer.\n\nEach layer can can makes RPC (Remote Procedure Call) to the layers below it. While it should be avoided, Python can also invoke Typescript functions. The Rust layers never make calls to the other layers. Note that it can make RPC to AnkiWeb and other servers, which is out of scope of this document.\n\nIn this document we'll provide examples of bridge between languages, explaining:\n\n- where the RPC is declared,\n- where it is called (with the appropriate imports) and\n- where it is implemented.\n\nImitating those examples should allow you to make call and create new RPCs.\n\n## Declaring RPCs\n\nLet's consider the method `NewDeck` of `DecksServices`. It's declared in [decks.proto](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/decks.proto#L14) as `rpc NewDeck(generic.Empty) returns (Deck);`. This means this methods takes no argument (technically, an argument containing no information), and returns a [`Deck`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/decks.proto#L54).\n\nRead [protobuf](./protobuf.md) to learn more about how those input and output types are defined.\n\nIf the RPC implementation is in Python, it should be declared in the service [frontend.proto](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/frontend.proto#L24C3-L24C66)'s `FrontendService`. RPCs declared in any other services are implemented in Rust.\n\n## Making a Remote Procedure Call\n\nIn this section we'll consider how to make Remote Procedure Call (RPC) from languages used in Anki. Languages used for AnkiDroid and AnkiMobile are out of scope of this document.\n\n### Making a RPC from Python\n\nPython can invoke the `NewDeck` method with [`col._backend.new_deck()`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/pylib/anki/decks.py#L168). This python method takes no argument and returns a `Deck` value.\n\nHowever, most Python code should not call this method directly. Instead it should call [`col.decks.new_deck()`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/pylib/anki/decks.py#L166). Generally speaking, all back-end functions called from Python should be called through a helper method defined in `pylib/anki/`. The `_backend` part is an implementation detail that most callers should ignore. This is especially important because add-ons should expect a relatively stable API independent of the implementation details of the RPC.\n\n### Invoking method from TypeScript\n\nLet's consider the method [`rpc GetCsvMetadata(CsvMetadataRequest) returns (CsvMetadata);`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/import_export.proto#L20) from `ImportExportService`..\n\nIt's used in the TypeScript class [`ImportCsvState`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/routes/import-csv/lib.ts#L102), as an asynchronous function. It's argument is a single javascript object, whose keys are as in [`CsvMetadataRequest`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/proto/anki/import_export.proto#L138) and it returns a `CsvMetadata`.\n\nThe method was imported with `import { getCsvMetadata } from \"@generated/backend\";` and the types were imported with `import type { CsvMetadata } from \"@generated/anki/import_export_pb\";`. Note that it was not necessary to import the input type given that it's simply an untyped javascript object.\n\n## Implementation\n\nLet's now look at implementations of those RPCs.\n\n### Implementation in Rust\n\nThe method NewDeck is implemented in Rust's [DecksService](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/rslib/src/decks/service.rs#L21) as `fn new_deck(&mut self) -> error::Result<anki_proto::decks::Deck>`. It should be noted that the method name was changed from Pascal case to snake case, and the rps's argument of type `generic.Empty` is ignored.\n\n### Implementation in Python\n\nLet's consider the implementation of the method [DeckOptionsRequireClose](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/mediasrv.py#L578). It's defined as `def deck_options_require_close() -> bytes:`. In this case, there should be a returned value. However, it'll be ignored, so returning `b\"\"` is perfectly fine.\n\nNote that the incoming HTTP request is not processed on the main thread. In order to do any work with the GUI, we should call `aqt.mw.taskman.run_on_main`.\n\n## Invoking a TypeScript method from Python\n\nThis case should be avoided if possible, as we generally should avoid\ncalls to the upper layer. Contrary to the previous cases, we don't use\nprotobuf.\n\n### Calling a TS function.\n\nLet's take as Example [`export function getTypedAnswer(): string | null`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/reviewer/index.ts#L35). It's an exported function, and its return type can be encoded in JSON.\n\nIt's called in the Reviewer class through [`self.web.evalWithCallback(\"getTypedAnswer();\", self._onTypedAnswer)`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/reviewer.py#L785). The result is then sent to [`_onTypedAnswer`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/reviewer.py#L787).\n\nIf no return value is needed, `web.eval` would have been sufficient.\n\n### Calling a Svelte method\n\nLet's now consider the case where the method we want to call is implemented in a Svelte library. Let's take as example [`deckOptionsPendingChanges`](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/ts/routes/deck-options/%5BdeckId%5D/%2Bpage.svelte#L17). We define it with:\n\n```js\nglobalThis.anki || = {};\nglobalThis.anki.methodName = async (): Promise<void>=>{body}\n```\n\nNote that if the function is asynchronous, you can't directly send the\nresult to a callback. Instead your function will have to call a post\nmethod that will be sent to Python or Rust.\n\nThis method is called in [deckoptions.py](https://github.com/ankitects/anki/blob/acaeee91fa853e4a7a78dcddbb832d009ec3529a/qt/aqt/deckoptions.py#L68) with `self.web.eval(\"anki.deckOptionsPendingChanges();\"`.\n"
  },
  {
    "path": "docs/linux.md",
    "content": "# Linux-specific notes\n\n## Requirements\n\nThese instructions are written for Debian/Ubuntu; adjust for your distribution.\nSome extra notes have been provided by a forum member, though some of the things\nmentioned there no longer apply:\nhttps://forums.ankiweb.net/t/guide-how-to-build-and-run-anki-from-source-with-xubuntu-20-04/12865\n\nYou can see a full list of buildtime and runtime requirements by looking at the\n[Dockerfile](../.buildkite/linux/docker/Dockerfile) used to build the\nofficial releases.\n\n**Ensure some basic tools are installed**:\n\n```\n$ sudo apt install bash grep findutils curl gcc gcc-12 g++ make git rsync\n```\n\n- The 'find' utility is 'findutils' on Debian.\n\n## Missing Libraries\n\nIf you get errors during build or startup, try starting with\n\nQT_DEBUG_PLUGINS=1 ./run\n\nIt will likely complain about missing libraries, which you can install with\nyour package manager. Some of the libraries that might be required on Debian\nfor example:\n\n```\nsudo apt install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \\\n  libxcb-randr0 libxcb-render-util0 libxkbfile1\n```\n\nThe libraries that might be required on Arch Linux:\n\n```\nsudo pacman -S nss libxkbfile\n```\n\nOn some distros such as Fedora, you may need to install the\n`libxcrypt-compat` package if you get an error like this:\n\n```\nerror while loading shared libraries: libcrypt.so.1: cannot open shared object file: No such file or directory\n```\n\n## Dependencies for Building the Launcher\n\nIf you want to build the launcher, you will need to install the following dependency:\n\n```\nsudo apt install gcc-aarch64-linux-gnu\n```\n\n## Audio\n\nTo play and record audio during development, install mpv and lame.\n\n## Glibc and Qt\n\nAnki requires a recent glibc.\n\nIf you are using a distro that uses musl, Anki will not work.\n\nYou can use your system's Qt libraries if they are Qt 6.2 or later, if\nyou wish. After installing the system libraries (eg:\n'sudo apt install python3-pyqt6.qt{quick,webengine} python3-venv pyqt6-dev-tools'),\nfind the place they are installed (eg '/usr/lib/python3/dist-packages'). On modern Ubuntu, you'll\nalso need 'sudo apt remove python3-protobuf'. Then before running any commands like './run', tell Anki where\nthe packages can be found:\n\n```\nexport PYTHONPATH=/usr/lib/python3/dist-packages\nexport PYTHON_BINARY=/usr/bin/python3\n```\n\n## Packaging considerations\n\nPython, node and protoc are downloaded as part of the build. You can optionally define\nPYTHON_BINARY, NODE_BINARY, YARN_BINARY and/or PROTOC_BINARY to use locally-installed versions instead.\n\nIf rust-toolchain.toml is removed, newer Rust versions can be used. Older versions\nmay or may not compile the code.\n\nTo build Anki fully offline, set the following environment variables:\n\n- OFFLINE_BUILD: If set, the build does not run tools that may access\n  the network.\n\n- NODE_BINARY, YARN_BINARY and PROTOC_BINARY must also be set.\n\nWith OFFLINE_BUILD defined, manual intervention is required for the\noffline build to succeed. The following conditions must be met:\n\n1. All required dependencies (node, Python, rust, yarn, etc.) must be\n   present in the build environment.\n\n2. The offline repositories for the translation files must be\n   copied/linked to ftl/qt-repo and ftl/core-repo.\n\n3. The Python pseudo venv must be set up:\n\n   ```\n   mkdir out/pyenv/bin\n   ln -s /path/to/python out/pyenv/bin/python\n   ln -s /path/to/protoc-gen-mypy out/pyenv/bin/protoc-gen-mypy\n   ```\n\n   Optionally, set up your environment to generate Sphinx documentation:\n\n   ```\n   ln -s /path/to/sphinx-apidoc out/pyenv/bin/sphinx-apidoc\n   ln -s /path/to/sphinx-build out/pyenv/bin/sphinx-build\n   ```\n\n   Note that the PYTHON_BINARY environment variable need not be set,\n   since it is only used when OFFLINE_BUILD is unset to automatically\n   create a network-dependent Python venv.\n\n4. Create the offline cache for yarn and use its own environment\n   variable YARN_CACHE_FOLDER to it:\n\n   ```\n   YARN_CACHE_FOLDER=/path/to/the/yarn/cache\n   /path/to/yarn install --ignore-scripts\n   ```\n\nYou are now ready to build wheels and Sphinx documentation fully\noffline.\n\n## More\n\nFor info on running tests, building wheels and so on, please see [Development](./development.md).\n"
  },
  {
    "path": "docs/mac.md",
    "content": "# Mac-specific notes\n\n## Requirements\n\n**Xcode**:\n\nInstall the latest XCode from the App Store. Open it at least once\nso it installs the command line tools.\n\n**Git/rsync**\n\nInstall via Homebrew or similar tool.\n\n## Audio\n\nTo play audio, use Homebrew to install mpv and lame.\n\n## More\n\nFor info on running tests, building wheels and so on, please see [Development](./development.md).\n"
  },
  {
    "path": "docs/ninja.md",
    "content": "Brief notes for people used to the existing Bazel build system:\n\n- Put the ninja binary on your path: https://github.com/ninja-build/ninja/releases/tag/v1.11.1\n  (on Windows, if you have it installed in msys, make sure the native binary occurs earlier on the path)\n- Ensure Rust is installed via rustup: https://rustup.rs/\n- Remove the .bazel and node_modules folders from your existing checkout\n\n- Run with ./run\n- Run tests with './ninja check' (tools\\ninja on Windows)\n- Format files with './ninja format'\n- Fix eslint/copyright issues with './ninja fix'\n- Targets are hierarchical, so './ninja check:jest:deck-options' will run\n  the Jest tests for ts/deck-options, and './ninja check:jest' will run all\n  Jest tests.\n"
  },
  {
    "path": "docs/protobuf.md",
    "content": "ProtoBuf is a format used both to save data in storage and transmit\ndata between services. You can think of it as similar to JSON with\nschemas, given that you can use basic types, list and records. Except\nthat it's usually transmitted and saved in an efficient byteform and\nnot in a human readable way.\n\n# Protocol Buffers\n\nAnki uses [different implementations of Protocol Buffers](./architecture.md#protobuf)\nand each has its own peculiarities. This document highlights some aspects relevant\nto Anki and hopefully helps to avoid some common pitfalls.\n\nFor information about Protobuf's types and syntax, please see the official [language guide](https://developers.google.com/protocol-buffers/docs/proto3).\n\n## General Notes\n\n### Names\n\nGenerated code follows the naming conventions of the targeted language. So to access\nthe message field `foo_bar` you need to use `fooBar` in Typescript and the\nnamespace created by the message `FooBar` is called `foo_bar` in Rust.\n\n### Optional Values\n\nIn Python and Typescript, unset optional values will contain the type's default\nvalue rather than `None`, `null` or `undefined`. Here's an example:\n\n```protobuf\nmessage Foo {\n  optional string name = 1;\n  optional int32 number = 2;\n}\n```\n\n```python\nmessage = Foo()\nassert message.number == 0\nassert message name == \"\"\n```\n\nIn Python, we can use the message's `HasField()` method to check whether a field is\nactually set:\n\n```python\nmessage = Foo(name=\"\")\nassert message.HasField(\"name\")\nassert not message.HasField(\"number\")\n```\n\nIn Typescript, this is even less ergonomic and it can be easier to avoid using\nthe default values in active fields. E.g. the `CsvMetadata` message uses 1-based\nindices instead of optional 0-based ones to avoid ambiguity when an index is `0`.\n\n### Oneofs\n\nAll fields in a oneof are implicitly optional, so the caveats [above](#optional-values)\napply just as much to a message like this:\n\n```protobuf\nmessage Foo {\n    oneof bar {\n      string name = 1;\n      int32 number = 2;\n    }\n}\n```\n\nIn addition to `HasField()`, `WhichOneof()` can be used to get the name of the set\nfield:\n\n```python\nmessage = Foo(name=\"\")\nassert message.WhichOneof(\"bar\") == \"name\"\n```\n\n### Backwards Compatibility\n\nThe official [language guide](https://developers.google.com/protocol-buffers/docs/proto3)\nmakes a lot of notes about backwards compatibility, but as Anki usually doesn't\nuse Protobuf to communicate between different clients, things like shuffling around\nfield numbers are usually not a concern.\n\nHowever, there are some messages, like `Deck`, which get stored in the database.\nIf these are modified in an incompatible way, this can lead to serious issues if\nclients with a different protocol try to read them. Such modifications are only\nsafe to make as part of a schema upgrade, because schema 11 (the targeted schema\nwhen choosing _Downgrade_), does not make use of Protobuf messages.\n\n### Field Numbers\n\nField numbers larger than 15 need an additional byte to encode, so `repeated` fields\nshould preferably be assigned a number between 1 and 15. If a message contains\n`reserved` fields, this is usually to accommodate potential future `repeated` fields.\n\n## Implementation-Specific Notes\n\n### Python\n\nProtobuf has an official Python implementation with an extensive [reference](https://developers.google.com/protocol-buffers/docs/reference/python-generated).\n\n### Typescript\n\nAnki uses [protobuf-es](https://github.com/bufbuild/protobuf-es), which offers\nsome documentation.\n\n### Rust\n\nAnki uses the [prost crate](https://docs.rs/prost/latest/prost/).\nIts documentation has some useful hints, but for working with the generated code,\nthere is a better option: From within `anki/rslib` run `cargo doc --open --document-private-items`.\nInside the `pb` module you will find all generated Rust types and their implementations.\n\n- Given an enum field `Foo foo = 1;`, `message.foo` is an `i32`. Use the accessor\n  `message.foo()` instead to avoid having to manually convert to a `Foo`.\n- Protobuf does not guarantee any oneof field to be set or an enum field to contain\n  a valid variant, so the Rust code needs to deal with a lot of `Option`s. As we\n  don't expect other parts of Anki to send invalid messages, using an `InvalidInput`\n  error or `unwrap_or_default()` is usually fine.\n"
  },
  {
    "path": "docs/syncserver/Dockerfile",
    "content": "FROM rust:1.85.0-alpine3.20 AS builder\n\nARG ANKI_VERSION\n\nRUN apk update && apk add --no-cache build-base protobuf && rm -rf /var/cache/apk/*\n\nRUN cargo install --git https://github.com/ankitects/anki.git \\\n--tag ${ANKI_VERSION} \\\n--root /anki-server  \\\n--locked \\\nanki-sync-server\n\nFROM alpine:3.21.0\n\n# Default PUID and PGID values (can be overridden at runtime). Use these to\n# ensure the files on the volume have the permissions you need.\nENV PUID=1000\nENV PGID=1000\n\nCOPY --from=builder /anki-server/bin/anki-sync-server /usr/local/bin/anki-sync-server\n\nRUN apk update && apk add --no-cache bash su-exec && rm -rf /var/cache/apk/*\n\n\nEXPOSE 8080\n\nCOPY entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\nENTRYPOINT [\"/entrypoint.sh\"]\nCMD [\"anki-sync-server\"]\n\n# This health check will work for Anki versions 24.08.x and newer.\n# For older versions, it may incorrectly report an unhealthy status, which should not be the case.\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n    CMD wget -qO- http://127.0.0.1:8080/health || exit 1\n\nVOLUME /anki_data\n\nLABEL maintainer=\"Jean Khawand <jk@jeankhawand.com>\"\n"
  },
  {
    "path": "docs/syncserver/Dockerfile.distroless",
    "content": "FROM rust:1.85.0 AS builder\n\nARG ANKI_VERSION\n\nRUN apt-get update && apt-get install -y build-essential protobuf-compiler && apt-get clean && rm -rf /var/lib/apt/lists/*\n\nRUN cargo install --git https://github.com/ankitects/anki.git \\\n--tag ${ANKI_VERSION} \\\n--root /anki-server  \\\n--locked \\\nanki-sync-server\n\nFROM gcr.io/distroless/cc-debian12\n\nCOPY --from=builder /anki-server/bin/anki-sync-server /usr/bin/anki-sync-server\n\n# Note that as a user of the container you should NOT overwrite these values\n# for safety and simplicity reasons\nENV SYNC_PORT=8080\nENV SYNC_BASE=/anki_data\n\nEXPOSE ${SYNC_PORT}\n\nCMD [\"anki-sync-server\"]\n\n# This health check will work for Anki versions 24.08.x and newer.\n# For older versions, it may incorrectly report an unhealthy status, which should not be the case.\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\nCMD [\"anki-sync-server\", \"--healthcheck\"]\n\nVOLUME /anki_data\n\nLABEL maintainer=\"Jean Khawand <jk@jeankhawand.com>\"\n"
  },
  {
    "path": "docs/syncserver/README.md",
    "content": "# Building and running Anki sync server in Docker\n\nThis is an example Dockerfile contributed by an Anki user, which shows how you can run a self-hosted sync server,\nsimilar to what AnkiWeb.net offers.\n\nBuilding and running the sync server within a container has the advantage of fully isolating\nthe build products and runtime dependencies from the rest of your system.\n\n## Requirements\n\n- [x] [Docker](https://docs.docker.com/get-started/)\n\n| **Aspect**             | **Dockerfile**                                             | **Dockerfile.distroless**                                 |\n| ---------------------- | ---------------------------------------------------------- | --------------------------------------------------------- |\n| **Shell & Tools**      | ✅ Includes shell and tools                                | ❌ Minimal, no shell or tools                             |\n| **Debugging**          | ✅ Easier debugging with shell and tools                   | ❌ Harder to debug due to minimal environment             |\n| **Health Checks**      | ✅ Supports complex health checks                          | ❌ Health checks need to be simple or directly executable |\n| **Image Size**         | ❌ Larger image size                                       | ✅ Smaller image size                                     |\n| **Customization**      | ✅ Easier to customize with additional packages            | ❌ Limited customization options                          |\n| **Attack Surface**     | ❌ Larger attack surface due to more installed packages    | ✅ Reduced attack surface                                 |\n| **Libraries**          | ✅ More libraries available                                | ❌ Limited libraries                                      |\n| **Start-up Time**      | ❌ Slower start-up time due to larger image size           | ✅ Faster start-up time                                   |\n| **Tool Compatibility** | ✅ Compatible with more tools and libraries                | ❌ Compatibility limitations with certain tools           |\n| **Maintenance**        | ❌ Higher maintenance due to larger image and dependencies | ✅ Lower maintenance with minimal base image              |\n| **Custom uid/gid**     | ✅ It's possible to pass in PUID and PGID                  | ❌ PUID and PGID are not supported                        |\n\n# Building image\n\nTo proceed with building, you must specify the Anki version you want, by replacing `<version>` with something like `24.11` and `<Dockerfile>` with the chosen Dockerfile (e.g., `Dockerfile` or `Dockerfile.distroless`)\n\n```bash\n# Execute this command from this directory\ndocker build -f <Dockerfile> --no-cache --build-arg ANKI_VERSION=<version> -t anki-sync-server .\n```\n\n# Run container\n\nOnce done with build, you can proceed with running this image with the following command:\n\n```bash\n# this will create anki server\ndocker run -d \\\n    -e \"SYNC_USER1=admin:admin\" \\\n    -p 8080:8080 \\\n    --mount type=volume,src=anki-sync-server-data,dst=/anki_data \\\n    --name anki-sync-server \\\n    anki-sync-server\n```\n\nIf the image you are using was built with `Dockerfile` you can specify the\n`PUID` and `PGID` env variables for the user and group id of the process that\nwill run the anki-sync-server process. This is valuable when you want the files\nwritten and read from the `/anki_data` volume to belong to a particular\nuser/group e.g. to access it from the host or another container. Note the the\nids chosen for `PUID` and `PGID` must not already be in use inside the\ncontainer (1000 and above is fine). For example add `-e \"PUID=1050\"` and `-e\n\"PGID=1050\"` to the above command.\n\nIf you want to have multiple Anki users that can sync their devices, you can\nspecify multiple `SYNC_USER` as follows:\n\n```bash\n# this will create anki server with multiple users\ndocker run -d \\\n    -e \"SYNC_USER1=admin:admin\" \\\n    -e \"SYNC_USER2=admin2:admin2\" \\\n    -p 8080:8080 \\\n    --mount type=volume,src=anki-sync-server-data,dst=/anki_data \\\n    --name anki-sync-server \\\n    anki-sync-server\n```\n\nMoreover, you can pass additional env vars mentioned\n[here](https://docs.ankiweb.net/sync-server.html). Note that `SYNC_BASE` and\n`SYNC_PORT` will be ignored. In the first case for safety reasons, to avoid\naccidentally placing data outside the volume and the second for simplicity\nsince the internal port of the container does not matter given that you can\nchange the external one.\n\n# Upgrading\n\nIf your image was built after January 2025 then you can just build a new image\nand start a new container with the same configuration as the previous\ncontainer. Everything should work as expected.\n\nIf the image you were running was built **before January 2025** then it did not\ncontain a volume, meaning all syncserver data was stored inside the container.\nIf you discard the container, for example because you want to build a new\ncontainer using an updated image, then your syncserver data will be lost.\n\nThe easiest way of working around this is by ensuring at least one of your\ndevices is fully in sync with your syncserver before upgrading the Docker\ncontainer. Then after upgrading the container when you try to sync your device\nit will tell you that the server has no data. You will then be given the option\nof uploading all local data from the device to syncserver.\n"
  },
  {
    "path": "docs/syncserver/entrypoint.sh",
    "content": "#!/bin/sh\nset -o errexit\nset -o nounset\nset -o pipefail\n\n# Default PUID and PGID if not provided\nexport PUID=${PUID:-1000}\nexport PGID=${PGID:-1000}\n\n# These values are fixed and cannot be overwritten from the outside for\n# convenience and safety reasons\nexport SYNC_PORT=8080\nexport SYNC_BASE=/anki_data\n\n# Check if group exists, create if not\nif ! getent group anki-group > /dev/null 2>&1; then\n    addgroup -g \"$PGID\" anki-group\nfi\n\n# Check if user exists, create if not\nif ! id -u anki > /dev/null 2>&1; then\n    adduser -D -H -u \"$PUID\" -G anki-group anki\nfi\n\n# Fix ownership of mounted volumes\nmkdir -p /anki_data\nchown anki:anki-group /anki_data\n\n# Run the provided command as the `anki` user\nexec su-exec anki \"$@\"\n"
  },
  {
    "path": "docs/windows.md",
    "content": "# Windows\n\n## Requirements\n\n**Windows**:\n\nYou must be running 64 bit Windows 10, version 1703 or newer.\n\n**Rustup**:\n\nAs mentioned in development.md, rustup must be installed. If you're on\nARM Windows and install the ARM64 version of rust-up, from this project folder,\nrun\n\n```\nrustup target add x86_64-pc-windows-msvc\n```\n\n**Visual Studio**:\n\nInstall Visual Studio Community Edition from Microsoft. Once you've downloaded\nthe installer, open it, and select \"Desktop Development with C++\" on the left,\nleaving the options shown on the right as is.\n\n**MSYS**:\n\nInstall [msys2](https://www.msys2.org/) into the default folder location.\n\nAfter installation completes, run msys2, and run the following command:\n\n```\n$ pacman -S git rsync\n```\n\nEdit your PATH environmental variable and add c:\\msys64\\usr\\bin to it, and\nreboot.\n\nIf you have native Windows apps relying on Git, e.g. the PowerShell extension\n[posh-git](https://github.com/dahlbyk/posh-git), you may want to install\n[Git for Windows](https://gitforwindows.org/) and put it on the path instead,\nas msys Git may cause issues with them. You'll need to make sure rsync is\navailable some other way.\n\n**Source folder**:\n\nAnki's source files do not need to be in a specific location, but it's best to\navoid long paths, as they can cause problems. Spaces in the path may cause\nproblems.\n\n## Audio\n\nTo play and record audio during development, mpv.exe and lame.exe must be on the path.\n\n## More\n\nFor info on running tests, building wheels and so on, please see\n[Development](./development.md).\n"
  },
  {
    "path": "ftl/.gitignore",
    "content": "usage/*\n!usage/no-deprecate.json\nmobile-repo\n"
  },
  {
    "path": "ftl/Cargo.toml",
    "content": "[package]\nname = \"ftl\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Helpers for Anki's i18n system\"\n\n[dependencies]\nanki_io.workspace = true\nanki_process.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\nclap.workspace = true\nfluent-syntax.workspace = true\nitertools.workspace = true\nregex.workspace = true\nserde_json.workspace = true\nsnafu.workspace = true\nwalkdir.workspace = true\n"
  },
  {
    "path": "ftl/README.md",
    "content": "Files related to Anki's translations.\n\nPlease see https://translating.ankiweb.net/anki/developers\n"
  },
  {
    "path": "ftl/copy-core-string.sh",
    "content": "#!/bin/bash\n# - sync ftl\n# - ./copy-core-string.sh scheduling-review browsing-sidebar-card-state-review\n# - confirm changes in core-repo/ correct\n# - commit and push changes\n# - ensure string in template isn't in the 'no need to translate' section\n# - update submodule in main repo\n./ftl string copy ftl/core-repo/core ftl/core-repo/core $1 $2\n"
  },
  {
    "path": "ftl/core/actions.ftl",
    "content": "actions-add = Add\n# Action in context menu:\n# In the browser sidebar, when in \"Select\" mode, right-click on the\n# selected criteria elements. In the context menu, click on \"Search\" to open\n# a submenu. This entry in the submenu creates a search term that matches\n# cards/notes meeting ALL of the selected criteria.\n# https://github.com/ankitects/anki/pull/1044\nactions-all-selected = All selected\n# Action in context menu:\n# In the browser sidebar, when in \"Select\" mode, right-click on the\n# selected criteria elements. In the context menu, click on \"Search\" to open\n# a submenu. This entry in the submenu creates a search term that matches\n# cards/notes meeting ANY of the selected criteria.\n# https://github.com/ankitects/anki/pull/1044\nactions-any-selected = Any selected\nactions-cancel = Cancel\nactions-choose = Choose\nactions-close = Close\nactions-discard = Discard\nactions-copy = Copy\nactions-create-copy = Create Copy\nactions-custom-study = Custom Study\nactions-decks = Decks\nactions-decrement-value = Decrement value\nactions-delete = Delete\nactions-export = Export\nactions-empty-cards = Empty Cards\nactions-filter = Filter\nactions-help = Help\nactions-increment-value = Increment value\nactions-import = Import\nactions-manage = Manage...\nactions-name = Name:\nactions-new = New\nactions-new-name = New name:\nactions-options = Options\nactions-options-for = Options for { $val }\nactions-preview = Preview\nactions-rebuild = Rebuild\nactions-rename = Rename\nactions-rename-deck = Rename Deck\nactions-rename-tag = Rename Tag\nactions-rename-with-parents = Rename with Parents\nactions-remove-tag = Remove Tag\nactions-replay-audio = Replay Audio\nactions-reposition = Reposition\nactions-save = Save\nactions-search = Search\nactions-select = Select\nactions-shortcut-key = Shortcut key: { $val }\nactions-suspend-card = Suspend Card\nactions-set-due-date = Set Due Date\nactions-toggle-load-balancer = Toggle Load Balancer\nactions-grade-now = Grade Now\nactions-answer-card = Answer Card\nactions-unbury-unsuspend = Unbury/Unsuspend\nactions-add-deck = Add Deck\nactions-add-note = Add Note\nactions-update-tag = Update Tag\nactions-update-note = Update Note\nactions-update-card = Update Card\nactions-update-deck = Update Deck\nactions-forget-card = Reset Card\nactions-build-filtered-deck = Build Deck\nactions-add-notetype = Add Note Type\nactions-remove-notetype = Remove Note Type\nactions-update-notetype = Update Note Type\nactions-update-config = Update Config\nactions-card-info = Card Info\nactions-previous-card-info = Previous Card Info\n# By convention, the name of a menu action is suffixed with \"...\" if additional\n# input is required before it can be performed. E.g. \"Export...\" vs. \"Delete\".\nactions-with-ellipsis = { $action }...\nactions-fullscreen-unsupported = Full screen mode is not supported for your video driver. Try switching to a different one from the preferences screen.\nactions-flag-number = Flag { $number }\n\n## The same translation may used for two independent actions:\n## searching for cards with a flag of the specified color, and\n## toggling the flag of the specified color on a card.\n\nactions-flag-red = Red\nactions-flag-orange = Orange\nactions-flag-green = Green\nactions-flag-blue = Blue\nactions-flag-pink = Pink\nactions-flag-turquoise = Turquoise\nactions-flag-purple = Purple\n\n##\n\nactions-set-flag = Set Flag\nactions-nothing-to-undo = Nothing to undo\nactions-nothing-to-redo = Nothing to redo\nactions-auto-advance = Auto Advance\nactions-auto-advance-activated = Auto Advance enabled\nactions-auto-advance-deactivated = Auto Advance disabled\nactions-processing = Processing...\n"
  },
  {
    "path": "ftl/core/adding.ftl",
    "content": "adding-add-shortcut-ctrlandenter = Add (shortcut: ctrl+enter)\nadding-added = Added\nadding-discard-current-input = Discard current input?\nadding-keep-editing = Keep Editing\nadding-edit = Edit \"{ $val }\"\nadding-history = History\nadding-note-deleted = (Note deleted)\nadding-shortcut = Shortcut: { $val }\nadding-the-first-field-is-empty = The first field is empty.\nadding-you-have-a-cloze-deletion-note = You have a cloze note type but have not made any cloze deletions. Proceed?\nadding-cloze-outside-cloze-notetype = Cloze deletion can only be used on cloze note types.\nadding-cloze-outside-cloze-field = Cloze deletion can only be used in fields which use the 'cloze:' filter. This is typically the first field.\n"
  },
  {
    "path": "ftl/core/browsing.ftl",
    "content": "browsing-add-notes = Add Notes...\nbrowsing-add-tags2 = Add Tags...\nbrowsing-add-to-selected-notes = Add to Selected Notes\nbrowsing-remove-from-selected-notes = Remove from Selected Notes\nbrowsing-addon = Add-on\nbrowsing-all-fields = All Fields\nbrowsing-answer = Answer\nbrowsing-any-flag = Any Flag\nbrowsing-average-ease = Avg. Ease\nbrowsing-average-interval = Avg. Interval\nbrowsing-browser-appearance = Browser Appearance\nbrowsing-browser-options = Browser Options\nbrowsing-buried = Buried\nbrowsing-card = Card\nbrowsing-cards = Cards\nbrowsing-card-list = Card List\nbrowsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck.\nbrowsing-cards-deleted =\n    { $count ->\n        [one] { $count } card deleted.\n       *[other] { $count } cards deleted.\n    }\nbrowsing-cards-deleted-with-deckname =\n    { $count ->\n        [one] { $count } card deleted from {$deck_name}.\n       *[other] { $count } cards deleted from {$deck_name}.\n    }\nbrowsing-change-deck = Change Deck\nbrowsing-change-deck2 = Change Deck...\nbrowsing-change-note-type = Change Note Type\n# Action in a context menu (right mouse-click on a card type)\nbrowsing-change-note-type2 = Change Note Type...\nbrowsing-change-notetype = Change Note Type\nbrowsing-clear-unused-tags = Clear Unused Tags\nbrowsing-confirm-saved-search-overwrite = A saved search with the name { $name } already exists. Do you want to overwrite it?\nbrowsing-created = Created\nbrowsing-current-deck = Current Deck\nbrowsing-current-note-type = Current note type:\nbrowsing-delete-notes = Delete Notes\nbrowsing-duplicate = duplicate\nbrowsing-ease = Ease\nbrowsing-enter-tags-to-add = Enter tags to add:\nbrowsing-enter-tags-to-delete = Enter tags to delete:\nbrowsing-filtered = (filtered)\nbrowsing-find = <b>Find</b>:\nbrowsing-find-and-replace = Find and Replace\nbrowsing-find-duplicates = Find Duplicates\nbrowsing-first-card = First Card\nbrowsing-flag = Flag\nbrowsing-font = <b>Font</b>:\nbrowsing-font-size = <b>Font Size</b>:\nbrowsing-found-as-across-bs = Found { $part } across { $whole }.\nbrowsing-ignore-case = Ignore case\nbrowsing-in = <b>In</b>:\nbrowsing-interval = Interval\nbrowsing-last-card = Last Card\nbrowsing-learning = (learning)\nbrowsing-line-size = <b>Line Size</b>:\nbrowsing-manage-note-types = Manage Note Types\nbrowsing-move-cards = Move Cards\nbrowsing-move-cards-to-deck = Move cards to deck:\nbrowsing-new = (new)\nbrowsing-new-note-type = New note type:\nbrowsing-no-flag = No Flag\nbrowsing-no-selection = No cards or notes selected.\nbrowsing-note = Note\nbrowsing-notes = Notes\nbrowsing-optional-filter = Optional filter:\nbrowsing-override-back-template = Override back template:\nbrowsing-override-font = Override font:\nbrowsing-override-front-template = Override front template:\nbrowsing-please-give-your-filter-a-name = Please give your filter a name:\nbrowsing-preview-selected-card = Preview Selected Card ({ $val })\nbrowsing-question = Question\nbrowsing-queue-bottom = Queue bottom: { $val }\nbrowsing-queue-top = Queue top: { $val }\nbrowsing-randomize-order = Randomize order\nbrowsing-remove-tags = Remove Tags...\nbrowsing-replace-with = <b>Replace With</b>:\nbrowsing-reposition = Reposition...\nbrowsing-reposition-new-cards = Reposition New Cards\nbrowsing-reschedule = Reschedule\nbrowsing-search-bar-hint = Search cards/notes (type text, then press Enter)\nbrowsing-search-in = Search in:\nbrowsing-search-within-formatting-slow = Search within formatting (slow)\nbrowsing-select-deck = Select Deck\nbrowsing-selected-notes-only = Selected notes only\nbrowsing-shift-position-of-existing-cards = Shift position of existing cards\nbrowsing-sidebar = Sidebar\nbrowsing-sidebar-filter = Sidebar filter\n# The field that is used for sorting (sort is an adjective here, not a verb)\nbrowsing-sort-field = Sort Field\nbrowsing-sorting-on-this-column-is-not = Sorting on this column is not supported. Please choose another.\nbrowsing-start-position = Start position:\nbrowsing-step = Step:\nbrowsing-suspended = Suspended\nbrowsing-tag-duplicates = Tag Duplicates\nbrowsing-tag-rename-warning-empty = You can't rename a tag that has no notes.\nbrowsing-target-field = Target field:\nbrowsing-toggle-bury = Toggle Bury\nbrowsing-toggle-showing-cards-notes = Toggle Cards/Notes\nbrowsing-toggle-mark = Toggle Mark\nbrowsing-toggle-suspend = Toggle Suspend\nbrowsing-treat-input-as-regular-expression = Treat input as regular expression\nbrowsing-update-saved-search = Update with Current Search\nbrowsing-whole-collection = Whole Collection\nbrowsing-window-title-notes = Browse ({ $selected } of { $total } notes selected)\nbrowsing-you-must-have-at-least-one = You must have at least one column.\nbrowsing-group =\n    { $count ->\n        [one] { $count } group\n       *[other] { $count } groups\n    }\nbrowsing-note-count =\n    { $count ->\n        [one] { $count } note\n       *[other] { $count } notes\n    }\nbrowsing-notes-updated =\n    { $count ->\n        [one] { $count } note updated.\n       *[other] { $count } notes updated.\n    }\nbrowsing-cards-updated =\n    { $count ->\n        [one] { $count } card updated.\n       *[other] { $count } cards updated.\n    }\nbrowsing-window-title = Browse ({ $selected } of { $total } cards selected)\nbrowsing-sidebar-expand = Expand\nbrowsing-sidebar-collapse = Collapse\nbrowsing-sidebar-expand-children = Expand Children\nbrowsing-sidebar-collapse-children = Collapse Children\nbrowsing-sidebar-decks = Decks\nbrowsing-sidebar-tags = Tags\nbrowsing-sidebar-notetypes = Note Types\nbrowsing-sidebar-saved-searches = Saved Searches\nbrowsing-sidebar-save-current-search = Save Current Search\nbrowsing-sidebar-card-state = Card State\nbrowsing-sidebar-flags = Flags\nbrowsing-today = Today\nbrowsing-tooltip-card-modified = The last time changes were made to a card, including reviews, flags and deck changes\nbrowsing-tooltip-note-modified = The last time changes were made to a note, usually field content or tag edits\nbrowsing-tooltip-card = The name of a card's card template\nbrowsing-tooltip-cards = The number of cards a note has\nbrowsing-tooltip-notetype = The name of a note's note type\nbrowsing-tooltip-question = The front side of a card, customisable in the card template editor\nbrowsing-tooltip-answer = The back side of a card, customisable in the card template editor\nbrowsing-studied-today = Studied\nbrowsing-added-today = Added\nbrowsing-again-today = Again\nbrowsing-edited-today = Edited\nbrowsing-sidebar-first-review = First Review\nbrowsing-sidebar-rescheduled = Rescheduled\nbrowsing-sidebar-due-today = Due\nbrowsing-sidebar-untagged = Untagged\nbrowsing-sidebar-overdue = Overdue\nbrowsing-row-deleted = (deleted)\nbrowsing-removed-unused-tags-count =\n    { $count ->\n        [one] Removed { $count } unused tag.\n       *[other] Removed { $count } unused tags.\n    }\nbrowsing-changed-new-position =\n    { $count ->\n        [one] Changed position of { $count } new card.\n       *[other] Changed position of { $count } new cards.\n    }\nbrowsing-reparented-decks =\n    { $count ->\n        [one] Renamed { $count } deck.\n       *[other] Renamed { $count } decks.\n    }\nbrowsing-sidebar-card-state-review = Review\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\n# Exactly one character representing 'Cards'; should differ from browsing-note-initial.\nbrowsing-card-initial = C\n# Exactly one character representing 'Notes'; should differ from browsing-card-initial.\nbrowsing-note-initial = N\n"
  },
  {
    "path": "ftl/core/card-stats.ftl",
    "content": "card-stats-added = Added\ncard-stats-first-review = First Review\ncard-stats-latest-review = Latest Review\ncard-stats-interval = Interval\ncard-stats-ease = Ease\ncard-stats-review-count = Reviews\ncard-stats-lapse-count = Lapses\ncard-stats-average-time = Average Time\ncard-stats-total-time = Total Time\ncard-stats-new-card-position = Position\ncard-stats-card-template = Card Type\ncard-stats-note-type = Note Type\ncard-stats-deck-name = Deck\ncard-stats-preset = Preset\ncard-stats-note-id = Note ID\ncard-stats-card-id = Card ID\ncard-stats-review-log-rating = Rating\ncard-stats-review-log-type = Type\ncard-stats-review-log-date = Date\ncard-stats-review-log-time-taken = Time\ncard-stats-review-log-type-learn = Learn\ncard-stats-review-log-type-review = Review\ncard-stats-review-log-type-relearn = Relearn\ncard-stats-review-log-type-filtered = Filtered\ncard-stats-review-log-type-manual = Manual\ncard-stats-review-log-type-rescheduled = Rescheduled\ncard-stats-review-log-elapsed-time = Elapsed Time\ncard-stats-no-card = (No card to display.)\ncard-stats-custom-data = Custom Data\ncard-stats-fsrs-stability = Stability\ncard-stats-fsrs-difficulty = Difficulty\ncard-stats-fsrs-retrievability = Retrievability\ncard-stats-fsrs-forgetting-curve-title = Forgetting Curve\ncard-stats-fsrs-forgetting-curve-first-week = First Week\ncard-stats-fsrs-forgetting-curve-first-month = First Month\ncard-stats-fsrs-forgetting-curve-first-year = First Year\ncard-stats-fsrs-forgetting-curve-all-time = All Time\ncard-stats-fsrs-forgetting-curve-desired-retention = Desired Retention\n\n## Window Titles\n\ncard-stats-current-card = Current Card ({ $context })\ncard-stats-previous-card = Previous Card ({ $context })\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\ncard-stats-fsrs-forgetting-curve-probability-of-recalling = Probability of Recall\n"
  },
  {
    "path": "ftl/core/card-template-rendering.ftl",
    "content": "### These messages are shown on the review screen, preview screen, and\n### card template screen when the user has made a mistake in their card\n### template, or the front of the card is empty.\n\n# Label of link users can click on\ncard-template-rendering-more-info = More information\ncard-template-rendering-front-side-problem = Front template has a problem:\ncard-template-rendering-back-side-problem = Back template has a problem:\ncard-template-rendering-browser-front-side-problem = Browser-specific front template has a problem:\ncard-template-rendering-browser-back-side-problem = Browser-specific back template has a problem:\n# when the user forgot to close a field reference,\n# eg, Missing '}}' in '{{Field'\ncard-template-rendering-no-closing-brackets = Missing '{ $missing }' in '{ $tag }'\n# when the user opened a conditional, but forgot to close it\n# eg, Missing '{{/Conditional}}'\ncard-template-rendering-conditional-not-closed = Missing '{ $missing }'\n# when the user closed the wrong conditional\n# eg, Found '{{/Something}}', but expected '{{/SomethingElse}}'\ncard-template-rendering-wrong-conditional-closed = Found '{ $found }', but expected '{ $expected }'\n# when the user closed a conditional that wasn't open\n# eg, Found '{{/Something}}', but missing '{{#Something}}' or '{{^Something}}'\ncard-template-rendering-conditional-not-open = Found '{ $found }', but missing '{ $missing1 }' or '{ $missing2 }'\n# when the user referenced a field that doesn't exist\n# eg, Found '{{Field}}', but there is not field called 'Field'\ncard-template-rendering-no-such-field = Found '{ $found }', but there is no field called '{ $field }'\n# This message is shown when the front side of the card is blank,\n# either due to a badly-designed template, or because required fields\n# are missing.\ncard-template-rendering-empty-front = The front of this card is blank.\ncard-template-rendering-missing-cloze =\n    No cloze { $number } found on card.\n    Please either add a cloze deletion, or use the Empty Cards tool.\n"
  },
  {
    "path": "ftl/core/card-templates.ftl",
    "content": "# This word is used by TTS voices instead of the elided part of a cloze.\ncard-templates-blank = blank\ncard-templates-changes-will-affect-notes =\n    { $count ->\n        [one] Changes below will affect the { $count } note that uses this card type.\n       *[other] Changes below will affect the { $count } notes that use this card type.\n    }\ncard-templates-card-type = Card Type:\ncard-templates-front-template = Front Template\ncard-templates-back-template = Back Template\ncard-templates-template-styling = Styling\ncard-templates-front-preview = Front Preview\ncard-templates-back-preview = Back Preview\ncard-templates-preview-box = Preview\ncard-templates-template-box = Template\ncard-templates-sample-cloze = This is a { \"{{c1::\" }sample{ \"}}\" } cloze deletion.\ncard-templates-fill-empty = Fill Empty Fields\ncard-templates-night-mode = Night Mode\n# Add \"mobile\" class to card preview, so the card appears like it would\n# on a mobile device.\ncard-templates-add-mobile-class = Add Mobile Class\ncard-templates-preview-settings = Options\ncard-templates-invalid-template-number = Card template { $number } in note type '{ $notetype }' has a problem.\ncard-templates-identical-front = The front side is identical to card template { $number }.\ncard-templates-no-front-field = Expected to find a field replacement on the front of the card template.\ncard-templates-missing-cloze = Expected to find '{ \"{{\" }cloze:Text{ \"}}\" }' or similar on the front and back of the card template.\ncard-templates-extraneous-cloze = 'cloze:' can only be used on cloze note types.\ncard-templates-see-preview = See the preview for more information.\ncard-templates-field-not-found = Field '{ $field }' not found.\ncard-templates-changes-saved = Changes saved.\ncard-templates-discard-changes = Discard changes?\ncard-templates-add-card-type = Add Card Type...\ncard-templates-anki-couldnt-find-the-line-between = Anki couldn't find the line between the question and answer. Please adjust the template manually to switch the question and answer.\ncard-templates-at-least-one-card-type-is = At least one card type is required.\ncard-templates-browser-appearance = Browser Appearance...\ncard-templates-card = Card { $val }\ncard-templates-card-types-for = Card Types for { $val }\ncard-templates-cloze = Cloze { $val }\ncard-templates-deck-override = Deck Override...\ncard-templates-copy-info = Copy Info to Clipboard\ncard-templates-delete-the-as-card-type-and = Delete the '{ $template }' card type, and its { $cards }?\ncard-templates-enter-deck-to-place-new = Enter deck to place new { $val } cards in, or leave blank:\ncard-templates-enter-new-card-position-1 = Enter new card position (1...{ $val }):\ncard-templates-flip = Flip\ncard-templates-form = Form\ncard-templates-off = (off)\ncard-templates-on = (on)\ncard-templates-remove-card-type = Remove Card Type...\ncard-templates-rename-card-type = Rename Card Type...\ncard-templates-reposition-card-type = Reposition Card Type...\ncard-templates-card-count =\n    { $count ->\n        [one] { $count } card\n       *[other] { $count } cards\n    }\ncard-templates-this-will-create-card-proceed =\n    { $count ->\n        [one] This will create { $count } card. Proceed?\n       *[other] This will create { $count } cards. Proceed?\n    }\ncard-templates-type-boxes-warning = Only one typing box per card template is supported.\ncard-templates-restore-to-default = Restore to Default\ncard-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed?\ncard-templates-restored-to-default = Note type has been restored to its original state.\n\n"
  },
  {
    "path": "ftl/core/change-notetype.ftl",
    "content": "change-notetype-current = Current\nchange-notetype-new = New\nchange-notetype-nothing = (Nothing)\nchange-notetype-collapse = Collapse\nchange-notetype-expand = Expand\nchange-notetype-will-discard-content = Will discard content on the following fields:\nchange-notetype-will-discard-cards = Will remove the following cards:\nchange-notetype-fields = Fields\nchange-notetype-templates = Templates\nchange-notetype-to-from-cloze =\n    When changing to or from a Cloze note type, card numbers remain unchanged.\n    \n    If changing to a regular note type, and there are more cloze deletions\n    than available card templates, any extra cards will be removed.\n"
  },
  {
    "path": "ftl/core/custom-study.ftl",
    "content": "### options related to the Custom Study window\ncustom-study-increase-todays-new-card-limit = Increase today's new card limit\n# increase limit by {amount} cards\ncustom-study-increase-todays-new-card-limit-by = Increase today's new card limit by\n# the last word in the sentence \"increase today's [new/review] card limit by {amount} cards\"\ncustom-study-cards = \n    { $count ->\n        [one] card\n       *[other] cards\n    }\ncustom-study-available-new-cards-2 = Available new cards: { $countString }\ncustom-study-increase-todays-review-card-limit = Increase today's review card limit\n# increase limit by {amount} cards\ncustom-study-increase-todays-review-limit-by = Increase today's review limit by\ncustom-study-available-review-cards-2 = Available review cards: { $countString }\ncustom-study-review-forgotten-cards = Review forgotten cards\ncustom-study-review-cards-forgotten-in-last = Review cards forgotten in the last\ncustom-study-days =\n    { $count ->\n        [one] day\n       *[other] days\n    }\ncustom-study-review-ahead = Review ahead\ncustom-study-review-ahead-by = Review ahead by\ncustom-study-preview-new-cards = Preview new cards\ncustom-study-preview-new-cards-added-in-the = Preview new cards added in the last\n\n## options for the \"study by card state or tag\" subsection\ncustom-study-study-by-card-state-or-tag = Study by card state or tag\n# verb, not noun. As in \"Select {amount} cards from the deck\"\ncustom-study-select = Select\n# As in \"select {amount} cards from the deck\"\ncustom-study-cards-from-the-deck = \n    { $count ->\n        [one] card from the deck\n       *[other] cards from the deck\n    }\ncustom-study-new-cards-only = New cards only\ncustom-study-due-cards-only = Due cards only\ncustom-study-all-review-cards-in-random-order = All review cards in random order\ncustom-study-all-cards-in-random-order-dont = All cards in random order (don't reschedule)\ncustom-study-choose-tags = Choose Tags\n\n##\ncustom-study-ok = OK\ncustom-study-no-cards-matched-the-criteria-you = No cards matched the criteria you provided.\ncustom-study-must-rename-deck = Please rename the existing Custom Study deck first.\ncustom-study-custom-study-session = Custom Study Session\ncustom-study-available-child-count = ({ $count } in subdecks)\n\n## inside the Selective Study window, accessible by selecting \"Study by card state or tag\" and then clicking \"Choose Tags\"\ncustom-study-selective-study = Selective Study\ncustom-study-require-one-or-more-of-these = Require one or more of these tags:\ncustom-study-select-tags-to-exclude = Select tags to exclude:\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\ncustom-study-available-new-cards = Available new cards: { $count }\ncustom-study-available-review-cards = Available review cards: { $count }\n"
  },
  {
    "path": "ftl/core/database-check.ftl",
    "content": "database-check-corrupt = Collection file is corrupt. Please restore from an automatic backup.\ndatabase-check-rebuilt = Database rebuilt and optimized.\ndatabase-check-card-properties =\n    { $count ->\n        [one] Fixed { $count } invalid card property.\n       *[other] Fixed { $count } invalid card properties.\n    }\ndatabase-check-card-last-review-time-empty =\n    { $count ->\n        [one] Added last review time to { $count } card.\n       *[other] Added last review time to { $count } cards.\n    }\ndatabase-check-missing-templates =\n    { $count ->\n        [one] Deleted { $count } card with missing template.\n       *[other] Deleted { $count } cards with missing template.\n    }\ndatabase-check-field-count =\n    { $count ->\n        [one] Fixed { $count } note with wrong field count.\n       *[other] Fixed { $count } notes with wrong field count.\n    }\ndatabase-check-new-card-high-due =\n    { $count ->\n        [one] Found { $count } new card with a due number >= 1,000,000 - consider repositioning it in the Browse screen.\n       *[other] Found { $count } new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen.\n    }\ndatabase-check-card-missing-note =\n    { $count ->\n        [one] Deleted { $count } card with missing note.\n       *[other] Deleted { $count } cards with missing note.\n    }\ndatabase-check-duplicate-card-ords =\n    { $count ->\n        [one] Deleted { $count } card with duplicate template.\n       *[other] Deleted { $count } cards with duplicate template.\n    }\ndatabase-check-missing-decks =\n    { $count ->\n        [one] Fixed { $count } missing deck.\n       *[other] Fixed { $count } missing decks.\n    }\ndatabase-check-revlog-properties =\n    { $count ->\n        [one] Fixed { $count } review entry with invalid properties.\n       *[other] Fixed { $count } review entries with invalid properties.\n    }\ndatabase-check-notes-with-invalid-utf8 =\n    { $count ->\n        [one] Fixed { $count } note with invalid utf8 characters.\n       *[other] Fixed { $count } notes with invalid utf8 characters.\n    }\ndatabase-check-fixed-invalid-ids =\n    { $count ->\n        [one] Fixed { $count } object with timestamps in the future.\n       *[other] Fixed { $count } objects with timestamps in the future.\n    }\n# \"db-check\" is always in English\ndatabase-check-notetypes-recovered = One or more note types were missing. The notes that used them have been given new note types starting with \"db-check\", but field names and card design have been lost, so you may be better off restoring from an automatic backup.\n\n## Progress info\n\ndatabase-check-checking-integrity = Checking collection...\ndatabase-check-rebuilding = Rebuilding...\ndatabase-check-checking-cards = Checking cards...\ndatabase-check-checking-notes = Checking notes...\ndatabase-check-checking-history = Checking history...\ndatabase-check-title = Check Database\n"
  },
  {
    "path": "ftl/core/deck-config.ftl",
    "content": "### Text shown on the \"Deck Options\" screen\n\n## Top section\n\n# Used in the deck configuration screen to show how many decks are used\n# by a particular configuration group, eg \"Group1 (used by 3 decks)\"\ndeck-config-used-by-decks =\n    used by { $decks ->\n        [one] { $decks } deck\n       *[other] { $decks } decks\n    }\ndeck-config-default-name = Default\ndeck-config-title = Deck Options\n\n## Daily limits section\n\ndeck-config-daily-limits = Daily Limits\ndeck-config-new-limit-tooltip =\n    The maximum number of new cards to introduce in a day, if new cards are available.\n    Because new material will increase your short-term review workload, this should typically\n    be at least 10x smaller than your review limit.\ndeck-config-review-limit-tooltip =\n    The maximum number of review cards to show in a day,\n    if cards are ready for review.\ndeck-config-limit-deck-v3 =\n    When studying a deck that has subdecks inside it, the limits set on each\n    subdeck control the maximum number of cards gathered from that particular deck.\n    The selected deck's limits control the total cards that will be shown.\ndeck-config-limit-new-bound-by-reviews =\n    The review limit affects the new limit. For example, if your review limit is\n    set to 200, and you have 190 reviews waiting, a maximum of 10 new cards will\n    be introduced. If your review limit has been reached, no new cards will be\n    shown.\ndeck-config-limit-interday-bound-by-reviews =\n    The review limit also affects interday learning cards. When applying the limit,\n    interday learning cards are gathered first, then review cards.\ndeck-config-tab-description =\n    - `Preset`: The limit applies to all decks using this preset.\n    - `This deck`: The limit is specific to this deck.\n    - `Today only`: Make a temporary change to this deck's limit.\ndeck-config-new-cards-ignore-review-limit = New cards ignore review limit\ndeck-config-new-cards-ignore-review-limit-tooltip =\n    By default, the review limit also applies to new cards, and no new cards will be\n    shown when the review limit has been reached. If this option is enabled, new cards\n    will be shown regardless of the review limit.\ndeck-config-apply-all-parent-limits = Limits start from top\ndeck-config-apply-all-parent-limits-tooltip =\n    By default, the daily limits of a higher-level deck do not apply if you're studying from its subdeck.\n    If this option is enabled, the limits will\n    start from the top-level deck instead, which can be useful if you wish to study individual\n    subdecks, while enforcing a total limit on cards for the deck tree.\ndeck-config-affects-entire-collection = Affects the entire collection.\n\n## Daily limit tabs: please try to keep these as short as the English version,\n## as longer text will not fit on small screens.\n\ndeck-config-shared-preset = Preset\ndeck-config-deck-only = This deck\ndeck-config-today-only = Today only\n\n## New Cards section\n\ndeck-config-learning-steps = Learning steps\n# Please don't translate `1m`, `2d`\n-deck-config-delay-hint = Delays are typically minutes (e.g. `1m`) or days (e.g. `2d`), but hours (e.g. `1h`) and seconds (e.g. `30s`) are also supported.\ndeck-config-learning-steps-tooltip =\n    One or more delays, separated by spaces. The first delay will be used\n    when you press the `Again` button on a new card, and is 1 minute by default.\n    The `Good` button will advance to the next step, which is 10 minutes by default.\n    Once all steps have been passed, the card will become a review card, and\n    will appear on a different day. { -deck-config-delay-hint }\ndeck-config-graduating-interval-tooltip =\n    The number of days to wait before showing a card again, after the `Good` button\n    is pressed on the final learning step.\ndeck-config-easy-interval-tooltip =\n    The number of days to wait before showing a card again, after the `Easy` button\n    is used to immediately remove a card from learning.\ndeck-config-new-insertion-order = Insertion order\ndeck-config-new-insertion-order-tooltip =\n    Controls the position (due #) new cards are assigned when you add new cards.\n    Cards with a lower due number will be shown first when studying. Changing\n    this option will automatically update the existing position of new cards.\ndeck-config-new-insertion-order-sequential = Sequential (oldest cards first)\ndeck-config-new-insertion-order-random = Random\ndeck-config-new-insertion-order-random-with-v3 =\n    With the v3 scheduler, it is better to leave this set to sequential, and\n    adjust the new card gather order instead.\n\n## Lapses section\n\ndeck-config-relearning-steps = Relearning steps\ndeck-config-relearning-steps-tooltip =\n    Zero or more delays, separated by spaces. By default, pressing the `Again`\n    button on a review card will show it again 10 minutes later. If no delays\n    are provided, the card will have its interval changed, without entering\n    relearning. { -deck-config-delay-hint }\ndeck-config-leech-threshold-tooltip =\n    The number of times `Again` needs to be pressed on a review card before it is\n    marked as a leech. Leeches are cards that consume a lot of your time, and\n    when a card is marked as a leech, it's a good idea to rewrite it, delete it, or\n    think of a mnemonic to help you remember it.\n# See actions-suspend-card and scheduling-tag-only for the wording\ndeck-config-leech-action-tooltip =\n    `Tag Only`: Add a 'leech' tag to the note, and display a pop-up.\n    \n    `Suspend Card`: In addition to tagging the note, hide the card until it is\n    manually unsuspended.\n\n## Burying section\n\ndeck-config-bury-title = Burying\ndeck-config-bury-new-siblings = Bury new siblings\ndeck-config-bury-review-siblings = Bury review siblings\ndeck-config-bury-interday-learning-siblings = Bury interday learning siblings\ndeck-config-bury-new-tooltip =\n    Whether other `new` cards of the same note (e.g. reverse cards, adjacent cloze deletions)\n    will be delayed until the next day.\ndeck-config-bury-review-tooltip = Whether other `review` cards of the same note will be delayed until the next day.\ndeck-config-bury-interday-learning-tooltip =\n    Whether other `learning` cards of the same note with intervals > 1 day\n    will be delayed until the next day.\ndeck-config-bury-priority-tooltip =\n    When Anki gathers cards, it first gathers intraday learning cards, then\n    interday learning cards, then review cards, and finally new cards. This affects\n    how burying works:\n    \n    - If you have all burying options enabled, the sibling that comes earliest in\n    that list will be shown. For example, a review card will be shown in preference\n    to a new card.\n    - Siblings later in the list can not bury earlier card types. For example, if you\n    disable burying of new cards, and study a new card, it will not bury any interday\n    learning or review cards, and you may see both a review sibling and new sibling in the\n    same session.\n\n## Gather order and sort order of cards\n\ndeck-config-ordering-title = Display Order\ndeck-config-new-gather-priority = New card gather order\ndeck-config-new-gather-priority-tooltip-2 =\n    `Deck`: Gathers cards from each subdeck in order, starting from the top. Cards from each subdeck are\n    gathered in ascending position. If the daily limit of the selected deck is reached, gathering\n    can stop before all subdecks have been checked. This order is fastest in large collections, and\n    allows you to prioritize subdecks that are closer to the top.\n    \n    `Ascending position`: Gathers cards by ascending position (due #), which is typically\n    the oldest-added first.\n    \n    `Descending position`: Gathers cards by descending position (due #), which is typically\n    the latest-added first.\n    \n    `Random notes`: Picks notes at random, then gathers all of its cards.\n    \n    `Random cards`: Gathers cards in a random order.\ndeck-config-new-card-sort-order = New card sort order\ndeck-config-new-card-sort-order-tooltip-2 =\n    `Card type, then order gathered`: Shows cards in order of card type number.\n    Cards of each card type number are shown in the order they were gathered. \n    If you have sibling burying disabled, this will ensure all front→back cards are seen before any back→front cards.\n    This is useful to have all cards of the same note shown in the same session, but not\n    too close to one another.\n    \n    `Order gathered`: Shows cards exactly as they were gathered. If sibling burying is disabled,\n    this will typically result in all cards of a note being seen one after the other.\n    \n    `Card type, then random`: Shows cards in order of card type number. Cards of each card\n    type number are shown in a random order. This order is useful if you don't want sibling cards\n    to appear too close to each other, but still want the cards to appear in a random order.\n    \n    `Random note, then card type`: Picks notes at random, then shows all of its cards\n    in order.\n    \n    `Random`: Shows cards in a random order.\ndeck-config-new-review-priority = New/review order\ndeck-config-new-review-priority-tooltip = When to show new cards in relation to review cards.\ndeck-config-interday-step-priority = Interday learning/review order\ndeck-config-interday-step-priority-tooltip =\n    When to show (re)learning cards that cross a day boundary.\n    \n    The review limit is always applied first to interday learning cards, and\n    then review cards. This option will control the order the gathered cards are shown in,\n    but interday learning cards will always be gathered first.\ndeck-config-review-sort-order = Review sort order\ndeck-config-review-sort-order-tooltip =\n    The default order prioritizes cards that have been waiting longest, so that\n    if you have a backlog of reviews, the longest-waiting ones will appear\n    first. If you have a large backlog that will take more than a few days to\n    clear, or wish to see cards in subdeck order, you may find the alternate\n    sort orders preferable.\n\ndeck-config-display-order-will-use-current-deck =\n    Anki will use the display order from the deck you \n    select to study, and not any subdecks it may have.\n\n## Gather order and sort order of cards – Combobox entries\n\n# Gather new cards ordered by deck.\ndeck-config-new-gather-priority-deck = Deck\n# Gather new cards ordered by deck, then ordered by random notes, ensuring all cards of the same note are grouped together.\ndeck-config-new-gather-priority-deck-then-random-notes = Deck, then random notes\n# Gather new cards ordered by position number, ascending (lowest to highest).\ndeck-config-new-gather-priority-position-lowest-first = Ascending position\n# Gather new cards ordered by position number, descending (highest to lowest).\ndeck-config-new-gather-priority-position-highest-first = Descending position\n# Gather the cards ordered by random notes, ensuring all cards of the same note are grouped together.\ndeck-config-new-gather-priority-random-notes = Random notes\n# Gather new cards randomly.\ndeck-config-new-gather-priority-random-cards = Random cards\n# Sort the cards first by their type, in ascending order (alphabetically), then randomized within each type.\ndeck-config-sort-order-card-template-then-random = Card type, then random\n# Sort the notes first randomly, then the cards by their type, in ascending order (alphabetically), within each note.\ndeck-config-sort-order-random-note-then-template = Random note, then card type\n# Sort the cards randomly.\ndeck-config-sort-order-random = Random\n# Sort the cards first by their type, in ascending order (alphabetically), then by the order they were gathered, in ascending order (oldest to newest).\ndeck-config-sort-order-template-then-gather = Card type, then order gathered\n# Sort the cards by the order they were gathered, in ascending order (oldest to newest).\ndeck-config-sort-order-gather = Order gathered\n# How new cards or interday learning cards are mixed with review cards.\ndeck-config-review-mix-mix-with-reviews = Mix with reviews\n# How new cards or interday learning cards are mixed with review cards.\ndeck-config-review-mix-show-after-reviews = Show after reviews\n# How new cards or interday learning cards are mixed with review cards.\ndeck-config-review-mix-show-before-reviews = Show before reviews\n# Sort the cards first by due date, in ascending order (oldest due date to newest), then randomly within the same due date.\ndeck-config-sort-order-due-date-then-random = Due date, then random\n# Sort the cards first by due date, in ascending order (oldest due date to newest), then by deck within the same due date.\ndeck-config-sort-order-due-date-then-deck = Due date, then deck\n# Sort the cards first by deck, then by due date in ascending order (oldest due date to newest) within the same deck.\ndeck-config-sort-order-deck-then-due-date = Deck, then due date\n# Sort the cards by the interval, in ascending order (shortest to longest).\ndeck-config-sort-order-ascending-intervals = Ascending intervals\n# Sort the cards by the interval, in descending order (longest to shortest).\ndeck-config-sort-order-descending-intervals = Descending intervals\n# Sort the cards by ease, in ascending order (lowest to highest ease).\ndeck-config-sort-order-ascending-ease = Ascending ease\n# Sort the cards by ease, in descending order (highest to lowest ease).\ndeck-config-sort-order-descending-ease = Descending ease\n# Sort the cards by difficulty, in ascending order (easiest to hardest).\ndeck-config-sort-order-ascending-difficulty = Easy cards first\n# Sort the cards by difficulty, in descending order (hardest to easiest).\ndeck-config-sort-order-descending-difficulty = Difficult cards first\n# Sort the cards by retrievability percentage, in ascending order (0% to 100%, least retrievable to most easily retrievable).\ndeck-config-sort-order-retrievability-ascending = Ascending retrievability\n# Sort the cards by retrievability percentage, in descending order (100% to 0%, most easily retrievable to least retrievable).\ndeck-config-sort-order-retrievability-descending = Descending retrievability\n\n## Timer section\n\ndeck-config-timer-title = Timers\ndeck-config-maximum-answer-secs = Maximum answer seconds\ndeck-config-maximum-answer-secs-tooltip =\n    The maximum number of seconds to record for a single review. If an answer\n    exceeds this time (because you stepped away from the screen for example),\n    the time taken will be recorded as the limit you have set.\ndeck-config-show-answer-timer-tooltip =\n    On the Study screen, show a timer that counts the time you're\n    taking to study each card.\ndeck-config-stop-timer-on-answer = Stop on-screen timer on answer\ndeck-config-stop-timer-on-answer-tooltip =\n    Whether to stop the on-screen timer when the answer is revealed.\n    This doesn't affect statistics.\n\n## Auto Advance section\n\ndeck-config-seconds-to-show-question = Seconds to show question for\ndeck-config-seconds-to-show-question-tooltip-3 = When auto advance is activated, the number of seconds to wait before applying the question action. Set to 0 to disable.\ndeck-config-seconds-to-show-answer = Seconds to show answer for\ndeck-config-seconds-to-show-answer-tooltip-2 = When auto advance is activated, the number of seconds to wait before applying the answer action. Set to 0 to disable.\ndeck-config-question-action-show-answer = Show Answer\ndeck-config-question-action-show-reminder = Show Reminder\ndeck-config-question-action = Question action \ndeck-config-question-action-tool-tip = The action to perform after the question is shown, and time has elapsed.\ndeck-config-answer-action = Answer action\ndeck-config-answer-action-tooltip-2 = The action to perform after the answer is shown, and time has elapsed.\ndeck-config-wait-for-audio-tooltip-2 = Wait for audio to finish before automatically applying the question action or answer action.\n\n## Audio section\n\ndeck-config-audio-title = Audio\ndeck-config-disable-autoplay = Don't play audio automatically\ndeck-config-disable-autoplay-tooltip =\n    When enabled, Anki will not play audio automatically.\n    It can be played manually by clicking/tapping on an audio icon, or by using the Replay action.\ndeck-config-skip-question-when-replaying = Skip question when replaying answer\ndeck-config-always-include-question-audio-tooltip =\n    Whether the question audio should be included when the Replay action is\n    used while looking at the answer side of a card.\n## Advanced section\n\ndeck-config-advanced-title = Advanced\ndeck-config-maximum-interval-tooltip =\n    The maximum number of days a review card will wait. When reviews have\n    reached the limit, `Hard`, `Good` and `Easy` will all give the same delay.\n    The shorter you set this, the greater your workload will be.\ndeck-config-starting-ease-tooltip =\n    The ease multiplier new cards start with. By default, the `Good` button on a\n    newly-learned card will delay the next review by 2.5x the previous delay.\ndeck-config-easy-bonus-tooltip =\n    An extra multiplier that is applied to a review card's interval when you rate\n    it `Easy`.\ndeck-config-interval-modifier-tooltip =\n    This multiplier is applied to all reviews, and minor adjustments can be used\n    to make Anki more conservative or aggressive in its scheduling. Please see\n    the manual before changing this option.\ndeck-config-hard-interval-tooltip = The multiplier applied to a review interval when answering `Hard`.\ndeck-config-new-interval-tooltip = The multiplier applied to a review interval when answering `Again`.\ndeck-config-minimum-interval-tooltip = The minimum interval given to a review card after answering `Again`.\ndeck-config-custom-scheduling = Custom scheduling\ndeck-config-custom-scheduling-tooltip = Affects the entire collection. Use at your own risk!\n\n## Easy Days section.\n\ndeck-config-easy-days-title = Easy Days\ndeck-config-easy-days-monday = Mon\ndeck-config-easy-days-tuesday = Tue\ndeck-config-easy-days-wednesday = Wed\ndeck-config-easy-days-thursday = Thu\ndeck-config-easy-days-friday = Fri\ndeck-config-easy-days-saturday = Sat\ndeck-config-easy-days-sunday = Sun\ndeck-config-easy-days-normal = Normal\ndeck-config-easy-days-reduced = Reduced\ndeck-config-easy-days-minimum = Minimum\ndeck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'.\ndeck-config-easy-days-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options.\n\n## Adding/renaming\n\ndeck-config-add-group = Add Preset\ndeck-config-name-prompt = Name\ndeck-config-rename-group = Rename Preset\ndeck-config-clone-group = Clone Preset\n\n## Removing\n\ndeck-config-remove-group = Remove Preset\ndeck-config-will-require-full-sync =\n    The requested change will require a one-way sync. If you have made changes\n    on another device, and not synced them to this device yet, please do so before\n    you proceed.\ndeck-config-confirm-remove-name = Remove { $name }?\n\n## Other Buttons\n\ndeck-config-save-button = Save\ndeck-config-save-to-all-subdecks = Save to All Subdecks\ndeck-config-save-and-optimize = Optimize All Presets\ndeck-config-revert-button-tooltip = Restore this setting to its default value?\n\n## These strings are shown via the Description button at the bottom of the\n## overview screen.\n\ndeck-config-description-new-handling = Anki 2.1.41+ handling\ndeck-config-description-new-handling-hint =\n    Treats input as markdown, and cleans HTML input. When enabled, the\n    description will also be shown on the congratulations screen.\n    Markdown will appear as text on Anki 2.1.40 and below.\n\n## Warnings shown to the user\n\ndeck-config-daily-limit-will-be-capped =\n    A parent deck has a limit of { $cards ->\n        [one] { $cards } card\n       *[other] { $cards } cards\n    }, which will override this limit.\ndeck-config-reviews-too-low =\n    If adding { $cards ->\n        [one] { $cards } new card each day\n       *[other] { $cards } new cards each day\n    }, your review limit should be at least { $expected }.\ndeck-config-learning-step-above-graduating-interval = The graduating interval should be at least as long as your final learning step.\ndeck-config-good-above-easy = The easy interval should be at least as long as the graduating interval.\ndeck-config-relearning-steps-above-minimum-interval = The minimum lapse interval should be at least as long as your final relearning step.\ndeck-config-maximum-answer-secs-above-recommended = Anki can schedule your reviews more efficiently when you keep each question short.\ndeck-config-too-short-maximum-interval = A maximum interval less than 6 months is not recommended.\ndeck-config-ignore-before-info = (Approximately) { $included }/{ $totalCards } cards will be used to optimize the FSRS parameters.\n\n## Selecting a deck\n\ndeck-config-which-deck = Which deck would you like to display options for?\n\n## Messages related to the FSRS scheduler\n\ndeck-config-updating-cards = Updating cards: { $current_cards_count }/{ $total_cards_count }...\ndeck-config-invalid-parameters = The provided FSRS parameters are invalid. Leave them blank to use the default values.\ndeck-config-placeholder-parameters = \n    Default parameters\n    (Press \"{deck-config-optimize-button}\" periodically to allow FSRS to better adjust to your memory)\ndeck-config-manual-parameter-edit-warning = The parameters should only be modified using the optimize button. Manually editing them is heavily advised against.\ndeck-config-not-enough-history = Insufficient review history to perform this operation.\ndeck-config-must-have-400-reviews =\n    { $count ->\n        [one] Only { $count } review was found.\n       *[other] Only { $count } reviews were found.\n    } You must have at least 400 reviews for this operation.\n# Numbers that control how aggressively the FSRS algorithm schedules cards\ndeck-config-weights = FSRS parameters\ndeck-config-compute-optimal-weights = Optimize FSRS parameters\ndeck-config-optimize-button = Optimize Current Preset\n# Indicates that a given function or label, provided via the \"text\" variable, operates slowly.\ndeck-config-slow-suffix = { $text } (slow)\ndeck-config-compute-button = Compute\ndeck-config-ignore-before = Ignore cards reviewed before\ndeck-config-time-to-optimize = It's been a while - using the Optimize All Presets button is recommended.\ndeck-config-evaluate-button = Evaluate\ndeck-config-desired-retention = Desired retention\ndeck-config-historical-retention = Historical retention\ndeck-config-smaller-is-better = Smaller numbers indicate a better fit to your review history.\ndeck-config-steps-too-large-for-fsrs = When FSRS is enabled, steps of 1 day or more are not recommended.\ndeck-config-get-params = Get Params\ndeck-config-complete = { $num }% complete.\ndeck-config-iterations = Iteration: { $count }...\ndeck-config-reschedule-cards-on-change = Reschedule cards on change\ndeck-config-fsrs-tooltip =\n    Affects the entire collection.\n\n    The Free Spaced Repetition Scheduler (FSRS) is an alternative to Anki's legacy SuperMemo 2 (SM-2) algorithm.\n    By more accurately determining how likely you are to forget a card, it can help you remember\n    more material in the same amount of time. This setting is shared by all presets.\n\ndeck-config-desired-retention-tooltip =\n    By default, Anki schedules cards so that you have a 90% chance of remembering them when\n    they come up for review again. If you increase this value, Anki will show cards more frequently\n    to increase the chances of you remembering them. If you decrease the value, Anki will show cards\n    less frequently, and you will forget more of them. Be conservative when adjusting this - higher\n    values will greatly increase your workload, and lower values can be demoralizing when you forget\n    a lot of material.\ndeck-config-desired-retention-tooltip2 = \n    The workload values provided by the info box are a rough approximation. For a greater level of accuracy, use the simulator.\ndeck-config-historical-retention-tooltip =\n    When some of your review history is missing, FSRS needs to fill in the gaps. By default, it will\n    assume that when you did those old reviews, you remembered 90% of the material. If your old retention\n    was appreciably higher or lower than 90%, adjusting this option will allow FSRS to better approximate\n    the missing reviews.\n\n    Your review history may be incomplete for two reasons:\n    1. Because you're using the 'ignore cards reviewed before' option.\n    2. Because you previously deleted review logs to free up space, or imported material from a different\n    SRS program.\n\n    The latter is quite rare, so unless you're using the former option, you probably don't need to adjust\n    this option.\ndeck-config-weights-tooltip2 =\n    FSRS parameters affect how cards are scheduled. Anki will start with default parameters. You can use \n    the option below to optimize the parameters to best match your performance in decks using this preset.\ndeck-config-reschedule-cards-on-change-tooltip =\n    Affects the entire collection, and is not saved.\n\n    This option controls whether the due dates of cards will be changed when you enable FSRS, or optimize\n    the parameters. The default is not to reschedule cards: future reviews will use the new scheduling, but\n    there will be no immediate change to your workload. If rescheduling is enabled, the due dates of cards\n    will be changed.\ndeck-config-reschedule-cards-warning =\n    Depending on your desired retention, this can result in a large number of cards becoming\n    due, so is not recommended when first switching from SM-2.\n\n    Use this option sparingly, as it will add a review entry to each of your cards, and\n    increase the size of your collection.\ndeck-config-ignore-before-tooltip-2 = \n    If set, cards reviewed before the provided date will be ignored when optimizing FSRS parameters.\n    This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons.\ndeck-config-compute-optimal-weights-tooltip2 =\n    When you click the Optimize button, FSRS will analyze your review history, and generate parameters that are \n    optimal for your memory and the content you're studying. If your decks vary wildly in subjective difficulty, it \n    is recommended to assign them separate presets, as the parameters for easy decks and hard decks will be different. \n    You don't need to optimize your parameters frequently - once every few months is sufficient.\n    \n    By default, parameters will be calculated from the review history of all decks using the current preset. You can\n    optionally adjust the search before calculating the parameters, if you'd like to alter which cards are used for\n    optimizing the parameters.\n\ndeck-config-please-save-your-changes-first = Please save your changes first.\ndeck-config-workload-factor-change = Approximate workload: {$factor}x\n    (compared to {$previousDR}% desired retention)\ndeck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.\ndeck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals.\ndeck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals.\n\ndeck-config-percent-of-reviews =  \n    { $reviews ->\n        [one] { $pct }% of { $reviews } review\n       *[other] { $pct }% of { $reviews } reviews\n    }\ndeck-config-percent-input = { $pct }%\n# This message appears during FSRS parameter optimization.\ndeck-config-checking-for-improvement = Checking for improvement...\ndeck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }...\ndeck-config-fsrs-must-be-enabled = FSRS must be enabled first.\ndeck-config-fsrs-params-optimal = The FSRS parameters currently appear to be optimal.\n\ndeck-config-fsrs-params-no-reviews = No reviews found. Make sure this preset is assigned to all decks (including subdecks) that you want to optimize, and try again.\n\ndeck-config-wait-for-audio = Wait for audio\ndeck-config-show-reminder = Show Reminder\ndeck-config-answer-again = Answer Again\ndeck-config-answer-hard = Answer Hard\ndeck-config-answer-good = Answer Good\ndeck-config-days-to-simulate = Days to simulate\ndeck-config-desired-retention-below-optimal = Your desired retention is below optimal. Increasing it is recommended.\n# Description of the y axis in the FSRS simulation\n# diagram (Deck options -> FSRS) showing the total number of\n# cards that can be recalled or retrieved on a specific date.\ndeck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental)\ndeck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental)\ndeck-config-fsrs-simulate-save-preset = After optimizing, please save your deck preset before running the simulator.\ndeck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental)\ndeck-config-additional-new-cards-to-simulate = Additional new cards to simulate\ndeck-config-simulate = Simulate\ndeck-config-clear-last-simulate = Clear Last Simulation\ndeck-config-fsrs-simulator-radio-count = Reviews\ndeck-config-advanced-settings = Advanced Settings\ndeck-config-smooth-graph = Smooth graph\ndeck-config-suspend-leeches = Suspend leeches\ndeck-config-save-options-to-preset = Save Changes to Preset\ndeck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator?\n# Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting\n# to show the total number of cards that can be recalled or retrieved on a\n# specific date.\ndeck-config-fsrs-simulator-radio-memorized = Memorized\ndeck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio\n# $time here is pre-formatted e.g. \"10 Seconds\" \ndeck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card\n\n## Messages related to the FSRS scheduler’s health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the \"Optimize\" function.\n\n# Checkbox\ndeck-config-health-check = Check health when optimizing\n# Message box showing the result of the health check\ndeck-config-fsrs-bad-fit-warning = Health Check:\n    Your memory is difficult for FSRS to predict. Recommendations:\n\n    - Suspend or reformulate any cards you constantly forget.\n    - Use the answer buttons consistently. Keep in mind that \"Hard\" is a passing grade, not a failing grade.\n    - Understand before you memorize.\n\n    If you follow these suggestions, performance will usually improve over the next few months.\n# Message box showing the result of the health check\ndeck-config-fsrs-good-fit = Health Check:\n    FSRS can adapt to your memory well.\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\ndeck-config-unable-to-determine-desired-retention =\n    Unable to determine a minimum recommended retention.\ndeck-config-predicted-minimum-recommended-retention = Minimum recommended retention: { $num }\ndeck-config-compute-minimum-recommended-retention = Minimum recommended retention\ndeck-config-compute-optimal-retention-tooltip4 =\n    This tool will attempt to find the desired retention value \n    that will lead to the most material learnt, in the least amount of time. The calculated number can serve as a reference\n    when deciding what to set your desired retention to. You may wish to choose a higher desired retention if you’re \n    willing to invest more study time to achieve it. Setting your desired retention lower than the minimum\n    is not recommended, as it will lead to a higher workload, because of the high forgetting rate.\ndeck-config-plotted-on-x-axis = (Plotted on the X-axis)\ndeck-config-a-100-day-interval = \n    { $days ->\n        [one] A 100 day interval will become { $days } day.\n       *[other] A 100 day interval will become { $days } days.\n    }\n\ndeck-config-fsrs-simulator-y-axis-title-time = Review Time/Day\ndeck-config-fsrs-simulator-y-axis-title-count = Review Count/Day\ndeck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total\ndeck-config-bury-siblings = Bury siblings\ndeck-config-do-not-bury = Do not bury siblings\ndeck-config-bury-if-new = Bury if new\ndeck-config-bury-if-new-or-review = Bury if new or review\ndeck-config-bury-if-new-review-or-interday = Bury if new, review, or interday learning\ndeck-config-bury-tooltip =\n    Siblings are other cards from the same note (eg forward/reverse cards, or\n    other cloze deletions from the same text).\n    \n    When this option is off, multiple cards from the same note may be seen on the same\n    day. When enabled, Anki will automatically *bury* siblings, hiding them until the next\n    day. This option allows you to choose which kinds of cards may be buried when you answer\n    one of their siblings.\n    \n    When using the V3 scheduler, interday learning cards can also be buried. Interday\n    learning cards are cards with a current learning step of one or more days.\ndeck-config-seconds-to-show-question-tooltip = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable.\ndeck-config-answer-action-tooltip = The action to perform on the current card before automatically advancing to the next one.\ndeck-config-wait-for-audio-tooltip = Wait for audio to finish before automatically revealing answer or next question.\ndeck-config-ignore-before-tooltip = \n    If set, reviews before the provided date will be ignored when optimizing & evaluating FSRS parameters.\n    This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons.\ndeck-config-compute-optimal-retention-tooltip =\n    This tool assumes you're starting with 0 cards, and will attempt to calculate the amount of material you'll\n    be able to retain in the given time frame. The estimated retention will greatly depend on your inputs, and\n    if it significantly differs from 0.9, it's a sign that the time you've allocated each day is either too low\n    or too high for the amount of cards you're trying to learn. This number can be useful as a reference, but it\n    is not recommended to copy it into the desired retention field.\ndeck-config-health-check-tooltip1 = This will show a warning if FSRS struggles to adapt to your memory.\ndeck-config-health-check-tooltip2 = Health check is performed only when using Optimize Current Preset.\n\ndeck-config-compute-optimal-retention = Compute minimum recommended retention\ndeck-config-predicted-optimal-retention = Minimum recommended retention: { $num }\ndeck-config-weights-tooltip =\n    FSRS parameters affect how cards are scheduled. Anki will start with default parameters. Once\n    you've accumulated 1000+ reviews, you can use the option below to optimize the parameters to best\n    match your performance in decks using this preset.\ndeck-config-compute-optimal-weights-tooltip =\n    Once you've done 1000+ reviews in Anki, you can use the Optimize button to analyze your review history,\n    and automatically generate parameters that are optimal for your memory and the content you're studying.\n    If you have decks that vary wildly in difficulty, it is recommended to assign them separate presets, as\n    the parameters for easy decks and hard decks will be different. There is no need to optimize your parameters\n    frequently - once every few months is sufficient.\n    \n    By default, parameters will be calculated from the review history of all decks using the current preset. You can\n    optionally adjust the search before calculating the parameters, if you'd like to alter which cards are used for\n    optimizing the parameters.\ndeck-config-compute-optimal-retention-tooltip2 =\n    This tool assumes that you’re starting with 0 learned cards, and will attempt to find the desired retention\n    value that will lead to the most material learnt, in the least amount of time. This number can be used as a\n    reference when deciding what to set your desired retention to. You may wish to choose a higher desired retention,\n    if you’re willing to trade more study time for a greater recall rate. Setting your desired retention lower than\n    the minimum is not recommended, as it will lead to more work without benefit.\ndeck-config-compute-optimal-retention-tooltip3 =\n    This tool assumes that you’re starting with 0 learned cards, and will attempt to find the desired retention value \n    that will lead to the most material learnt, in the least amount of time. To accurately simulate your learning process, \n    this feature requires a minimum of 400+ reviews. The calculated number can serve as a reference when deciding what to \n    set your desired retention to. You may wish to choose a higher desired retention, if you’re willing to trade more study \n    time for a greater recall rate. Setting your desired retention lower than the minimum is not recommended, as it will \n    lead to a higher workload, because of the high forgetting rate.\ndeck-config-seconds-to-show-question-tooltip-2 = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable.\ndeck-config-invalid-weights = Parameters must be either left blank to use the defaults, or must be 17 comma-separated numbers.\ndeck-config-fsrs-on-all-clients =\n    Please ensure all of your Anki clients are Anki(Mobile) 23.10+ or AnkiDroid 2.17+. FSRS will\n    not work correctly if one of your clients is older.\ndeck-config-optimize-all-tip = You can optimize all presets at once by using the dropdown button next to \"Save\".\n"
  },
  {
    "path": "ftl/core/decks.ftl",
    "content": "## In the options window of a filtered deck\ndecks-limit-to = Limit to\ndecks-cards-selected-by = cards selected by\ndecks-reschedule-cards-based-on-my-answers = Reschedule cards based on my answers in this deck\ndecks-enable-second-filter = Enable second filter\ndecks_create_even_if_empty = Create/update this deck even if empty\n# e.g. \"Delay for Again\", \"Delay for Hard\", \"Delay for Good\"\ndecks-delay-for-button = Delay for { $button }\n# The count of cards waiting to be reviewed\ndecks-zero-minutes-hint = (0 = return card to original deck)\n# filter is a noun here\ndecks-filter = Filter:\ndecks-filter-2 = Filter 2\n\n## column names on the main \"Decks\" window \ndecks-deck = Deck\ndecks-learn-header = Learn\ndecks-review-header = Due\n\n##\ndecks-unmovable-cards = Show any excluded cards\ndecks-add-new-deck-ctrlandn = Add New Deck (Ctrl+N)\ndecks-build = Build\ndecks-create-deck = Create Deck\ndecks-custom-steps-in-minutes = Custom steps (in minutes)\ndecks-delete-deck = Delete Deck\n# a button that links to AnkiWeb for browsing shared decks\ndecks-get-shared = Get Shared\n# import deck from file\ndecks-import-file = Import File\ndecks-minutes = minutes\ndecks-new-deck-name = New deck name:\ndecks-no-deck = [no deck]\ndecks-please-select-something = Please select something.\ndecks-repeat-failed-cards-after = Delay Repeat failed cards after\ndecks-study = Study\ndecks-study-deck = Study Deck\ndecks-filtered-deck-search-empty = No cards matched the provided search. Some cards may have been excluded because they are in a different filtered deck, or suspended.\n\n## Sort order of cards\n\n# Combobox entry: Sort the cards by the date they were added, in ascending order (oldest to newest)\ndecks-order-added = Order added\n# Combobox entry: Sort the cards by the date they were added, in descending order (newest to oldest)\ndecks-latest-added-first = Latest added first\n# Combobox entry: Sort the cards by due date, in ascending order (oldest due date to newest)\ndecks-order-due = Order due\n# Combobox entry: Sort the cards by the number of lapses, in descending order (most lapses to least lapses)\ndecks-most-lapses = Most lapses\n# Combobox entry: Sort the cards by the interval, in ascending order (shortest to longest)\ndecks-increasing-intervals = Increasing intervals\n# Combobox entry: Sort the cards by the interval, in descending order (longest to shortest)\ndecks-decreasing-intervals = Decreasing intervals\n# Combobox entry: Sort the cards by the last review date, in ascending order (oldest seen to newest seen)\ndecks-oldest-seen-first = Oldest seen first\n# Combobox entry: Sort the cards in random order\ndecks-random = Random\n# Combobox entry: Sort the cards by relative overdueness, in descending order (most overdue to least overdue)\ndecks-relative-overdueness = Relative overdueness\n\n## These strings are no longer used - you do not need to translate them if they\n## are not already translated.\n"
  },
  {
    "path": "ftl/core/editing.ftl",
    "content": "editing-actual-size = Toggle actual size\nediting-add-media = Add Media\nediting-align-left = Align left\nediting-align-right = Align right\nediting-an-error-occurred-while-opening = An error occurred while opening { $val }\nediting-attach-picturesaudiovideo = Attach pictures/audio/video\nediting-bold-text = Bold text\nediting-cards = Cards\nediting-center = Center\nediting-change-color = Change color\nediting-cloze-deletion = Cloze deletion (new card)\nediting-cloze-deletion-repeat = Cloze deletion (same card)\nediting-copy-image = Copy image\nediting-couldnt-record-audio-have-you-installed = Couldn't record audio. Have you installed 'lame'?\nediting-customize-card-templates = Customize Card Templates\nediting-customize-fields = Customize Fields\nediting-cut = Cut\nediting-double-click-image = double-click image\nediting-double-click-to-expand = double-click to expand\nediting-double-click-to-collapse = double-click to collapse\nediting-edit-current = Edit Current\nediting-edit-html = Edit HTML\nediting-fields = Fields\nediting-float-left = Float left\nediting-float-right = Float right\nediting-float-none = No float\nediting-indent = Increase indent\nediting-italic-text = Italic text\nediting-jump-to-tags-with-ctrlandshiftandt = Jump to tags with Ctrl+Shift+T\nediting-justify = Justify\nediting-latex = LaTeX\nediting-latex-equation = LaTeX equation\nediting-latex-math-env = LaTeX math env.\nediting-mathjax-block = MathJax block\nediting-mathjax-chemistry = MathJax chemistry\nediting-mathjax-inline = MathJax inline\nediting-mathjax-placeholder = Press { $accept } to accept, { $newline } for new line.\nediting-media = Media\nediting-open-image = Open image\nediting-show-in-folder = Show in folder\nediting-ordered-list = Ordered list\nediting-outdent = Decrease indent\nediting-paste = Paste\nediting-record-audio = Record audio\nediting-remove-formatting = Remove formatting\nediting-restore-original-size = Restore original size\nediting-select-remove-formatting = Select formatting to remove\nediting-show-duplicates = Show Duplicates\nediting-subscript = Subscript\nediting-superscript = Superscript\nediting-tags = Tags\nediting-tags-add = Add tag\nediting-tags-copy = Copy tags\nediting-tags-remove = Remove tags\nediting-tags-select-all = Select all tags\nediting-text-color = Text color\nediting-text-highlight-color = Text highlight color\nediting-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'\nediting-toggle-html-editor = Toggle HTML Editor\nediting-toggle-visual-editor = Toggle Visual Editor\nediting-toggle-sticky = Toggle sticky\nediting-expand = Expand\nediting-collapse = Collapse\nediting-expand-field = Expand field\nediting-collapse-field = Collapse field\nediting-underline-text = Underline text\nediting-unordered-list = Unordered list\nediting-warning-cloze-deletions-will-not-work = Warning, cloze deletions will not work until you switch the type at the top to Cloze.\nediting-mathjax-preview = MathJax Preview\nediting-shrink-images = Shrink Images\nediting-close-html-tags = Auto-close HTML tags\nediting-from-clipboard = From Clipboard\nediting-alignment = Alignment\nediting-equations = Equations\nediting-no-image-found-on-clipboard = No image found on clipboard.\nediting-image-occlusion-mode = Image Occlusion Mode\nediting-image-occlusion-zoom-out = Zoom Out\nediting-image-occlusion-zoom-in = Zoom In\nediting-image-occlusion-zoom-reset = Reset Zoom\nediting-image-occlusion-toggle-translucent = Toggle Translucency\nediting-image-occlusion-delete = Delete\nediting-image-occlusion-duplicate = Duplicate\nediting-image-occlusion-group = Group Selection\nediting-image-occlusion-ungroup = Ungroup Selection\nediting-image-occlusion-select-all = Select All\nediting-image-occlusion-alignment = Alignment\nediting-image-occlusion-align-left = Align Left\nediting-image-occlusion-align-h-center = Align Horizontal Centers\nediting-image-occlusion-align-right = Align Right\nediting-image-occlusion-align-top = Align Top\nediting-image-occlusion-align-v-center = Align Vertical Centers\nediting-image-occlusion-align-bottom = Align Bottom\nediting-image-occlusion-select-tool = Select\nediting-image-occlusion-zoom-tool = Zoom\nediting-image-occlusion-rectangle-tool = Rectangle\nediting-image-occlusion-ellipse-tool = Ellipse\nediting-image-occlusion-polygon-tool = Polygon\nediting-image-occlusion-text-tool = Text\nediting-image-occlusion-fill-tool = Fill with colour\nediting-image-occlusion-toggle-mask-editor = Toggle Mask Editor\nediting-image-occlusion-reset = Reset Image Occlusion\nediting-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion?\n \n## You don't need to translate these strings, as they will be replaced with different ones soon.\n\nediting-html-editor = HTML Editor\n"
  },
  {
    "path": "ftl/core/empty-cards.ftl",
    "content": "empty-cards-for-note-type = Empty cards for { $notetype }:\nempty-cards-count-line = { $empty_count } of { $existing_count } cards empty ({ $template_names }).\nempty-cards-window-title = Empty Cards\nempty-cards-preserve-notes-checkbox = Keep notes with no valid cards\nempty-cards-delete-button = Delete\nempty-cards-not-found = No empty cards.\nempty-cards-deleted-count =\n    Deleted { $cards ->\n        [one] { $cards } card.\n       *[other] { $cards } cards.\n    }\nempty-cards-delete-empty-cards = Delete Empty Cards\nempty-cards-delete-empty-notes = Delete Empty Notes\nempty-cards-deleting = Deleting...\n"
  },
  {
    "path": "ftl/core/errors.ftl",
    "content": "errors-parse-number-fail = A number was invalid or out of range.\nerrors-filtered-parent-deck = Filtered decks can not have child decks.\nerrors-filtered-deck-required = This action can only be used on a filtered deck.\nerrors-100-tags-max =\n    A maximum of 100 tags can be selected. Listing the\n    tags you want instead of the ones you don't want is usually simpler, and there\n    is no need to select child tags if you have selected a parent tag.\nerrors-multiple-notetypes-selected = Please select notes from only one note type.\nerrors-please-check-database = Please use the Check Database action, then try again.\nerrors-please-check-media = Please use the Check Media action, then try again.\nerrors-collection-too-new = This collection requires a newer version of Anki to open.\nerrors-invalid-ids = This deck contains timestamps in the future. Please contact the deck author and ask them to fix the issue.\nerrors-inconsistent-db-state = Your database appears to be in an inconsistent state. Please use the Check Database action.\n\n## Card Rendering\n\nerrors-bad-directive = Error in directive '{ $directive }': { $error }\nerrors-option-not-set = '{ $option }' not set\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\nerrors-invalid-input-empty = Invalid input.\nerrors-invalid-input-details = Invalid input: { $details }\n"
  },
  {
    "path": "ftl/core/exporting.ftl",
    "content": "exporting-all-decks = All Decks\nexporting-anki-20-deck = Anki 2.0 Deck\nexporting-anki-collection-package = Anki Collection Package\nexporting-anki-deck-package = Anki Deck Package\nexporting-cards-in-plain-text = Cards in Plain Text\n# used in the filename during the export of a collection package\nexporting-collection = collection\nexporting-collection-exported = Collection exported.\nexporting-colpkg-too-new = Please update to the latest Anki version, then import the .colpkg/.apkg file again.\nexporting-couldnt-save-file = Couldn't save file: { $val }\nexporting-export = Export...\nexporting-export-format = <b>Export format</b>:\nexporting-include = <b>Include</b>:\nexporting-include-html-and-media-references = Include HTML and media references\nexporting-include-media = Include media\nexporting-include-scheduling-information = Include scheduling information\nexporting-include-deck-configs = Include deck presets\nexporting-include-tags = Include tags\nexporting-support-older-anki-versions = Support older Anki versions (slower/larger files)\nexporting-notes-in-plain-text = Notes in Plain Text\nexporting-selected-notes = Selected Notes\nexporting-card-exported =\n    { $count ->\n        [one] { $count } card exported.\n       *[other] { $count } cards exported.\n    }\nexporting-exported-media-file =\n    { $count ->\n        [one] Exported { $count } media file\n       *[other] Exported { $count } media files\n    }\nexporting-note-exported =\n    { $count ->\n        [one] { $count } note exported.\n       *[other] { $count } notes exported.\n    }\nexporting-exporting-file = Exporting file...\nexporting-processed-media-files =\n    { $count ->\n        [one] Processed { $count } media file...\n       *[other] Processed { $count } media files...\n    }\nexporting-include-deck = Include deck name\nexporting-include-notetype = Include note type name\nexporting-include-guid = Include unique identifier\n"
  },
  {
    "path": "ftl/core/fields.ftl",
    "content": "fields-add-field = Add Field\nfields-delete-field-from = Delete field from { $val }?\nfields-editing-font = Editing Font\nfields-field = Field:\nfields-field-name = Field name:\nfields-description = Description\nfields-description-placeholder = Text to show inside the field when it's empty\nfields-fields-for = Fields for { $val }\nfields-font = Font:\nfields-new-position-1 = New position (1...{ $val }):\nfields-notes-require-at-least-one-field = Notes require at least one field.\nfields-reverse-text-direction-rtl = Reverse text direction (RTL)\nfields-collapse-by-default = Collapse by default\nfields-html-by-default = Use HTML editor by default\nfields-size = Size:\nfields-sort-by-this-field-in-the = Sort by this field in the browser\nfields-that-field-name-is-already-used = That field name is already used.\nfields-name-first-letter-not-valid = The field name should not start with #, ^ or /.\nfields-name-invalid-letter = The field name should not contain :, \", { \"{\" } or { \"}\" }.\n# If enabled, the field is not included when searching for 'text', 're:text' and so on,\n# but is when searching for a specific field, eg 'field:text'.\nfields-exclude-from-search = Exclude from unqualified searches (slower)\nfields-field-is-required = This is a required field, and can not be deleted.\n"
  },
  {
    "path": "ftl/core/findreplace.ftl",
    "content": "findreplace-notes-updated =\n    { $total ->\n        [one] { $changed } of { $total } note updated\n       *[other] { $changed } of { $total } notes updated\n    }\n"
  },
  {
    "path": "ftl/core/help.ftl",
    "content": "### Text shown in Help pages\n\n## Header/footer\n\n# Link to more detailed information in the manual\nhelp-for-more-info = For more information, see { $link } in the manual.\n\n# Tooltip for links to the manual\nhelp-open-manual-chapter = Open { $name } in the manual\n\nhelp-ok = OK\n\n## Body\n\n# Newly introduced settings may not have an explanation yet\nhelp-no-explanation =\n    Whoops! There doesn't seem to be an explanation for this setting yet.\n    \n    You can help us complete this help page on { $link }.\n"
  },
  {
    "path": "ftl/core/importing.ftl",
    "content": "importing-failed-debug-info = Import failed. Debugging info:\nimporting-aborted = Aborted: { $val }\nimporting-added-duplicate-with-first-field = Added duplicate with first field: { $val }\nimporting-all-supported-formats = All supported formats { $val }\nimporting-allow-html-in-fields = Allow HTML in fields\nimporting-anki-files-are-from-a-very = .anki files are from a very old version of Anki. You can import them with add-on 175027074 or with Anki 2.0, available on the Anki website.\nimporting-anki2-files-are-not-directly-importable = .anki2 files are not directly importable - please import the .apkg or .zip file you have received instead.\nimporting-appeared-twice-in-file = Appeared twice in file: { $val }\nimporting-by-default-anki-will-detect-the = By default, Anki will detect the character between fields, such as a tab, comma, and so on. If Anki is detecting the character incorrectly, you can enter it here. Use \\t to represent tab.\nimporting-cannot-merge-notetypes-of-different-kinds =\n    Cloze note types cannot be merged with regular note types.\n    You may still import the file with '{ importing-merge-notetypes }' disabled.\nimporting-change = Change\nimporting-colon = Colon\nimporting-comma = Comma\nimporting-empty-first-field = Empty first field: { $val }\nimporting-field-separator = Field separator\nimporting-field-separator-guessed =  Field separator (guessed)\nimporting-field-mapping = Field mapping\nimporting-field-of-file-is = Field <b>{ $val }</b> of file is:\nimporting-fields-separated-by = Fields separated by: { $val }\nimporting-file-must-contain-field-column = File must contain at least one column that can be mapped to a note field.\nimporting-file-version-unknown-trying-import-anyway = File version unknown, trying import anyway.\nimporting-first-field-matched = First field matched: { $val }\nimporting-identical = Identical\nimporting-ignore-field = Ignore field\nimporting-ignore-lines-where-first-field-matches = Ignore lines where first field matches existing note\nimporting-ignored = <ignored>\nimporting-import-even-if-existing-note-has = Import even if existing note has same first field\nimporting-import-options = Import options\nimporting-importing-complete = Importing complete.\nimporting-invalid-file-please-restore-from-backup = Invalid file. Please restore from backup.\nimporting-map-to = Map to { $val }\nimporting-map-to-tags = Map to Tags\nimporting-mapped-to = mapped to <b>{ $val }</b>\nimporting-mapped-to-tags = mapped to <b>Tags</b>\n# the action of combining two existing note types to create a new one\nimporting-merge-notetypes = Merge note types\nimporting-merge-notetypes-help =\n    If checked, and you or the deck author altered the schema of a note type, Anki will\n    merge the two versions instead of keeping both.\n    \n    Altering a note type's schema means adding, removing, or reordering fields or templates,\n    or changing the sort field.\n    As a counterexample, changing the front side of an existing template does *not* constitute\n    a schema change.\n    \n    Warning: This will require a one-way sync, and may mark existing notes as modified.\nimporting-mnemosyne-20-deck-db = Mnemosyne 2.0 Deck (*.db)\nimporting-multicharacter-separators-are-not-supported-please = Multi-character separators are not supported. Please enter one character only.\nimporting-new-deck-will-be-created = A new deck will be created: { $name }\nimporting-notes-added-from-file = Notes added from file: { $val }\nimporting-notes-found-in-file = Notes found in file: { $val }\nimporting-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copies are already in your collection: { $val }\nimporting-notes-skipped-update-due-to-notetype = Notes not updated, as note type has been modified since you first imported the notes: { $val }\nimporting-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val }\nimporting-include-reviews = Include reviews\nimporting-also-import-progress = Import any learning progress\nimporting-with-deck-configs = Import any deck presets\nimporting-updates = Updates\nimporting-include-reviews-help =\n    If enabled, any previous reviews that the deck sharer included will also be imported.\n    Otherwise, all cards will be imported as new cards, and any \"leech\" or \"marked\"\n    tags will be removed.\nimporting-with-deck-configs-help =\n    If enabled, any deck options that the deck sharer included will also be imported.\n    Otherwise, all decks will be assigned the default preset.\nimporting-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip)\n# the '|' character\nimporting-pipe = Pipe\n# Warning displayed when the csv import preview table is clipped (some columns were hidden)\n# $count is intended to be a large number (1000 and above)\nimporting-preview-truncated =\n    { $count ->\n        *[other] Only the first { $count } columns are shown. If this doesn't seem right, try changing the field separator.\n    }\nimporting-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } fields, expected { $expected }\nimporting-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual.\nimporting-semicolon = Semicolon\nimporting-skipped = Skipped\nimporting-tab = Tab\nimporting-tag-modified-notes = Tag modified notes:\nimporting-text-separated-by-tabs-or-semicolons = Text separated by tabs or semicolons (*)\nimporting-the-first-field-of-the-note = The first field of the note type must be mapped.\nimporting-the-provided-file-is-not-a = The provided file is not a valid .apkg file.\nimporting-this-file-does-not-appear-to = This file does not appear to be a valid .apkg file. If you're getting this error from a file downloaded from AnkiWeb, chances are that your download failed. Please try again, and if the problem persists, please try again with a different browser.\nimporting-this-will-delete-your-existing-collection = This will delete your existing collection and replace it with the data in the file you're importing. Are you sure?\nimporting-unable-to-import-from-a-readonly = Unable to import from a read-only file.\nimporting-unknown-file-format = Unknown file format.\nimporting-update-existing-notes-when-first-field = Update existing notes when first field matches\nimporting-updated = Updated\nimporting-update-if-newer = If newer\nimporting-update-always = Always\nimporting-update-never = Never\nimporting-update-notes = Update notes\nimporting-update-notes-help =\n    When to update an existing note in your collection. By default, this is only done\n    if the matching imported note was more recently modified.\nimporting-update-notetypes = Update note types\nimporting-update-notetypes-help =\n    When to update an existing note type in your collection. By default, this is only done\n    if the matching imported note type was more recently modified. Changes to template text\n    and styling can always be imported, but for schema changes (e.g. the number or order of\n    fields has changed), the '{ importing-merge-notetypes }' option will also need to be enabled.\nimporting-note-added =\n    { $count ->\n        [one] { $count } note added\n       *[other] { $count } notes added\n    }\nimporting-note-imported =\n    { $count ->\n        [one] { $count } note imported.\n       *[other] { $count } notes imported.\n    }\nimporting-note-unchanged =\n    { $count ->\n        [one] { $count } note unchanged\n       *[other] { $count } notes unchanged\n    }\nimporting-note-updated =\n    { $count ->\n        [one] { $count } note updated\n       *[other] { $count } notes updated\n    }\nimporting-processed-media-file =\n    { $count ->\n        [one] Imported { $count } media file\n       *[other] Imported { $count } media files\n    }\nimporting-importing-file = Importing file...\nimporting-extracting = Extracting data...\nimporting-gathering = Gathering data...\nimporting-failed-to-import-media-file = Failed to import media file: { $debugInfo }\nimporting-processed-notes =\n    { $count ->\n        [one] Processed { $count } note...\n       *[other] Processed { $count } notes...\n    }\nimporting-processed-cards =\n    { $count ->\n        [one] Processed { $count } card...\n       *[other] Processed { $count } cards...\n    }\nimporting-existing-notes = Existing notes\n# \"Existing notes: Duplicate\" (verb)\nimporting-duplicate = Duplicate\n# \"Existing notes: Preserve\" (verb)\nimporting-preserve = Preserve\n# \"Existing notes: Update\" (verb)\nimporting-update = Update\nimporting-tag-all-notes = Tag all notes\nimporting-tag-updated-notes = Tag updated notes\nimporting-file = File\n# \"Match scope: notetype / notetype and deck\". Controls how duplicates are matched.\nimporting-match-scope = Match scope\n# Used with the 'match scope' option\nimporting-notetype-and-deck = Note type and deck\nimporting-cards-added =\n    { $count ->\n        [one] { $count } card added.\n       *[other] { $count } cards added.\n    }\nimporting-file-empty = The file you selected is empty.\nimporting-notes-added =\n    { $count ->\n        [one] { $count } new note imported.\n       *[other] { $count } new notes imported.\n    }\nimporting-notes-updated =\n    { $count ->\n        [one] { $count } note was used to update existing ones.\n       *[other] { $count } notes were used to update existing ones.\n    }\nimporting-existing-notes-skipped =\n    { $count ->\n        [one] { $count } note already present in your collection.\n       *[other] { $count } notes already present in your collection.\n    }\nimporting-notes-failed =\n    { $count ->\n        [one] { $count } note could not be imported.\n        *[other] { $count } notes could not be imported.\n    }\nimporting-conflicting-notes-skipped =\n    { $count ->\n        [one] { $count } note was not imported, because its note type has changed.\n       *[other] { $count } notes were not imported, because their note type has changed.\n    }\nimporting-conflicting-notes-skipped2 =\n    { $count ->\n        [one] { $count } note was not imported, because its note type has changed, and '{ importing-merge-notetypes }' was not enabled.\n        *[other] { $count } notes were not imported, because their note type has changed, and '{ importing-merge-notetypes }' was not enabled.\n    }\nimporting-import-log = Import Log\nimporting-no-notes-in-file = No notes found in file.\nimporting-notes-found-in-file2 =\n    { $notes ->\n        [one] { $notes } note\n       *[other] { $notes } notes\n    } found in file. Of those:\nimporting-show = Show\nimporting-details = Details\nimporting-status = Status\nimporting-duplicate-note-added = Duplicate note added\nimporting-added-new-note = New note added\nimporting-existing-note-skipped = Note skipped, as an up-to-date copy is already in your collection\nimporting-note-skipped-update-due-to-notetype = Note not updated, as note type has been modified since you first imported the note\nimporting-note-skipped-update-due-to-notetype2 = Note not updated, as note type has been modified since you first imported the note, and '{ importing-merge-notetypes }' was not enabled\nimporting-note-updated-as-file-had-newer = Note updated, as file had newer version\nimporting-note-skipped-due-to-missing-notetype = Note skipped, as its notetype was missing\nimporting-note-skipped-due-to-missing-deck = Note skipped, as its deck was missing\nimporting-note-skipped-due-to-empty-first-field = Note skipped, as its first field is empty\nimporting-field-separator-help =\n    The character separating fields in the text file. You can use the preview to check\n    if the fields are separated correctly.\n    \n    Please note that if this character appears in any field itself, the field has to be\n    quoted accordingly to the CSV standard. Spreadsheet programs like LibreOffice will\n    do this automatically.\n\n    It cannot be changed if the text file forces use of a specific separator via a file header.\n    If a file header is not present, Anki will try to guess what the separator is.\nimporting-allow-html-in-fields-help =\n    Enable this if the file contains HTML formatting. E.g. if the file contains the string\n    '&lt;br&gt;', it will appear as a line break on your card. On the other hand, with this\n    option disabled, the literal characters '&lt;br&gt;' will be rendered.\nimporting-notetype-help =\n    Newly-imported notes will have this note type, and only existing notes with this\n    note type will be updated.\n    \n    You can choose which fields in the file correspond to which note type fields with the\n    mapping tool.\nimporting-deck-help = Imported cards will be placed in this deck.\nimporting-existing-notes-help =\n    What to do if an imported note matches an existing one.\n    \n    - `{ importing-update }`: Update the existing note.\n    - `{ importing-preserve }`: Do nothing.\n    - `{ importing-duplicate }`: Create a new note.\nimporting-match-scope-help =\n    Only existing notes with the same note type will be checked for duplicates. This can\n    additionally be restricted to notes with cards in the same deck.\nimporting-tag-all-notes-help =\n    These tags will be added to both newly-imported and updated notes.\nimporting-tag-updated-notes-help = These tags will be added to any updated notes.\nimporting-overview = Overview\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\nimporting-importing-collection = Importing collection...\nimporting-unable-to-import-filename = Unable to import { $filename }: file type not supported\nimporting-notes-that-could-not-be-imported = Notes that could not be imported as note type has changed: { $val }\nimporting-added = Added\nimporting-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)\nimporting-supermemo-xml-export-xml = Supermemo XML export (*.xml)\n"
  },
  {
    "path": "ftl/core/keyboard.ftl",
    "content": "keyboard-ctrl = Ctrl\nkeyboard-shift = Shift\n"
  },
  {
    "path": "ftl/core/launcher.ftl",
    "content": "launcher-title = Anki Launcher\nlauncher-press-enter-to-install = Press the Enter/Return key on your keyboard to install or update Anki.\nlauncher-press-enter-to-start = Press enter to start Anki.\nlauncher-anki-will-start-shortly = Anki will start shortly.\nlauncher-you-can-close-this-window = You can close this window.\nlauncher-updating-anki = Updating Anki...\nlauncher-latest-anki = Install Latest Anki (default)\nlauncher-choose-a-version = Choose a version\nlauncher-sync-project-changes = Sync project changes\nlauncher-keep-existing-version = Keep existing version ({ $current })\nlauncher-revert-to-previous = Revert to previous version ({ $prev })\nlauncher-allow-betas = Allow betas: { $state }\nlauncher-on = on\nlauncher-off = off\nlauncher-cache-downloads = Cache downloads: { $state }\nlauncher-download-mirror = Download mirror: { $state }\nlauncher-uninstall = Uninstall Anki\nlauncher-invalid-input = Invalid input. Please try again.\nlauncher-latest-releases = Latest releases: { $releases }\nlauncher-enter-the-version-you-want = Enter the version you want to install:\nlauncher-versions-before-cant-be-installed = Versions before 2.1.50 can't be installed.\nlauncher-invalid-version = Invalid version.\nlauncher-unable-to-check-for-versions = Unable to check for Anki versions. Please check your internet connection.\nlauncher-checking-for-updates = Checking for updates...\nlauncher-uninstall-confirm = Uninstall Anki's program files? (y/n)\nlauncher-uninstall-cancelled = Uninstall cancelled.\nlauncher-program-files-removed = Program files removed.\nlauncher-remove-all-profiles-confirm = Remove all profiles/cards? (y/n)\nlauncher-user-data-removed = User data removed.\nlauncher-download-mirror-options = Download mirror options:\nlauncher-mirror-no-mirror = No mirror\nlauncher-mirror-china = China\nlauncher-mirror-disabled = Mirror disabled.\nlauncher-mirror-china-enabled = China mirror enabled.\nlauncher-beta-releases-enabled = Beta releases enabled.\nlauncher-beta-releases-disabled = Beta releases disabled.\nlauncher-download-caching-enabled = Download caching enabled.\nlauncher-download-caching-disabled = Download caching disabled and cache cleared.\n"
  },
  {
    "path": "ftl/core/media-check.ftl",
    "content": "## Shown at the top of the media check screen\n\nmedia-check-window-title = Check Media\n# the number of files, and the total space used by files\n# that have been moved to the trash folder. eg,\n# \"Trash folder: 3 files, 3.47MB\"\nmedia-check-trash-count =\n    Trash folder: { $count ->\n        [one] { $count } file, { $megs }MB\n       *[other] { $count } files, { $megs }MB\n    }\nmedia-check-missing-count = Missing files: { $count }\nmedia-check-unused-count = Unused files: { $count }\nmedia-check-renamed-count = Renamed files: { $count }\nmedia-check-oversize-count = Over 100MB: { $count }\nmedia-check-subfolder-count = Subfolders: { $count }\nmedia-check-extracted-count = Extracted images: { $count }\n\n## Shown at the top of each section\n\nmedia-check-renamed-header = Some files have been renamed for compatibility:\nmedia-check-oversize-header = Files over 100MB can not be synced with AnkiWeb.\nmedia-check-subfolder-header = Folders inside the media folder are not supported.\nmedia-check-missing-header = The following files are referenced by cards, but were not found in the media folder:\nmedia-check-unused-header = The following files were found in the media folder, but do not appear to be used on any cards:\nmedia-check-template-references-field-header =\n    Anki can not detect used files when you use { \"{{Field}}\" } references in media/LaTeX tags. The media/LaTeX tags should be placed on individual notes instead.\n    \n    Referencing templates:\n\n## Shown once for each file\n\nmedia-check-renamed-file = Renamed: { $old } -> { $new }\nmedia-check-oversize-file = Over 100MB: { $filename }\nmedia-check-subfolder-file = Folder: { $filename }\nmedia-check-missing-file = Missing: { $filename }\nmedia-check-unused-file = Unused: { $filename }\n\n##\n\n# Eg \"Basic: Card 1 (Front Template)\"\nmedia-check-notetype-template = { $notetype }: { $card_type } ({ $side })\n\n## Progress\n\nmedia-check-checked = Checked { $count }...\n\n## Deleting unused media\n\nmedia-check-delete-unused-confirm = Delete unused media?\nmedia-check-files-remaining =\n    { $count ->\n        [one] { $count } file\n       *[other] { $count } files\n    } remaining.\nmedia-check-delete-unused-complete =\n    { $count ->\n        [one] { $count } file\n       *[other] { $count } files\n    } moved to the trash.\nmedia-check-trash-emptied = The trash folder is now empty.\nmedia-check-trash-restored = Restored deleted files to the media folder.\n\n## Rendering LaTeX\n\nmedia-check-all-latex-rendered = All LaTeX rendered.\n\n## Buttons\n\nmedia-check-delete-unused = Delete Unused\nmedia-check-render-latex = Render LaTeX\n# button to permanently delete media files from the trash folder\nmedia-check-empty-trash = Empty Trash\n# button to move deleted files from the trash back into the media folder\nmedia-check-restore-trash = Restore Deleted\nmedia-check-check-media-action = Check Media\n# a tag for notes with missing media files (must not contain whitespace)\nmedia-check-missing-media-tag = missing-media\n# add a tag to notes with missing media\nmedia-check-add-tag = Tag Missing\n"
  },
  {
    "path": "ftl/core/media.ftl",
    "content": "media-error-executing = Error executing { $val }.\nmedia-error-running = Error running { $val }\nmedia-for-security-reasons-is-not = For security reasons, '{ $val }' is not allowed on cards. You can still use it by placing the command in a different package, and importing that package in the LaTeX header instead.\nmedia-generated-file = Generated file: { $val }\nmedia-have-you-installed-latex-and-dvipngdvisvgm = Have you installed latex and dvipng/dvisvgm?\nmedia-recordingtime = Recording...<br>Time: { $secs }\nmedia-sound-and-video-on-cards-will = Sound and video on cards will not function until mpv or mplayer is installed.\n"
  },
  {
    "path": "ftl/core/network.ftl",
    "content": "network-offline = Please check your internet connection.\nnetwork-timeout = Connection timed out. Please try again. If you see frequent timeouts, please try a different network connection.\nnetwork-proxy-auth = Your proxy requires authentication.\nnetwork-other = A network error occurred.\nnetwork-details = Error details: { $details }\n"
  },
  {
    "path": "ftl/core/notetypes.ftl",
    "content": "notetypes-notetype = Note Type\n\n## Default field names in newly created note types\n\nnotetypes-front-field = Front\nnotetypes-back-field = Back\nnotetypes-add-reverse-field = Add Reverse\nnotetypes-text-field = Text\nnotetypes-back-extra-field = Back Extra\n\n## Default note type names\n\nnotetypes-basic-name = Basic\nnotetypes-basic-reversed-name = Basic (and reversed card)\nnotetypes-basic-optional-reversed-name = Basic (optional reversed card)\nnotetypes-basic-type-answer-name = Basic (type in the answer)\nnotetypes-cloze-name = Cloze\n\n## Default card template names\n\nnotetypes-card-1-name = Card 1\nnotetypes-card-2-name = Card 2\nnotetypes-add = Add: { $val }\nnotetypes-add-note-type = Add Note Type\nnotetypes-cards = Cards\nnotetypes-clone = Clone: { $val }\nnotetypes-copy = { $val } copy\nnotetypes-create-scalable-images-with-dvisvgm = Create scalable images with dvisvgm\nnotetypes-delete-this-note-type-and-all = Delete this note type and all its cards?\nnotetypes-delete-this-unused-note-type = Delete this unused note type?\nnotetypes-fields = Fields\nnotetypes-footer = Footer\nnotetypes-header = Header\nnotetypes-note-types = Note Types\nnotetypes-options = Options\nnotetypes-please-add-another-note-type-first = Please add another note type first.\nnotetypes-type = Type\n\n## Image Occlusion\n\nnotetypes-image = Image\nnotetypes-occlusion = Occlusion\nnotetypes-occlusion-mask = Mask\nnotetypes-occlusion-note = Note\nnotetypes-comments-field = Comments\nnotetypes-toggle-masks = Toggle Masks\nnotetypes-image-occlusion-name = Image Occlusion\nnotetypes-hide-all-guess-one = Hide All, Guess One\nnotetypes-hide-one-guess-one = Hide One, Guess One\nnotetypes-error-generating-cloze = An error occurred when generating an image occlusion note\nnotetypes-error-getting-imagecloze = An error occurred while fetching an image occlusion note\nnotetypes-error-loading-image-occlusion = Error loading image occlusion. Is your Anki version up to date?\nnotetype-error-no-image-to-show = No image to show.\nnotetypes-no-occlusion-created = You must make at least one occlusion.\nnotetypes-no-occlusion-created2 = Unable to add. Either you have not added any occlusions, or the first field is empty.\nnotetypes-io-select-image = Select Image\nnotetypes-io-paste-image-from-clipboard = Paste Image from Clipboard\n"
  },
  {
    "path": "ftl/core/preferences.ftl",
    "content": "preferences-automatically-sync-on-profile-openclose = Automatically sync on profile open/close\npreferences-backups = Backups\npreferences-change-deck-depending-on-note-type = Change deck depending on note type\npreferences-changes-will-take-effect-when-you = Changes will take effect when you restart Anki.\npreferences-hours-past-midnight = hours past midnight\npreferences-language = Language\npreferences-interrupt-current-audio-when-answering = Interrupt current audio when answering\npreferences-learn-ahead-limit = Learn ahead limit\npreferences-mins = mins\npreferences-network = Syncing\npreferences-next-day-starts-at = Next day starts at\npreferences-media-is-not-backed-up = Media is not backed up. Please create a periodic backup of your Anki folder to be safe.\npreferences-on-next-sync-force-changes-in = On next sync, force changes in one direction\npreferences-paste-clipboard-images-as-png = Paste clipboard images as PNG\npreferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting\npreferences-generate-latex-images-automatically = Generate LaTeX images (security risk)\npreferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences.\npreferences-periodically-sync-media = Periodically sync media\npreferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change.\npreferences-preferences = Preferences\npreferences-scheduling = Scheduling\npreferences-show-learning-cards-with-larger-steps = Show learning cards with larger steps before reviews\npreferences-show-next-review-time-above-answer = Show next review time above answer buttons\npreferences-spacebar-rates-card = Spacebar (or enter) also answers card\npreferences-show-play-buttons-on-cards-with = Show play buttons on cards with audio\npreferences-show-remaining-card-count = Show remaining card count\npreferences-some-settings-will-take-effect-after = Some settings will take effect after you restart Anki.\npreferences-tab-synchronisation = Synchronization\npreferences-synchronize-audio-and-images-too = Synchronize audio and images too\npreferences-login-successful-sync-now = Log-in successful. Save preferences and sync now?\npreferences-timebox-time-limit = Timebox time limit\npreferences-user-interface-size = User interface size\npreferences-when-adding-default-to-current-deck = When adding, default to current deck\npreferences-you-can-restore-backups-via-fileswitch = You can restore backups via File > Switch Profile.\npreferences-legacy-timezone-handling = Legacy timezone handling (buggy, but required for AnkiDroid <= 2.14)\npreferences-default-search-text = Default search text\npreferences-default-search-text-example = e.g. \"deck:current\"\npreferences-theme = Theme\npreferences-theme-follow-system = Follow System\npreferences-theme-light = Light\npreferences-theme-dark = Dark\npreferences-v3-scheduler = V3 scheduler\npreferences-updates = Updates\npreferences-check-for-updates = Check for program updates\npreferences-check-for-addon-updates = Check for add-on updates\npreferences-ignore-accents-in-search = Ignore accents in search (slower)\npreferences-backup-explanation =\n    Anki periodically backs up your collection. After backups are more than 2 days old,\n    Anki will start removing some of them to free up disk space.\npreferences-daily-backups = Daily backups to keep:\npreferences-weekly-backups = Weekly backups to keep:\npreferences-monthly-backups = Monthly backups to keep:\npreferences-minutes-between-backups = Minutes between automatic backups:\npreferences-reduce-motion = Reduce motion\npreferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface\npreferences-custom-sync-url = Self-hosted sync server\npreferences-custom-sync-url-disclaimer = For advanced users - please see the manual\npreferences-hide-top-bar-during-review = Hide top bar during review\npreferences-hide-bottom-bar-during-review = Hide bottom bar during review\npreferences-always = Always\npreferences-full-screen-only = Full screen only\npreferences-appearance = Appearance\npreferences-general = General\npreferences-style = Style\npreferences-review = Review\npreferences-answer-keys = Answer keys\npreferences-distractions = Distractions\npreferences-minimalist-mode = Minimalist mode\npreferences-minimalist-mode-tooltip = Make the interface more compact/less fancy\npreferences-editing = Editing\npreferences-browsing = Browsing\npreferences-default-deck = Default deck\npreferences-account = AnkiWeb Account\npreferences-note = Note\npreferences-scheduler = Scheduler\npreferences-user-interface = User Interface\npreferences-import-export = Import/Export\npreferences-network-timeout = Network timeout\npreferences-reset-window-sizes = Reset Window Sizes\npreferences-reset-window-sizes-complete = Window sizes and locations have been reset.\npreferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable.\npreferences-third-party-services = Third-Party Services\npreferences-ankihub-not-logged-in = Not currently logged in to AnkiHub.\npreferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your flashcard data in sync across your devices, and provides a way to recover the data if your device breaks or is lost.\npreferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features.\npreferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment.\n\n## URL scheme related\npreferences-url-schemes = URL Schemes\npreferences-url-scheme-prompt = Allowed URL Schemes (space-separated):\npreferences-url-scheme-warning = Blocked attempt to open `{ $link }`, which may be a security issue.\n\n    If you trust the deck author and wish to proceed, you can add `{ $scheme }` to your allowed URL Schemes.\npreferences-url-scheme-allow-once = Allow Once\npreferences-url-scheme-always-allow = Always Allow\n\n## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\n\npreferences-basic = Basic\npreferences-reviewer = Reviewer\npreferences-media = Media\npreferences-not-logged-in = Not currently logged in to AnkiWeb.\n"
  },
  {
    "path": "ftl/core/profiles.ftl",
    "content": "profiles-anki-could-not-read-your-profile = Anki could not read your profile data. Window sizes and your sync login details have been forgotten.\nprofiles-anki-could-not-rename-your-profile = Anki could not rename your profile because it could not rename the profile folder on disk. Please ensure you have permission to write to Documents/Anki and no other programs are accessing your profile folders, then try again.\nprofiles-folder-already-exists = Folder already exists.\nprofiles-open = Open\nprofiles-open-backup = Open Backup...\nprofiles-please-remove-the-folder-and = Please remove the folder { $val } and try again.\nprofiles-profile-corrupt = Profile Corrupt\nprofiles-profiles = Profiles\nprofiles-quit = Quit\nprofiles-user-1 = User 1\nprofiles-confirm-lang-choice = Are you sure you wish to display Anki's interface in { $lang }?\nprofiles-could-not-create-data-folder = Anki could not create its data folder. Please see the File Locations section of the manual, and ensure that location is not read-only.\nprofiles-prefs-corrupt-title = Preferences Corrupt\nprofiles-prefs-file-is-corrupt = Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple profiles, please add them back using the same names to recover your cards.\nprofiles-profile-does-not-exist = Requested profile does not exist.\nprofiles-creating-backup = Creating Backup...\nprofiles-backup-created = Backup created.\nprofiles-backup-creation-failed = Backup creation failed: { $reason }\nprofiles-backup-unchanged = No changes since latest backup.\n"
  },
  {
    "path": "ftl/core/scheduling.ftl",
    "content": "## The next time a card will be shown, in a short form that will fit\n## on the answer buttons. For example, English shows \"4d\" to\n## represent the card will be due in 4 days, \"3m\" for 3 minutes, and\n## \"5mo\" for 5 months.\n\nscheduling-answer-button-time-seconds = { $amount }s\nscheduling-answer-button-time-minutes = { $amount }m\nscheduling-answer-button-time-hours = { $amount }h\nscheduling-answer-button-time-days = { $amount }d\nscheduling-answer-button-time-months = { $amount }mo\nscheduling-answer-button-time-years = { $amount }y\n\n## A span of time, such as the delay until a card is shown again, the\n## amount of time taken to answer a card, and so on. It is used by itself,\n## such as in the Interval column of the browse screen,\n## and labels like \"Total Time\" in the card info screen.\n\nscheduling-time-span-seconds =\n    { $amount ->\n        [one] { $amount } second\n       *[other] { $amount } seconds\n    }\nscheduling-time-span-minutes =\n    { $amount ->\n        [one] { $amount } minute\n       *[other] { $amount } minutes\n    }\nscheduling-time-span-hours =\n    { $amount ->\n        [one] { $amount } hour\n       *[other] { $amount } hours\n    }\nscheduling-time-span-days =\n    { $amount ->\n        [one] { $amount } day\n       *[other] { $amount } days\n    }\nscheduling-time-span-months =\n    { $amount ->\n        [one] { $amount } month\n       *[other] { $amount } months\n    }\nscheduling-time-span-years =\n    { $amount ->\n        [one] { $amount } year\n       *[other] { $amount } years\n    }\n\n## Shown in the \"Congratulations!\" message after study finishes.\n\n# eg \"The next learning card will be ready in 5 minutes.\"\nscheduling-next-learn-due =\n    The next learning card will be ready in { $unit ->\n        [seconds]\n            { $amount ->\n                [one] { $amount } second\n               *[other] { $amount } seconds\n            }\n        [minutes]\n            { $amount ->\n                [one] { $amount } minute\n               *[other] { $amount } minutes\n            }\n       *[hours]\n            { $amount ->\n                [one] { $amount } hour\n               *[other] { $amount } hours\n            }\n    }.\nscheduling-learn-remaining =\n    { $remaining ->\n        [one] There is one remaining learning card due later today.\n       *[other] There are { $remaining } learning cards due later today.\n    }\nscheduling-congratulations-finished = Congratulations! You have finished this deck for now.\nscheduling-today-review-limit-reached =\n    Today's review limit has been reached, but there are still cards\n    waiting to be reviewed. For optimum memory, consider increasing\n    the daily limit in the options.\nscheduling-today-new-limit-reached =\n    There are more new cards available, but the daily limit has been\n    reached. You can increase the limit in the options, but please\n    bear in mind that the more new cards you introduce, the higher\n    your short-term review workload will become.\nscheduling-buried-cards-found = One or more cards were buried, and will be shown tomorrow. You can { $unburyThem } if you wish to see them immediately.\n# used in scheduling-buried-cards-found\n# \"... you can unbury them if you wish to see...\"\nscheduling-unbury-them = unbury them\nscheduling-how-to-custom-study = If you wish to study outside of the regular schedule, you can use the { $customStudy } feature.\n# used in scheduling-how-to-custom-study\n# \"... you can use the custom study feature.\"\nscheduling-custom-study = custom study\n\n## Scheduler upgrade\n\nscheduling-update-soon = Anki 2.1 comes with a new scheduler, which fixes a number of issues that previous Anki versions had. Updating to it is recommended.\nscheduling-update-done = Scheduler updated successfully.\nscheduling-update-button = Update\nscheduling-update-later-button = Later\nscheduling-update-more-info-button = Learn More\nscheduling-update-required =\n    Your collection needs to be upgraded to the V2 scheduler.\n    Please select { scheduling-update-more-info-button } before proceeding.\n\n## Other scheduling strings\n\nscheduling-always-include-question-side-when-replaying = Always include question side when replaying audio\nscheduling-at-least-one-step-is-required = At least one step is required.\nscheduling-automatically-play-audio = Automatically play audio\nscheduling-bury-related-new-cards-until-the = Bury related new cards until the next day\nscheduling-bury-related-reviews-until-the-next = Bury related reviews until the next day\nscheduling-days = days\nscheduling-description = Description\nscheduling-easy-bonus = Easy bonus\nscheduling-easy-interval = Easy interval\nscheduling-end = (end)\nscheduling-general = General\nscheduling-graduating-interval = Graduating interval\nscheduling-hard-interval = Hard interval\nscheduling-ignore-answer-times-longer-than = Ignore answer times longer than\nscheduling-interval-modifier = Interval modifier\nscheduling-lapses = Lapses\nscheduling-lapses2 = lapses\nscheduling-learning = Learning\nscheduling-leech-action = Leech action\nscheduling-leech-threshold = Leech threshold\nscheduling-maximum-interval = Maximum interval\nscheduling-maximum-reviewsday = Maximum reviews/day\nscheduling-minimum-interval = Minimum interval\nscheduling-mix-new-cards-and-reviews = Mix new cards and reviews\nscheduling-new-cards = New Cards\nscheduling-new-cardsday = New cards/day\nscheduling-new-interval = New interval\nscheduling-new-options-group-name = New options group name:\nscheduling-options-group = Options group:\nscheduling-order = Order\nscheduling-parent-limit = (parent limit: { $val })\nscheduling-reset-counts = Reset repetition and lapse counts\nscheduling-restore-position = Restore original position where possible\nscheduling-review = Review\nscheduling-reviews = Reviews\nscheduling-seconds = seconds\nscheduling-set-all-decks-below-to = Set all decks below { $val } to this option group?\nscheduling-set-for-all-subdecks = Set for all subdecks\nscheduling-show-answer-timer = Show on-screen timer\nscheduling-show-new-cards-after-reviews = Show new cards after reviews\nscheduling-show-new-cards-before-reviews = Show new cards before reviews\nscheduling-show-new-cards-in-order-added = Show new cards in order added\nscheduling-show-new-cards-in-random-order = Show new cards in random order\nscheduling-starting-ease = Starting ease\nscheduling-steps-in-minutes = Steps (in minutes)\nscheduling-steps-must-be-numbers = Steps must be numbers.\nscheduling-tag-only = Tag Only\nscheduling-the-default-configuration-cant-be-removed = The default configuration can't be removed.\nscheduling-your-changes-will-affect-multiple-decks = Your changes will affect multiple decks. If you wish to change only the current deck, please add a new options group first.\nscheduling-deck-updated =\n    { $count ->\n        [one] { $count } deck updated.\n       *[other] { $count } decks updated.\n    }\nscheduling-set-due-date-prompt =\n    { $cards ->\n        [one] Show card in how many days?\n       *[other] Show cards in how many days?\n    }\nscheduling-set-due-date-prompt-hint =\n    0 = today\n    1! = tomorrow + change interval to 1\n    3-7 = random choice of 3-7 days\nscheduling-set-due-date-done =\n    { $cards ->\n        [one] Set due date of { $cards } card.\n       *[other] Set due date of { $cards } cards.\n    }\nscheduling-graded-cards-done =\n    { $cards ->\n        [one] Graded { $cards } card.\n       *[other] Graded { $cards } cards.\n    }\nscheduling-forgot-cards =\n    { $cards ->\n        [one] Reset { $cards } card.\n       *[other] Reset { $cards } cards.\n    }\n"
  },
  {
    "path": "ftl/core/search.ftl",
    "content": "## Errors shown when invalid search input is encountered.\n## Backticks change the text formatting, so please don't change the backticks.\n## Text inside backticks should not be changed unless noted.\n## It's ok to change quotes outside of backticks however, eg:\n## \"`{ $context }`\" => 「`{ $context }`」\n\nsearch-invalid-search = Invalid search: { $reason }\nsearch-misplaced-and = an `and` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `\"and\"`.\nsearch-misplaced-or = an `or` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `\"or\"`.\n# Here, the ellipsis \"...\" may be localised.\nsearch-empty-group = a group `(...)` was found, but there was nothing between the brackets to search for. If you want to search for literal brackets, wrap them in double quotes: `\"( )\"`.\nsearch-unopened-group = a closing bracket `)` was found, but there was no opening bracket `(` preceding it. If you want to search for a literal `)`, wrap it in double quotes or prepend a backslash: `\")\"` or `\\)`.\nsearch-unclosed-group = an opening bracket `(` was found, but there was no closing bracket `)` following it. If you want to search for a literal `(`, wrap it in double quotes or prepend a backslash: `\"(\"` or `\\(` .\nsearch-empty-quote = a pair of double quotes `\"\"` was found, but there was nothing between them to search for. If you want to search for literal double quotes, prepend backslashes: `\\\"\\\"`.\nsearch-unclosed-quote = an opening double quote `\"` was found, but there was no second one to close it. If you want to search for a literal `\"`, prepend a backslash: `\\\"`.\nsearch-missing-key = a colon `:` was found, but there was no keyword preceding it. If you want to search for a literal `:`, prepend a backslash: `\\:`.\nsearch-unknown-escape = the escape sequence `{ $val }` is not defined. If you want to search for a literal backslash `\\`, prepend another one: `\\\\`.\nsearch-invalid-argument = `{ $term }` was given an invalid argument '`{ $argument }`'.\nsearch-invalid-flag-2 = `flag:` must be followed by a valid flag number: `1` (red), `2` (orange), `3` (green), `4` (blue), `5` (pink), `6` (turquoise), `7` (purple) or `0` (no flag).\nsearch-invalid-prop-operator = `prop:{ $val }` must be followed by one of the following comparison operators: `=`, `!=`, `<`, `>`, `<=` or `>=`.\nsearch-invalid-other = please check for typing mistakes.\n\n## eg. expected a number in \"due>5x\", but found \"5x\"\n\nsearch-invalid-number = expected a number in \"`{ $context }`\", but found \"`{ $provided }`\".\nsearch-invalid-whole-number = expected a whole number in \"`{ $context }`\", but found \"`{ $provided }`\".\nsearch-invalid-positive-whole-number = expected a positive whole number in \"`{ $context }`\", but found \"`{ $provided }`\".\nsearch-invalid-negative-whole-number = expected a whole number less than or equal to 0 in \"`{ $context }`\", but found \"`{ $provided }`\".\nsearch-invalid-answer-button = expected an answer button between 1-4 in \"`{ $context }`\", but found \"`{ $provided }`\".\n\n## Column labels in browse screen\n\nsearch-note-modified = Note Modified\nsearch-card-modified = Card Modified\n\n##\n\n# Tooltip for search lines outside browser\nsearch-view-in-browser = View in browser\n"
  },
  {
    "path": "ftl/core/statistics.ftl",
    "content": "# The date a card will be ready to review\nstatistics-due-date = Due\n# The count of cards waiting to be reviewed\nstatistics-due-count = Due\n# Shown in the Due column of the Browse screen when the card is a new card\nstatistics-due-for-new-card = New #{ $number }\n\n## eg 16.8s (3.6 cards/minute)\n\nstatistics-cards-per-min = { $cards-per-minute } cards/minute\nstatistics-average-answer-time = { $average-seconds }s ({ statistics-cards-per-min })\n\n## A span of time studying took place in, for example\n## \"(studied 30 cards) in 3 minutes\"\n\nstatistics-in-time-span-seconds =\n    { $amount ->\n        [one] in { $amount } second\n       *[other] in { $amount } seconds\n    }\nstatistics-in-time-span-minutes =\n    { $amount ->\n        [one] in { $amount } minute\n       *[other] in { $amount } minutes\n    }\nstatistics-in-time-span-hours =\n    { $amount ->\n        [one] in { $amount } hour\n       *[other] in { $amount } hours\n    }\nstatistics-in-time-span-days =\n    { $amount ->\n        [one] in { $amount } day\n       *[other] in { $amount } days\n    }\nstatistics-in-time-span-months =\n    { $amount ->\n        [one] in { $amount } month\n       *[other] in { $amount } months\n    }\nstatistics-in-time-span-years =\n    { $amount ->\n        [one] in { $amount } year\n       *[other] in { $amount } years\n    }\n# Shown at the bottom of the deck list, and in the statistics screen.\n# eg \"Studied 3 cards in 13 seconds today (4.33s/card).\"\n# The { statistics-in-time-span-seconds } part should be pasted in from the English\n# version unmodified.\nstatistics-studied-today =\n    Studied { statistics-cards }\n    { $unit ->\n        [seconds] { statistics-in-time-span-seconds }\n        [minutes] { statistics-in-time-span-minutes }\n        [hours] { statistics-in-time-span-hours }\n        [days] { statistics-in-time-span-days }\n        [months] { statistics-in-time-span-months }\n       *[years] { statistics-in-time-span-years }\n    } today\n    ({ $secs-per-card }s/card)\n\n##\n\nstatistics-cards =\n    { $cards ->\n        [one] { $cards } card\n       *[other] { $cards } cards\n    }\nstatistics-notes =\n    { $notes ->\n        [one] { $notes } note\n       *[other] { $notes } notes\n    }\n# a count of how many cards have been answered, eg \"Total: 34 reviews\"\nstatistics-reviews =\n    { $reviews ->\n        [one] { $reviews } review\n       *[other] { $reviews } reviews\n    }\n# This fragment of the tooltip in the FSRS simulation\n# diagram (Deck options -> FSRS) shows the total number of\n# cards that can be recalled or retrieved on a specific date.\nstatistics-memorized = {$memorized} cards memorized\nstatistics-today-title = Today\nstatistics-today-again-count = Again count:\nstatistics-today-type-counts = Learn: { $learnCount }, Review: { $reviewCount }, Relearn: { $relearnCount }, Filtered: { $filteredCount }\nstatistics-today-no-cards = No cards have been studied today.\nstatistics-today-no-mature-cards = No mature cards were studied today.\nstatistics-today-correct-mature = Correct answers on mature cards: { $correct }/{ $total } ({ $percent }%)\nstatistics-counts-total-cards = Total\nstatistics-counts-new-cards = New\nstatistics-counts-young-cards = Young\nstatistics-counts-mature-cards = Mature\nstatistics-counts-suspended-cards = Suspended\nstatistics-counts-buried-cards = Buried\nstatistics-counts-filtered-cards = Filtered\nstatistics-counts-learning-cards = Learning\nstatistics-counts-relearning-cards = Relearning\nstatistics-counts-title = Card Counts\nstatistics-counts-separate-suspended-buried-cards = Separate suspended/buried cards\n\n## Retention represents your actual retention from past reviews, in\n## comparison to the \"desired retention\" setting of FSRS, which forecasts\n## future retention. Retention is the percentage of all reviewed cards\n## that were marked as \"Hard,\" \"Good,\" or \"Easy\" within a specific time period.\n##\n## Most of these strings are used as column / row headings in a table.\n## (Excluding -title and -subtitle)\n## It is important to keep these translations short so that they do not make\n## the table too large to display on a single stats card.\n##\n## N.B. Stats cards may be very small on mobile devices and when the Stats\n##      window is certain sizes.\n\nstatistics-true-retention-title = Retention\nstatistics-true-retention-subtitle = Pass rate of cards with an interval ≥ 1 day.\nstatistics-true-retention-tooltip = If you are using FSRS, your retention is expected to be close to your desired retention. Please keep in mind that data for a single day is noisy, so it's better to look at monthly data.\nstatistics-true-retention-range = Range\nstatistics-true-retention-pass = Pass\nstatistics-true-retention-fail = Fail\n# This will usually be the same as statistics-counts-total-cards\nstatistics-true-retention-total = Total\nstatistics-true-retention-count = Count\nstatistics-true-retention-retention = Retention\n# This will usually be the same as statistics-counts-young-cards\nstatistics-true-retention-young = Young\n# This will usually be the same as statistics-counts-mature-cards\nstatistics-true-retention-mature = Mature\nstatistics-true-retention-all = All\nstatistics-true-retention-today = Today\nstatistics-true-retention-yesterday = Yesterday\nstatistics-true-retention-week = Last week\nstatistics-true-retention-month = Last month\nstatistics-true-retention-year = Last year\nstatistics-true-retention-all-time = All time\n# If there are no reviews within a specific time period, the retention\n# percentage cannot be calculated and is displayed as \"N/A.\"\nstatistics-true-retention-not-applicable = N/A\n\n##\n\nstatistics-range-all-time = all\nstatistics-range-1-year-history = last 12 months\nstatistics-range-all-history = all history\nstatistics-range-deck = deck\nstatistics-range-collection = collection\nstatistics-range-search = Search\nstatistics-card-ease-title = Card Ease\nstatistics-card-difficulty-title = Card Difficulty\nstatistics-card-stability-title = Card Stability\nstatistics-card-stability-subtitle = The delay at which retrievability falls to 90%.\nstatistics-median-stability = Median stability\nstatistics-card-retrievability-title = Card Retrievability\nstatistics-card-ease-subtitle = The lower the ease, the more frequently a card will appear.\nstatistics-card-difficulty-subtitle2 = The higher the difficulty, the slower stability will increase.\nstatistics-retrievability-subtitle = The probability of recalling a card today.\n# eg \"3 cards with 150-170% ease\"\nstatistics-card-ease-tooltip =\n    { $cards ->\n        [one] { $cards } card with { $percent } ease\n       *[other] { $cards } cards with { $percent } ease\n    }\nstatistics-card-difficulty-tooltip =\n    { $cards ->\n        [one] { $cards } card with { $percent } difficulty\n       *[other] { $cards } cards with { $percent } difficulty\n    }\nstatistics-retrievability-tooltip =\n    { $cards ->\n        [one] { $cards } card with { $percent } retrievability\n       *[other] { $cards } cards with { $percent } retrievability\n    }\nstatistics-future-due-title = Future Due\nstatistics-future-due-subtitle = The number of reviews due in the future.\nstatistics-added-title = Added\nstatistics-added-subtitle = The number of new cards you have added.\nstatistics-reviews-count-subtitle = The number of questions you have answered.\nstatistics-reviews-time-subtitle = The time taken to answer the questions.\nstatistics-answer-buttons-title = Answer Buttons\n# eg Button: 4\nstatistics-answer-buttons-button-number = Button\n# eg Times pressed: 123\nstatistics-answer-buttons-button-pressed = Times pressed\nstatistics-answer-buttons-subtitle = The number of times you have pressed each button.\nstatistics-reviews-title = Reviews\nstatistics-reviews-time-checkbox = Time\nstatistics-in-days-single =\n    { $days ->\n        [0] Today\n        [1] Tomorrow\n       *[other] In { $days } days\n    }\nstatistics-in-days-range = In { $daysStart }-{ $daysEnd } days\nstatistics-days-ago-single =\n    { $days ->\n        [1] Yesterday\n       *[other] { $days } days ago\n    }\nstatistics-days-ago-range = { $daysStart }-{ $daysEnd } days ago\nstatistics-running-total = Running total\nstatistics-cards-due =\n    { $cards ->\n        [one] { $cards } card due\n       *[other] { $cards } cards due\n    }\nstatistics-backlog-checkbox = Backlog\nstatistics-intervals-title = Review Intervals\nstatistics-intervals-subtitle = Delays until review cards are shown again.\nstatistics-intervals-day-range =\n    { $cards ->\n        [one] { $cards } card with a { $daysStart }~{ $daysEnd } day interval\n       *[other] { $cards } cards with a { $daysStart }~{ $daysEnd } day interval\n    }\nstatistics-intervals-day-single =\n    { $cards ->\n        [one] { $cards } card with a { $day } day interval\n       *[other] { $cards } cards with a { $day } day interval\n    }\nstatistics-stability-day-range =\n    { $cards ->\n        [one] { $cards } card with a { $daysStart }~{ $daysEnd } day stability\n       *[other] { $cards } cards with a { $daysStart }~{ $daysEnd } day stability\n    }\nstatistics-stability-day-single =\n    { $cards ->\n        [one] { $cards } card with a { $day } day stability\n       *[other] { $cards } cards with a { $day } day stability\n    }\n# hour range, eg \"From 14:00-15:00\"\nstatistics-hours-range = From { $hourStart }:00~{ $hourEnd }:00\nstatistics-hours-correct = { $correct }/{ $total } correct ({ $percent }%)\nstatistics-hours-correct-info = → (not 'Again')\n# the emoji depicts the graph displaying this number\nstatistics-hours-reviews = 📊 { $reviews } reviews\n# the emoji depicts the graph displaying this number\nstatistics-hours-correct-reviews = 📈 { $percent }% correct ({ $reviews })\nstatistics-hours-title = Hourly Breakdown\nstatistics-hours-subtitle = Review success rate for each hour of the day.\n# shown when graph is empty\nstatistics-no-data = NO DATA\nstatistics-calendar-title = Calendar\n\n## An amount of elapsed time, used in the graphs to show the amount of\n## time spent studying. For example, English would show \"5s\" for 5 seconds,\n## \"13.5m\" for 13.5 minutes, and so on.\n##\n## Please try to keep the text short, as longer text may get cut off.\n\nstatistics-elapsed-time-seconds = { $amount }s\nstatistics-elapsed-time-minutes = { $amount }m\nstatistics-elapsed-time-hours = { $amount }h\nstatistics-elapsed-time-days = { $amount }d\nstatistics-elapsed-time-months = { $amount }mo\nstatistics-elapsed-time-years = { $amount }y\n\n##\n\nstatistics-average-for-days-studied = Average for days studied\n# This term is used in a variety of contexts to refers to the total amount of\n# items (e.g., cards, mature cards, etc) for a given period, rather than the\n# total of all existing items.\nstatistics-total = Total\nstatistics-days-studied = Days studied\nstatistics-average-answer-time-label = Average answer time\nstatistics-average = Average\nstatistics-median-interval = Median interval\nstatistics-due-tomorrow = Due tomorrow\n# This string, ‘Daily load,’ appears in the ‘Future due’ table and represents a\n# forecasted estimate of the number of cards expected to be reviewed daily in \n# the future. Unlike the other strings in the table that display actual data \n# derived from the current scheduling (e.g., ‘Average’, ‘Due tomorrow’),\n# ‘Daily load’ is a projection based on the given data.\nstatistics-daily-load = Daily load\n# eg 5 of 15 (33.3%)\nstatistics-amount-of-total-with-percentage = { $amount } of { $total } ({ $percent }%)\nstatistics-average-over-period = Average over period\nstatistics-reviews-per-day =\n    { $count ->\n        [one] { $count } review/day\n       *[other] { $count } reviews/day\n    }\nstatistics-minutes-per-day =\n    { $count ->\n        [one] { $count } minute/day\n       *[other] { $count } minutes/day\n    }\nstatistics-cards-per-day =\n    { $count ->\n        [one] { $count } card/day\n       *[other] { $count } cards/day\n    }\nstatistics-median-ease = Median ease\nstatistics-median-difficulty = Median difficulty\nstatistics-average-retrievability = Average retrievability\nstatistics-estimated-total-knowledge = Estimated total knowledge\nstatistics-save-pdf = Save PDF\nstatistics-saved = Saved.\nstatistics-stats = stats\nstatistics-title = Statistics\n\n## These strings are no longer used - you do not need to translate them if they\n## are not already translated.\n\nstatistics-average-stability = Average stability\nstatistics-average-interval = Average interval\nstatistics-average-ease = Average ease\nstatistics-average-difficulty = Average difficulty\n"
  },
  {
    "path": "ftl/core/studying.ftl",
    "content": "studying-again = Again\nstudying-all-buried-cards = All Buried Cards\nstudying-audio-5s = Audio -5s\nstudying-audio-and5s = Audio +5s\nstudying-buried-siblings = Buried Siblings\nstudying-bury = Bury\nstudying-bury-card = Bury Card\nstudying-bury-note = Bury Note\nstudying-card-suspended = Card suspended.\nstudying-card-was-a-leech = Card was a leech.\nstudying-cards-buried =\n    { $count ->\n        [one] { $count } card buried.\n       *[other] { $count } cards buried.\n    }\nstudying-cards-will-be-automatically-returned-to = Cards will be automatically returned to their original decks after you review them.\nstudying-continue = Continue\nstudying-counts-differ = Counts differ from the deck list, because burying is enabled. Some cards have been excluded, and others may have taken their place.\nstudying-delete-note = Delete Note\nstudying-deleting-this-deck-from-the-deck = Deleting this deck from the deck list will return all remaining cards to their original deck.\nstudying-easy = Easy\nstudying-edit = Edit\nstudying-empty = Empty\nstudying-finish = Finish\nstudying-flag-card = Flag Card\nstudying-good = Good\nstudying-hard = Hard\nstudying-it-has-been-suspended = It has been suspended.\nstudying-manually-buried-cards = Manually Buried Cards\nstudying-mark-note = Mark Note\nstudying-more = More\nstudying-no-cards-are-due-yet = No cards are due yet.\nstudying-note-suspended = Note suspended.\nstudying-pause-audio = Pause Audio\nstudying-please-run-toolsempty-cards = Please run Tools>Empty Cards\nstudying-record-own-voice = Record Own Voice\nstudying-replay-own-voice = Replay Own Voice\nstudying-show-answer = Show Answer\nstudying-space = Space\nstudying-study-now = Study Now\nstudying-suspend = Suspend\nstudying-suspend-note = Suspend Note\nstudying-this-is-a-special-deck-for = This is a special deck for studying outside of the normal schedule.\nstudying-to-review = To Review\nstudying-type-answer-unknown-field = Type answer: unknown field { $val }\nstudying-unbury = Unbury\nstudying-what-would-you-like-to-unbury = What would you like to unbury?\nstudying-you-havent-recorded-your-voice-yet = You haven't recorded your voice yet.\nstudying-card-studied-in-minute =\n    { $cards ->\n        [one] { $cards } card\n       *[other] { $cards } cards\n    } studied in\n    { $minutes ->\n        [one] { $minutes } minute.\n       *[other] { $minutes } minutes.\n    }\nstudying-question-time-elapsed = Question time elapsed\nstudying-answer-time-elapsed = Answer time elapsed\n\n## OBSOLETE; you do not need to translate this\n\nstudying-card-studied-in =\n    { $count ->\n        [one] { $count } card studied in\n       *[other] { $count } cards studied in\n    }\nstudying-minute =\n    { $count ->\n        [one] { $count } minute.\n       *[other] { $count } minutes.\n    }\n"
  },
  {
    "path": "ftl/core/sync.ftl",
    "content": "### Messages shown when synchronizing with AnkiWeb.\n\n\n## Media synchronization\n\nsync-media-added-count = Added: { $up }↑ { $down }↓\nsync-media-removed-count = Removed: { $up }↑ { $down }↓\nsync-media-checked-count = Checked: { $count }\nsync-media-starting = Media sync starting...\nsync-media-complete = Media sync complete.\nsync-media-failed = Media sync failed.\nsync-media-aborting = Media sync aborting...\nsync-media-aborted = Media sync aborted.\n# Shown in the sync log to indicate media syncing will not be done, because it\n# was previously disabled by the user in the preferences screen.\nsync-media-disabled = Media sync disabled.\n# Title of the screen that shows syncing progress history\nsync-media-log-title = Media Sync Log\n\n## Error messages / dialogs\n\nsync-conflict = Only one copy of Anki can sync to your account at once. Please wait a few minutes, then try again.\nsync-server-error = AnkiWeb encountered a problem. Please try again in a few minutes.\nsync-client-too-old = Your Anki version is too old. Please update to the latest version to continue syncing.\nsync-wrong-pass = Email or password was incorrect; please try again.\nsync-resync-required = Please sync again. If this message keeps appearing, please post on the support site.\nsync-must-wait-for-end = Anki is currently syncing. Please wait for the sync to complete, then try again.\nsync-confirm-empty-download = Local collection has no cards. Download from AnkiWeb?\nsync-confirm-empty-upload = AnkiWeb collection has no cards. Replace it with local collection?\nsync-conflict-explanation =\n    Your decks here and on AnkiWeb differ in such a way that they can't be merged together, so it's necessary to overwrite the decks on one side with the decks from the other.\n    \n    If you choose download, Anki will fetch the collection from AnkiWeb, and any changes you have made on this device since the last sync will be lost.\n    \n    If you choose upload, Anki will send this device's data to AnkiWeb, and any changes that are waiting on AnkiWeb will be lost.\n    \n    After all devices are in sync, future reviews and added cards can be merged automatically.\nsync-conflict-explanation2 =\n    There is a conflict between decks on this device and AnkiWeb. You must choose which version to keep:\n\n    - Select **{ sync-download-from-ankiweb }** to replace decks here with AnkiWeb’s version. You will lose any changes you made on this device since your last sync.\n    - Select **{ sync-upload-to-ankiweb }** to overwrite AnkiWeb’s versions with decks from this device, and delete any changes on AnkiWeb.\n\n    Once the conflict is resolved, syncing will work as usual.\n\nsync-ankiweb-id-label = Email:\nsync-password-label = Password:\nsync-account-required =\n    <h1>Account Required</h1>\n    A free account is required to keep your collection synchronized. Please <a href=\"{ $link }\">sign up</a> for an account, then enter your details below.\nsync-sanity-check-failed = Please use the Check Database function, then sync again. If problems persist, please force a one-way sync in the preferences screen.\nsync-clock-off = Unable to sync - your clock is not set to the correct time.\n# “details” expands to a string such as “300.14 MB > 300.00 MB”\nsync-upload-too-large =\n    Your collection file is too large to send to AnkiWeb. You can reduce its size by removing any unwanted decks (optionally exporting them first), and then using Check Database to shrink the file size down.\n    \n    { $details } (uncompressed)\nsync-sign-in = Sign in\nsync-ankihub-dialog-heading = AnkiHub Login\nsync-ankihub-username-label = Username or Email:\nsync-ankihub-login-failed = Unable to log in to AnkiHub with the provided credentials.\nsync-ankihub-addon-installation = AnkiHub Add-on Installation\n\n## Buttons\n\nsync-media-log-button = Media Log\nsync-abort-button = Abort\nsync-download-from-ankiweb = Download from AnkiWeb\nsync-upload-to-ankiweb = Upload to AnkiWeb\nsync-cancel-button = Cancel\n\n## Normal sync progress\n\nsync-downloading-from-ankiweb = Downloading from AnkiWeb...\nsync-uploading-to-ankiweb = Uploading to AnkiWeb...\nsync-syncing = Syncing...\nsync-checking = Checking...\nsync-connecting = Connecting...\nsync-added-updated-count = Added/modified: { $up }↑ { $down }↓\nsync-log-in-button = Log In\nsync-log-out-button = Log Out\nsync-collection-complete = Collection sync complete.\n"
  },
  {
    "path": "ftl/core/undo.ftl",
    "content": "### The strings in this file are currently in development,\n### and you may want to skip translating them for now.\n\nundo-undo = Undo\nundo-redo = Redo\n# eg \"Undo Answer Card\"\nundo-undo-action = Undo { $val }\n# eg \"Answer Card Undone\"\nundo-action-undone = { $action } undone\nundo-redo-action = Redo { $action }\nundo-action-redone = { $action } redone\n"
  },
  {
    "path": "ftl/ftl",
    "content": "#!/bin/bash\n\ncd $(dirname $0)/..\ncargo run -p ftl -- $*\n"
  },
  {
    "path": "ftl/move-from-ankimobile",
    "content": "#!/bin/bash\n#\n# Move a translation that previously only existed in AnkiMobile to the core translations.\n#\n\n./ftl string move ftl/mobile-repo/mobile ftl/core-repo/core $*\n"
  },
  {
    "path": "ftl/qt/about.ftl",
    "content": "about-a-big-thanks-to-all-the = A big thanks to all the people who have provided suggestions, bug reports and donations.\nabout-about-anki = About Anki\nabout-anki-is-a-friendly-intelligent-spaced = Anki is a friendly, intelligent spaced learning system. It's free and open source.\nabout-anki-is-licensed-under-the-agpl3 = Anki is licensed under the AGPL3 license. Please see the license file in the source distribution for more information.\nabout-copied-to-clipboard = Copied to clipboard\nabout-copy-debug-info = Copy Debug Info\nabout-if-you-have-contributed-and-are = If you have contributed and are not on this list, please get in touch.\nabout-version = Version { $val }\nabout-visit-website = <a href='{ $val }'>Visit website</a>\nabout-written-by-damien-elmes-with-patches = Written by Damien Elmes, with patches, translation,    testing and design from:<p>{ $cont }\n# appended to the end of the contributor list in the about screen\nabout-and-others = and others\n"
  },
  {
    "path": "ftl/qt/addons.ftl",
    "content": "addons-possibly-involved = Add-ons possibly involved: { $addons }\naddons-failed-to-load =\n    An add-on you installed failed to load. If problems persist, please go to the Tools>Add-ons menu, and disable or delete the add-on.\n    \n    When loading '{ $name }':\n    { $traceback }\naddons-failed-to-load2 =\n    The following add-ons failed to load:\n    { $addons }\n\n    They may need to be updated to support this version of Anki. Click the { addons-check-for-updates } button\n    to see if any updates are available.\n\n    You can use the { about-copy-debug-info } button to get information that you can paste in a report to\n    the add-on author.\n\n    For add-ons that don't have an update available, you can disable or delete the add-on to prevent this\n    message from appearing.\naddons-startup-failed = Add-on Startup Failed\n# Shown in the add-on configuration screen (Tools>Add-ons>Config), in the title bar\naddons-config-window-title = Configure '{ $name }'\naddons-config-validation-error = There was a problem with the provided configuration: { $problem }, at path { $path }, against schema { $schema }.\naddons-window-title = Add-ons\naddons-addon-has-no-configuration = Add-on has no configuration.\naddons-addon-installation-error = Add-on installation error\naddons-browse-addons = Browse Add-ons\naddons-changes-will-take-effect-when-anki = Changes will take effect when Anki is restarted.\naddons-check-for-updates = Check for Updates\naddons-checking = Checking...\naddons-code = Code:\naddons-config = Config\naddons-configuration = Configuration\naddons-corrupt-addon-file = Corrupt add-on file.\naddons-disabled = (disabled)\naddons-disabled2 = (disabled)\naddons-download-complete-please-restart-anki-to = Download complete. Please restart Anki to apply changes.\naddons-downloaded-fnames = Downloaded { $fname }\naddons-downloading-adbd-kb02fkb = Downloading { $part }/{ $total } ({ $kilobytes }KB)...\naddons-error-downloading-ids-errors = Error downloading <i>{ $id }</i>: { $error }\naddons-error-installing-bases-errors = Error installing <i>{ $base }</i>: { $error }\naddons-get-addons = Get Add-ons...\naddons-important-as-addons-are-programs-downloaded = <b>Important</b>: As add-ons are programs downloaded from the internet, they are potentially malicious.<b>You should only install add-ons you trust.</b><br><br>Are you sure you want to proceed with the installation of the following Anki add-on(s)?<br><br>%(names)s\naddons-install-addon = Install Add-on\naddons-install-addons = Install Add-on(s)\naddons-install-anki-addon = Install Anki add-on\naddons-install-from-file = Install from file...\naddons-installation-complete = Installation complete\naddons-installed-names = Installed { $name }\naddons-installed-successfully = Installed successfully.\naddons-invalid-addon-manifest = Invalid add-on manifest.\naddons-invalid-code = Invalid code.\naddons-invalid-code-or-addon-not-available = Invalid code, or add-on not available for your version of Anki.\naddons-invalid-configuration = Invalid configuration:\naddons-invalid-configuration-top-level-object-must = Invalid configuration: top level object must be a map\naddons-no-updates-available = No updates available.\naddons-one-or-more-errors-occurred = One or more errors occurred:\naddons-packaged-anki-addon = Packaged Anki Add-on\naddons-please-check-your-internet-connection = Please check your internet connection.\naddons-please-report-this-to-the-respective = Please report this to the respective add-on author(s).\naddons-please-restart-anki-to-complete-the = <b>Please restart Anki to complete the installation.</b>\naddons-please-select-a-single-addon-first = Please select a single add-on first.\naddons-requires = (requires { $val })\naddons-restored-defaults = Restored defaults\naddons-the-following-addons-are-incompatible-with = The following add-ons are incompatible with { $name } and have been disabled: { $found }\naddons-the-following-addons-have-updates-available = The following add-ons have updates available. Install them now?\naddons-the-following-conflicting-addons-were-disabled = The following conflicting add-ons were disabled:\naddons-this-addon-is-not-compatible-with = This add-on is not compatible with your version of Anki.\naddons-to-browse-addons-please-click-the = To browse add-ons, please click the browse button below.<br><br>When you've found an add-on you like, please paste its code below. You can paste multiple codes, separated by spaces.\naddons-toggle-enabled = Toggle Enabled\naddons-unable-to-update-or-delete-addon = Unable to update or delete add-on. Please start Anki while holding down the shift key to temporarily disable add-ons, then try again.  Debug info: { $val }\naddons-unknown-error = Unknown error: { $val }\naddons-view-addon-page = View Add-on Page\naddons-view-files = View Files\naddons-delete-the-numd-selected-addon =\n    { $count ->\n        [one] Delete the { $count } selected add-on?\n       *[other] Delete the { $count } selected add-ons?\n    }\naddons-choose-update-window-title = Update Add-ons\naddons-choose-update-update-all = Update All\n"
  },
  {
    "path": "ftl/qt/errors.ftl",
    "content": "-errors-support-site = [support site](https://help.ankiweb.net)\nerrors-standard-popup2 =\n    Anki encountered a problem. Please follow the troubleshooting steps.\nerrors-may-be-addon = The problem may be caused by an add-on.\nerrors-troubleshooting-button = Troubleshooting\nerrors-copy-debug-info-button = Copy Debug Info\nerrors-copied-to-clipboard = Copied to clipboard\nerrors-standard-popup =\n    # Error\n    \n    An error occurred. Please use **Tools > Check Database** to see if\n    that fixes the problem.\n    \n    If problems persist, please report the problem on our { -errors-support-site }.\n    Please copy and paste the information below into your report.\nerrors-addons-active-popup =\n    # Error\n    \n    An error occurred. Please start Anki while holding down the shift key,\n    which will temporarily disable the add-ons you have installed.\n    \n    If the issue only occurs when add-ons are enabled, please use the\n    Tools > Add-ons menu item to disable some add-ons and restart Anki,\n    repeating until you discover the add-on that is causing the problem.\n    \n    When you've discovered the add-on that is causing the problem, please\n    report the issue to the add-on author.\n    \n    Debug info:\nerrors-accessing-db =\n    An error occurred while accessing the database.\n    \n    Possible causes:\n    \n    - Antivirus, firewall, backup, or synchronization software may be\n    interfering with Anki. Try disabling such software and see if the\n    problem goes away.\n    - Your disk may be full.\n    - The Documents/Anki folder may be on a network drive.\n    - Files in the Documents/Anki folder may not be writeable.\n    - Your hard disk may have errors.\n    \n    It's a good idea to run Tools>Check Database to ensure your collection is not corrupt.\nerrors-unable-open-collection =\n    Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager.\n    \n    Debug info:\nerrors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, or try a different voice.\nerrors-windows-ssl-updates = Secure connection failed. Please ensure Windows updates are installed, then try again.\n\n## OBSOLETE; you do not need to translate this\n\n"
  },
  {
    "path": "ftl/qt/preferences.ftl",
    "content": "## Video drivers/hardware acceleration. Please avoid translating 'OpenGL' and 'ANGLE'.\n\npreferences-video-driver = Video driver\npreferences-video-driver-opengl-mac = OpenGL (recommended on Macs)\npreferences-video-driver-software-mac = Software (not recommended)\npreferences-video-driver-opengl-other = OpenGL (faster, may cause issues)\npreferences-video-driver-software-other = Software (slower)\npreferences-video-driver-angle = ANGLE (may work better than OpenGL)\npreferences-video-driver-default = default\n"
  },
  {
    "path": "ftl/qt/profiles.ftl",
    "content": "profiles-folder-readme =\n    This folder stores all of your Anki data in a single location,\n    to make backups easy. To tell Anki to use a different location,\n    please see:\n    \n    { $link }\n# will appear as 'Downgrade & Quit'\nprofiles-downgrade-and-quit = Downgrade && Quit\n"
  },
  {
    "path": "ftl/qt/qt-accel.ftl",
    "content": "qt-accel-about = &About\nqt-accel-about-mac = About Anki...\nqt-accel-cards = &Cards\nqt-accel-check-database = &Check Database\nqt-accel-check-media = Check &Media\nqt-accel-edit = &Edit\nqt-accel-exit = E&xit\nqt-accel-export = &Export...\nqt-accel-export-notes = &Export Notes...\nqt-accel-file = &File\nqt-accel-filter = Fil&ter\nqt-accel-find = &Find\nqt-accel-find-and-replace = Find and Re&place...\nqt-accel-find-duplicates = Find &Duplicates...\nqt-accel-go = &Go\nqt-accel-guide = &Guide\nqt-accel-help = &Help\nqt-accel-import = &Import...\nqt-accel-info = &Info...\nqt-accel-invert-selection = &Invert Selection\nqt-accel-next-card = &Next Card\nqt-accel-note = N&ote\nqt-accel-notes = &Notes\nqt-accel-preferences = &Preferences\nqt-accel-previous-card = &Previous Card\nqt-accel-select-all = Select &All\nqt-accel-select-notes = Select &Notes\nqt-accel-support-anki = &Support Anki\nqt-accel-switch-profile = &Switch Profile\nqt-accel-tools = &Tools\nqt-accel-undo = &Undo\nqt-accel-redo = &Redo\nqt-accel-set-due-date = Set &Due Date...\nqt-accel-forget = &Reset\nqt-accel-view = &View\nqt-accel-full-screen = Toggle &Full Screen\nqt-accel-layout = &Layout\nqt-accel-layout-auto = &Auto\nqt-accel-layout-vertical = &Vertical\nqt-accel-layout-horizontal = &Horizontal\nqt-accel-zoom-in = Zoom &In\nqt-accel-zoom-out = Zoom &Out\nqt-accel-reset-zoom = &Reset Zoom\nqt-accel-toggle-sidebar = Toggle Sidebar\nqt-accel-zoom-editor-in = Zoom Editor &In\nqt-accel-zoom-editor-out = Zoom Editor &Out\nqt-accel-create-backup = Create &Backup\nqt-accel-load-backup = &Revert to Backup\nqt-accel-upgrade-downgrade = Upgrade/Downgrade\n"
  },
  {
    "path": "ftl/qt/qt-misc.ftl",
    "content": "qt-misc-addon-will-be-installed-when-a = Add-on will be installed when a profile is opened.\nqt-misc-addons = Add-ons\nqt-misc-all-cards-notes-and-media-for = All cards, notes, and media for this profile will be deleted. Are you sure?\nqt-misc-all-cards-notes-and-media-for2 = All cards, notes, and media for the profile \"{ $name }\" will be deleted. Are you sure?\nqt-misc-anki-updatedanki-has-been-released = <h1>Anki Updated</h1>Anki { $val } has been released.<br><br>\nqt-misc-automatic-syncing-and-backups-have-been = Backup successfully restored. Automatic syncing and backups have been disabled for now. To enable them again, close the profile or restart Anki.\nqt-misc-back-side-only = Back Side Only\nqt-misc-backing-up = Backing Up...\nqt-misc-browse = Browse\nqt-misc-change-note-type-ctrlandn = Change Note Type (Ctrl+N)\nqt-misc-check-the-files-in-the-media = Check the files in the media directory\nqt-misc-choose-deck = Choose Deck\nqt-misc-choose-note-type = Choose Note Type\nqt-misc-closing = Closing...\nqt-misc-configure-interface-language-and-options = Configure interface language and options\nqt-misc-copy-to-clipboard = Copy to Clipboard\nqt-misc-create-filtered-deck = Create Filtered Deck...\nqt-misc-debug-console = Debug Console\nqt-misc-deck-will-be-imported-when-a = Deck will be imported when a profile is opened.\nqt-misc-empty-cards = Empty Cards...\nqt-misc-error-during-startup = Error during startup: { $val }\nqt-misc-ignore-this-update = Ignore this update\nqt-misc-in-order-to-ensure-your-collection = In order to ensure your collection works correctly when moved between devices, Anki requires your computer's internal clock to be set correctly. The internal clock can be wrong even if your system is showing the correct local time.<br><br>Please go to the time settings on your computer and check the following:<br><br>- AM/PM<br>- Clock drift<br>- Day, month and year<br>- Timezone<br>- Daylight savings<br><br>Difference to correct time: { $val }.\nqt-misc-invalid-property-found-on-card-please = Invalid property found on card. Please use Tools>Check Database, and if the problem comes up again, please ask on the support site.\nqt-misc-loading = Loading...\nqt-misc-manage = Manage\nqt-misc-manage-note-types = Manage Note Types\nqt-misc-name-exists = Name exists.\nqt-misc-non-unicode-text = <non-unicode text>\nqt-misc-optimizing = Optimizing...\nqt-misc-unable-to-record =\n    Unable to record. Please ensure a microphone is connected, and Anki has permission to use the microphone.\n    If other programs are using your microphone, closing them may help.\n    \n    Original error: { $error }\nqt-misc-please-ensure-a-profile-is-open = Please ensure a profile is open and Anki is not busy, then try again.\nqt-misc-please-select-1-card = (please select 1 card)\nqt-misc-please-select-a-deck = Please select a deck.\nqt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file.\nqt-misc-processing = Processing...\nqt-misc-replace-your-collection-with-an-earlier2 = Replace your collection with an earlier backup from { $val }?\nqt-misc-revert-to-backup = Revert to backup\n# please do not change the quote character, and please only change the font name if you have confirmed the new name is a valid Windows font\nqt-misc-segoe-ui = \"Segoe UI\"\nqt-misc-shift-key-was-held-down-skipping = Shift key was held down. Skipping automatic syncing and add-on loading.\nqt-misc-shortcut-key-left-arrow = Shortcut key: Left arrow\nqt-misc-shortcut-key-right-arrow-or-enter = Shortcut key: Right arrow or Enter\nqt-misc-stats = Stats\nqt-misc-study-deck = Study Deck...\nqt-misc-sync = Sync\nqt-misc-target-deck-ctrlandd = Target Deck (Ctrl+D)\nqt-misc-the-following-character-can-not-be = The following character can not be used: { $val }\nqt-misc-the-requested-change-will-require-a = The requested change will require a full upload of the database when you next synchronize your collection. If you have reviews or other changes waiting on another device that haven't been synchronized here yet, they will be lost. Continue?\nqt-misc-there-must-be-at-least-one = There must be at least one profile.\nqt-misc-this-file-exists-are-you-sure = This file exists. Are you sure you want to overwrite it?\nqt-misc-unable-to-access-anki-media-folder = Unable to access Anki media folder. The permissions on your system's temporary folder may be incorrect.\nqt-misc-unexpected-response-code = Unexpected response code: { $val }\nqt-misc-would-you-like-to-download-it = Would you like to download it now?\nqt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen.\nqt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again.\nqt-misc-your-firewall-or-antivirus-program-is = Your firewall or antivirus program is preventing Anki from creating a connection to itself. Please add an exception for Anki.\nqt-misc-error = Error\nqt-misc-no-temp-folder = No usable temporary folder found. Make sure C:\\\\temp exists or TEMP in your environment points to a valid, writable folder.\nqt-misc-incompatible-video-driver = Your video driver is incompatible. Please start Anki again, and Anki will switch to a slower, more compatible mode.\nqt-misc-error-loading-graphics-driver = Error loading '{ $mode }' graphics driver. Please start Anki again to try the next driver. { $context }\nqt-misc-anki-is-running = Anki Already Running\nqt-misc-if-instance-is-not-responding = If the existing instance of Anki is not responding, please close it using your task manager, or restart your computer.\nqt-misc-second =\n    { $count ->\n        [one] { $count } second\n       *[other] { $count } seconds\n    }\nqt-misc-layout-auto-enabled = Responsive layout enabled\nqt-misc-layout-vertical-enabled = Vertical layout enabled\nqt-misc-layout-horizontal-enabled = Horizontal layout enabled\nqt-misc-open-anki-launcher = Change to a different Anki version?\n\n## deprecated- these strings will be removed in the future, and do not need\n## to be translated\n\nqt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup?\n"
  },
  {
    "path": "ftl/remove-unused.sh",
    "content": "#!/bin/bash\n#\n# To use, run:\n#\n# - ./update-ankimobile-usage.sh\n# - ./remove-unused.sh\n#\n# If you need to maintain compatibility with an older stable branch, you\n# can use ./update-desktop-usage.sh in the older release, then copy the\n# generated file into usage/ with a different name.\n#   \n# Caveats:\n#   - Messages are considered in use if they are referenced in other messages,\n#     even if those messages themselves are not in use and going to be deleted.\n#   - Usually, if there is a bug and a message is failed to be recognised as in\n#     use, building will fail. However, this is not true for nested message, for\n#     which only a runtime error will be printed.\n\nset -e\n\nroot=$(realpath $(dirname $0)/..)\n\n# update currently used keys\n./update-desktop-usage.sh head\n\n# then remove unused keys\nbazel run //rslib/i18n_helpers:garbage_collect_ftl_entries $root/ftl $root/ftl/usage\n"
  },
  {
    "path": "ftl/src/garbage_collection.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::fs;\nuse std::io::BufReader;\nuse std::iter::FromIterator;\nuse std::path::PathBuf;\nuse std::sync::LazyLock;\n\nuse anki_io::create_file;\nuse anyhow::Context;\nuse anyhow::Result;\nuse clap::Args;\nuse fluent_syntax::ast;\nuse fluent_syntax::ast::Resource;\nuse fluent_syntax::parser;\nuse regex::Regex;\nuse walkdir::DirEntry;\nuse walkdir::WalkDir;\n\nuse crate::serialize;\n\n#[derive(Args)]\npub struct WriteJsonArgs {\n    target_filename: PathBuf,\n    source_roots: Vec<String>,\n}\n\n#[derive(Args)]\npub struct GarbageCollectArgs {\n    json_root: String,\n    ftl_roots: Vec<String>,\n}\n\n#[derive(Args)]\npub struct DeprecateEntriesArgs {\n    #[clap(long, num_args(1..), required(true))]\n    ftl_roots: Vec<String>,\n    #[clap(long, num_args(1..), required(true))]\n    source_roots: Vec<String>,\n    #[clap(long, num_args(1..), required(true))]\n    json_roots: Vec<String>,\n}\n\nconst DEPCRATION_WARNING: &str =\n    \"NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.\";\n\n/// Extract references from all Rust, Python, TS, Svelte, Swift, Kotlin and\n/// Designer files in the `roots`, convert them to kebab case and write them as\n/// a json to the target file.\npub fn write_ftl_json(args: WriteJsonArgs) -> Result<()> {\n    let refs = gather_ftl_references(&args.source_roots);\n    let mut refs = Vec::from_iter(refs);\n    refs.sort();\n    serde_json::to_writer_pretty(create_file(args.target_filename)?, &refs)\n        .context(\"writing json\")?;\n\n    Ok(())\n}\n\n/// Delete every entry in `ftl_root` that is not mentioned in another message\n/// or any json in `json_root`.\npub fn garbage_collect_ftl_entries(args: GarbageCollectArgs) -> Result<()> {\n    let used_ftls = get_all_used_messages_and_terms(&args.json_root, &args.ftl_roots);\n    strip_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls);\n    Ok(())\n}\n\n/// Moves every entry in `ftl_roots` that is not mentioned in another message, a\n/// source file or any json in `json_roots` to the bottom of its file below a\n/// deprecation warning.\npub fn deprecate_ftl_entries(args: DeprecateEntriesArgs) -> Result<()> {\n    let mut used_ftls = gather_ftl_references(&args.source_roots);\n    import_messages_from_json(&args.json_roots, &mut used_ftls);\n    extract_nested_messages_and_terms(&args.ftl_roots, &mut used_ftls);\n    deprecate_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls);\n    Ok(())\n}\n\nfn get_all_used_messages_and_terms(\n    json_root: &str,\n    ftl_roots: &[impl AsRef<str>],\n) -> HashSet<String> {\n    let mut used_ftls = HashSet::new();\n    import_messages_from_json(&[json_root], &mut used_ftls);\n    extract_nested_messages_and_terms(ftl_roots, &mut used_ftls);\n    used_ftls\n}\n\nfn for_files_with_ending(\n    roots: &[impl AsRef<str>],\n    file_ending: &str,\n    mut op: impl FnMut(DirEntry),\n) {\n    for root in roots {\n        for res in WalkDir::new(root.as_ref()) {\n            let entry = res.expect(\"failed to visit dir\");\n            if entry.file_type().is_file()\n                && entry\n                    .file_name()\n                    .to_str()\n                    .expect(\"non-unicode filename\")\n                    .ends_with(file_ending)\n            {\n                op(entry);\n            }\n        }\n    }\n}\n\nfn gather_ftl_references(roots: &[impl AsRef<str>]) -> HashSet<String> {\n    let mut refs = HashSet::new();\n    for_files_with_ending(roots, \"\", |entry| {\n        extract_references_from_file(&mut refs, &entry)\n    });\n    refs\n}\n\n/// Iterates over all .ftl files in `root`, parses them and rewrites the file if\n/// `op` decides to return a new AST.\nfn rewrite_ftl_files(\n    roots: &[impl AsRef<str>],\n    mut op: impl FnMut(Resource<&str>) -> Option<Resource<&str>>,\n) {\n    for_files_with_ending(roots, \".ftl\", |entry| {\n        let ftl = fs::read_to_string(entry.path()).expect(\"failed to open file\");\n        let ast = parser::parse(ftl.as_str()).expect(\"failed to parse ftl\");\n        if let Some(ast) = op(ast) {\n            fs::write(entry.path(), serialize::serialize(&ast)).expect(\"failed to write file\");\n        }\n    });\n}\n\nfn import_messages_from_json(json_roots: &[impl AsRef<str>], entries: &mut HashSet<String>) {\n    for_files_with_ending(json_roots, \".json\", |entry| {\n        let buffer = BufReader::new(fs::File::open(entry.path()).expect(\"failed to open file\"));\n        let refs: Vec<String> = serde_json::from_reader(buffer).expect(\"failed to parse json\");\n        entries.extend(refs);\n    })\n}\n\nfn extract_nested_messages_and_terms(\n    ftl_roots: &[impl AsRef<str>],\n    used_ftls: &mut HashSet<String>,\n) {\n    static REFERENCE: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"\\{\\s*-?([-0-9a-z]+)\\s*\\}\").unwrap());\n    for_files_with_ending(ftl_roots, \".ftl\", |entry| {\n        let source = fs::read_to_string(entry.path()).expect(\"file not readable\");\n        for caps in REFERENCE.captures_iter(&source) {\n            used_ftls.insert(caps[1].to_string());\n        }\n    })\n}\n\nfn strip_unused_ftl_messages_and_terms(roots: &[impl AsRef<str>], used_ftls: &HashSet<String>) {\n    rewrite_ftl_files(roots, |mut ast| {\n        let num_entries = ast.body.len();\n        ast.body.retain(entry_use_check(used_ftls));\n        (ast.body.len() < num_entries).then_some(ast)\n    });\n}\n\nfn deprecate_unused_ftl_messages_and_terms(roots: &[impl AsRef<str>], used_ftls: &HashSet<String>) {\n    rewrite_ftl_files(roots, |ast| {\n        let (mut used, mut unused): (Vec<_>, Vec<_>) =\n            ast.body.into_iter().partition(entry_use_check(used_ftls));\n        if unused.is_empty() {\n            None\n        } else {\n            append_deprecation_warning(&mut used);\n            used.append(&mut unused);\n            Some(Resource { body: used })\n        }\n    });\n}\n\nfn append_deprecation_warning(entries: &mut Vec<ast::Entry<&str>>) {\n    entries.retain(|entry| match entry {\n        ast::Entry::GroupComment(ast::Comment { content }) => {\n            !matches!(content.first(), Some(&DEPCRATION_WARNING))\n        }\n        _ => true,\n    });\n    entries.push(ast::Entry::GroupComment(ast::Comment {\n        content: vec![DEPCRATION_WARNING],\n    }));\n}\n\nfn entry_use_check(used_ftls: &HashSet<String>) -> impl Fn(&ast::Entry<&str>) -> bool + '_ {\n    |entry: &ast::Entry<&str>| match entry {\n        ast::Entry::Message(msg) => used_ftls.contains(msg.id.name),\n        ast::Entry::Term(term) => used_ftls.contains(term.id.name),\n        _ => true,\n    }\n}\n\nfn extract_references_from_file(refs: &mut HashSet<String>, entry: &DirEntry) {\n    static SNAKECASE_TR: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"\\Wtr\\s*\\.([0-9a-z_]+)\\W\").unwrap());\n    static CAMELCASE_TR: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"\\Wtr2?\\.([0-9A-Za-z_]+)\\W\").unwrap());\n    static DESIGNER_STYLE_TR: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"<string>([0-9a-z_]+)</string>\").unwrap());\n\n    let file_name = entry.file_name().to_str().expect(\"non-unicode filename\");\n\n    let (regex, case_conversion): (&Regex, fn(&str) -> String) =\n        if file_name.ends_with(\".rs\") || file_name.ends_with(\".py\") {\n            (&SNAKECASE_TR, snake_to_kebab_case)\n        } else if file_name.ends_with(\".ts\")\n            || file_name.ends_with(\".svelte\")\n            || file_name.ends_with(\".swift\")\n            || file_name.ends_with(\".kt\")\n        {\n            (&CAMELCASE_TR, camel_to_kebab_case)\n        } else if file_name.ends_with(\".ui\") {\n            (&DESIGNER_STYLE_TR, snake_to_kebab_case)\n        } else {\n            return;\n        };\n\n    let source = fs::read_to_string(entry.path()).expect(\"file not readable\");\n    for caps in regex.captures_iter(&source) {\n        refs.insert(case_conversion(&caps[1]));\n    }\n}\n\nfn snake_to_kebab_case(name: &str) -> String {\n    name.replace('_', \"-\")\n}\n\nfn camel_to_kebab_case(name: &str) -> String {\n    let mut kebab = String::with_capacity(name.len() + 8);\n    for ch in name.chars() {\n        if ch.is_ascii_uppercase() || ch == '_' {\n            kebab.push('-');\n        }\n        if ch != '_' {\n            kebab.push(ch.to_ascii_lowercase());\n        }\n    }\n    kebab\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn case_conversion() {\n        assert_eq!(snake_to_kebab_case(\"foo\"), \"foo\");\n        assert_eq!(snake_to_kebab_case(\"foo_bar\"), \"foo-bar\");\n        assert_eq!(snake_to_kebab_case(\"foo_123\"), \"foo-123\");\n        assert_eq!(snake_to_kebab_case(\"foo123\"), \"foo123\");\n\n        assert_eq!(camel_to_kebab_case(\"foo\"), \"foo\");\n        assert_eq!(camel_to_kebab_case(\"fooBar\"), \"foo-bar\");\n        assert_eq!(camel_to_kebab_case(\"foo_123\"), \"foo-123\");\n        assert_eq!(camel_to_kebab_case(\"foo123\"), \"foo123\");\n        assert_eq!(camel_to_kebab_case(\"123foo\"), \"123foo\");\n        assert_eq!(camel_to_kebab_case(\"123Foo\"), \"123-foo\");\n    }\n}\n"
  },
  {
    "path": "ftl/src/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod garbage_collection;\nmod serialize;\nmod string;\nmod sync;\n\nuse anyhow::Result;\nuse clap::Parser;\nuse clap::Subcommand;\nuse garbage_collection::deprecate_ftl_entries;\nuse garbage_collection::garbage_collect_ftl_entries;\nuse garbage_collection::write_ftl_json;\nuse garbage_collection::DeprecateEntriesArgs;\nuse garbage_collection::GarbageCollectArgs;\nuse garbage_collection::WriteJsonArgs;\n\nuse crate::string::string_operation;\nuse crate::string::StringCommand;\n\n#[derive(Parser)]\nstruct Cli {\n    #[command(subcommand)]\n    command: Command,\n}\n\n#[derive(Subcommand)]\nenum Command {\n    /// Update commit references to the latest translations,\n    /// and copy source files to the translation repos. Requires access to the\n    /// i18n repos to run.\n    Sync,\n    /// Extract references from all Rust, Python, TS, Svelte and Designer files\n    /// in the given roots, convert them to ftl names case and write them as\n    /// a json to the target file.\n    WriteJson(WriteJsonArgs),\n    /// Delete every entry in the ftl files that is not mentioned in another\n    /// message or a given json.\n    GarbageCollect(GarbageCollectArgs),\n    /// Deprecate unused ftl entries by moving them to the bottom of the file\n    /// and adding a deprecation warning. An entry is considered unused if\n    /// cannot be found in a source or JSON file.\n    Deprecate(DeprecateEntriesArgs),\n    /// Operations on individual messages and their translations.\n    #[clap(subcommand)]\n    String(StringCommand),\n}\n\nfn main() -> Result<()> {\n    match Cli::parse().command {\n        Command::Sync => sync::sync(),\n        Command::WriteJson(args) => write_ftl_json(args),\n        Command::GarbageCollect(args) => garbage_collect_ftl_entries(args),\n        Command::Deprecate(args) => deprecate_ftl_entries(args),\n        Command::String(args) => string_operation(args),\n    }\n}\n"
  },
  {
    "path": "ftl/src/serialize.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// copied from https://github.com/projectfluent/fluent-rs/pull/241\n\nuse std::fmt;\nuse std::fmt::Error;\nuse std::fmt::Write;\n\nuse fluent_syntax::ast::*;\nuse fluent_syntax::parser::Slice;\n\npub fn serialize<'s, S: Slice<'s>>(resource: &Resource<S>) -> String {\n    serialize_with_options(resource, Options::default())\n}\n\npub fn serialize_with_options<'s, S: Slice<'s>>(\n    resource: &Resource<S>,\n    options: Options,\n) -> String {\n    let mut ser = Serializer::new(options);\n\n    ser.serialize_resource(resource)\n        .expect(\"Writing to an in-memory buffer never fails\");\n\n    ser.into_serialized_text()\n}\n\n#[derive(Debug)]\npub struct Serializer {\n    writer: TextWriter,\n    options: Options,\n    state: State,\n}\n\nimpl Serializer {\n    pub fn new(options: Options) -> Self {\n        Serializer {\n            writer: TextWriter::default(),\n            options,\n            state: State::default(),\n        }\n    }\n\n    pub fn serialize_resource<'s, S: Slice<'s>>(&mut self, res: &Resource<S>) -> Result<(), Error> {\n        for entry in &res.body {\n            match entry {\n                Entry::Message(msg) => self.serialize_message(msg)?,\n                Entry::Term(term) => self.serialize_term(term)?,\n                Entry::Comment(comment) => self.serialize_free_comment(comment, \"#\")?,\n                Entry::GroupComment(comment) => self.serialize_free_comment(comment, \"##\")?,\n                Entry::ResourceComment(comment) => self.serialize_free_comment(comment, \"###\")?,\n                Entry::Junk { content } if self.options.with_junk => {\n                    self.serialize_junk(content.as_ref())?\n                }\n                Entry::Junk { .. } => continue,\n            }\n\n            self.state.has_entries = true;\n        }\n\n        Ok(())\n    }\n\n    pub fn into_serialized_text(self) -> String {\n        self.writer.buffer\n    }\n\n    fn serialize_junk(&mut self, junk: &str) -> Result<(), Error> {\n        self.writer.write_literal(junk)\n    }\n\n    fn serialize_free_comment<'s, S: Slice<'s>>(\n        &mut self,\n        comment: &Comment<S>,\n        prefix: &str,\n    ) -> Result<(), Error> {\n        if self.state.has_entries {\n            self.writer.newline();\n        }\n        self.serialize_comment(comment, prefix)?;\n        self.writer.newline();\n\n        Ok(())\n    }\n\n    fn serialize_comment<'s, S: Slice<'s>>(\n        &mut self,\n        comment: &Comment<S>,\n        prefix: &str,\n    ) -> Result<(), Error> {\n        for line in &comment.content {\n            self.writer.write_literal(prefix)?;\n\n            if !line.as_ref().trim().is_empty() {\n                self.writer.write_literal(\" \")?;\n                self.writer.write_literal(line.as_ref())?;\n            }\n\n            self.writer.newline();\n        }\n\n        Ok(())\n    }\n\n    fn serialize_message<'s, S: Slice<'s>>(&mut self, msg: &Message<S>) -> Result<(), Error> {\n        if let Some(comment) = msg.comment.as_ref() {\n            self.serialize_comment(comment, \"#\")?;\n        }\n\n        self.writer.write_literal(msg.id.name.as_ref())?;\n        self.writer.write_literal(\" =\")?;\n\n        if let Some(value) = msg.value.as_ref() {\n            self.serialize_pattern(value)?;\n        }\n\n        self.serialize_attributes(&msg.attributes)?;\n\n        self.writer.newline();\n        Ok(())\n    }\n\n    fn serialize_term<'s, S: Slice<'s>>(&mut self, term: &Term<S>) -> Result<(), Error> {\n        if let Some(comment) = term.comment.as_ref() {\n            self.serialize_comment(comment, \"#\")?;\n        }\n\n        self.writer.write_literal(\"-\")?;\n        self.writer.write_literal(term.id.name.as_ref())?;\n        self.writer.write_literal(\" =\")?;\n        self.serialize_pattern(&term.value)?;\n\n        self.serialize_attributes(&term.attributes)?;\n\n        self.writer.newline();\n\n        Ok(())\n    }\n\n    fn serialize_pattern<'s, S: Slice<'s>>(&mut self, pattern: &Pattern<S>) -> Result<(), Error> {\n        let start_on_newline = pattern.elements.iter().any(|elem| match elem {\n            PatternElement::TextElement { value } => value.as_ref().contains('\\n'),\n            PatternElement::Placeable { expression } => is_select_expr(expression),\n        });\n\n        if start_on_newline {\n            self.writer.newline();\n            self.writer.indent();\n        } else {\n            self.writer.write_literal(\" \")?;\n        }\n\n        for element in &pattern.elements {\n            self.serialize_element(element)?;\n        }\n\n        if start_on_newline {\n            self.writer.dedent();\n        }\n\n        Ok(())\n    }\n\n    fn serialize_attributes<'s, S: Slice<'s>>(\n        &mut self,\n        attrs: &[Attribute<S>],\n    ) -> Result<(), Error> {\n        if attrs.is_empty() {\n            return Ok(());\n        }\n\n        self.writer.indent();\n\n        for attr in attrs {\n            self.writer.newline();\n            self.serialize_attribute(attr)?;\n        }\n\n        self.writer.dedent();\n\n        Ok(())\n    }\n\n    fn serialize_attribute<'s, S: Slice<'s>>(&mut self, attr: &Attribute<S>) -> Result<(), Error> {\n        self.writer.write_literal(\".\")?;\n        self.writer.write_literal(attr.id.name.as_ref())?;\n        self.writer.write_literal(\" =\")?;\n\n        self.serialize_pattern(&attr.value)?;\n\n        Ok(())\n    }\n\n    fn serialize_element<'s, S: Slice<'s>>(\n        &mut self,\n        elem: &PatternElement<S>,\n    ) -> Result<(), Error> {\n        match elem {\n            PatternElement::TextElement { value } => self.writer.write_literal(value.as_ref()),\n            PatternElement::Placeable { expression } => match expression {\n                Expression::Inline(InlineExpression::Placeable { expression }) => {\n                    // A placeable inside a placeable is a special case because we\n                    // don't want the braces to look silly (e.g. \"{ { Foo() } }\").\n                    self.writer.write_literal(\"{{ \")?;\n                    self.serialize_expression(expression)?;\n                    self.writer.write_literal(\" }}\")?;\n                    Ok(())\n                }\n                Expression::Select { .. } => {\n                    // select adds its own newline and indent, emit the brace\n                    // *without* a space so we don't get 5 spaces instead of 4\n                    self.writer.write_literal(\"{ \")?;\n                    self.serialize_expression(expression)?;\n                    self.writer.write_literal(\"}\")?;\n                    Ok(())\n                }\n                Expression::Inline(_) => {\n                    self.writer.write_literal(\"{ \")?;\n                    self.serialize_expression(expression)?;\n                    self.writer.write_literal(\" }\")?;\n                    Ok(())\n                }\n            },\n        }\n    }\n\n    fn serialize_expression<'s, S: Slice<'s>>(\n        &mut self,\n        expr: &Expression<S>,\n    ) -> Result<(), Error> {\n        match expr {\n            Expression::Inline(inline) => self.serialize_inline_expression(inline),\n            Expression::Select { selector, variants } => {\n                self.serialize_select_expression(selector, variants)\n            }\n        }\n    }\n\n    fn serialize_inline_expression<'s, S: Slice<'s>>(\n        &mut self,\n        expr: &InlineExpression<S>,\n    ) -> Result<(), Error> {\n        match expr {\n            InlineExpression::StringLiteral { value } => {\n                self.writer.write_literal(\"\\\"\")?;\n                self.writer.write_literal(value.as_ref())?;\n                self.writer.write_literal(\"\\\"\")?;\n                Ok(())\n            }\n            InlineExpression::NumberLiteral { value } => self.writer.write_literal(value.as_ref()),\n            InlineExpression::VariableReference {\n                id: Identifier { name: value },\n            } => {\n                self.writer.write_literal(\"$\")?;\n                self.writer.write_literal(value.as_ref())?;\n                Ok(())\n            }\n            InlineExpression::FunctionReference { id, arguments } => {\n                self.writer.write_literal(id.name.as_ref())?;\n                self.serialize_call_arguments(arguments)?;\n\n                Ok(())\n            }\n            InlineExpression::MessageReference { id, attribute } => {\n                self.writer.write_literal(id.name.as_ref())?;\n\n                if let Some(attr) = attribute.as_ref() {\n                    self.writer.write_literal(\".\")?;\n                    self.writer.write_literal(attr.name.as_ref())?;\n                }\n\n                Ok(())\n            }\n            InlineExpression::TermReference {\n                id,\n                attribute,\n                arguments,\n            } => {\n                self.writer.write_literal(\"-\")?;\n                self.writer.write_literal(id.name.as_ref())?;\n\n                if let Some(attr) = attribute.as_ref() {\n                    self.writer.write_literal(\".\")?;\n                    self.writer.write_literal(attr.name.as_ref())?;\n                }\n                if let Some(args) = arguments.as_ref() {\n                    self.serialize_call_arguments(args)?;\n                }\n\n                Ok(())\n            }\n            InlineExpression::Placeable { expression } => {\n                self.writer.write_literal(\"{\")?;\n                self.serialize_expression(expression)?;\n                self.writer.write_literal(\"}\")?;\n\n                Ok(())\n            }\n        }\n    }\n\n    fn serialize_select_expression<'s, S: Slice<'s>>(\n        &mut self,\n        selector: &InlineExpression<S>,\n        variants: &[Variant<S>],\n    ) -> Result<(), Error> {\n        self.serialize_inline_expression(selector)?;\n        self.writer.write_literal(\" ->\")?;\n\n        self.writer.newline();\n        self.writer.indent();\n\n        for variant in variants {\n            self.serialize_variant(variant)?;\n            self.writer.newline();\n        }\n\n        self.writer.dedent();\n        Ok(())\n    }\n\n    fn serialize_variant<'s, S: Slice<'s>>(&mut self, variant: &Variant<S>) -> Result<(), Error> {\n        if variant.default {\n            self.writer.write_char_into_indent('*');\n        }\n\n        self.writer.write_literal(\"[\")?;\n        self.serialize_variant_key(&variant.key)?;\n        self.writer.write_literal(\"]\")?;\n        self.serialize_pattern(&variant.value)?;\n\n        Ok(())\n    }\n\n    fn serialize_variant_key<'s, S: Slice<'s>>(\n        &mut self,\n        key: &VariantKey<S>,\n    ) -> Result<(), Error> {\n        match key {\n            VariantKey::NumberLiteral { value } | VariantKey::Identifier { name: value } => {\n                self.writer.write_literal(value.as_ref())\n            }\n        }\n    }\n\n    fn serialize_call_arguments<'s, S: Slice<'s>>(\n        &mut self,\n        args: &CallArguments<S>,\n    ) -> Result<(), Error> {\n        let mut argument_written = false;\n\n        self.writer.write_literal(\"(\")?;\n\n        for positional in &args.positional {\n            if argument_written {\n                self.writer.write_literal(\", \")?;\n            }\n\n            self.serialize_inline_expression(positional)?;\n            argument_written = true;\n        }\n\n        for named in &args.named {\n            if argument_written {\n                self.writer.write_literal(\", \")?;\n            }\n\n            self.writer.write_literal(named.name.name.as_ref())?;\n            self.writer.write_literal(\": \")?;\n            self.serialize_inline_expression(&named.value)?;\n            argument_written = true;\n        }\n\n        self.writer.write_literal(\")\")?;\n        Ok(())\n    }\n}\n\nfn is_select_expr<'s, S: Slice<'s>>(expr: &Expression<S>) -> bool {\n    match expr {\n        Expression::Select { .. } => true,\n        Expression::Inline(InlineExpression::Placeable { expression }) => {\n            is_select_expr(expression)\n        }\n        Expression::Inline(_) => false,\n    }\n}\n\n#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]\npub struct Options {\n    pub with_junk: bool,\n}\n\n#[derive(Debug, Default, PartialEq)]\nstruct State {\n    has_entries: bool,\n}\n\n#[derive(Debug, Clone, Default)]\nstruct TextWriter {\n    buffer: String,\n    indent_level: usize,\n}\n\nimpl TextWriter {\n    fn indent(&mut self) {\n        self.indent_level += 1;\n    }\n\n    fn dedent(&mut self) {\n        self.indent_level = self\n            .indent_level\n            .checked_sub(1)\n            .expect(\"Dedenting without a corresponding indent\");\n    }\n\n    fn write_indent(&mut self) {\n        for _ in 0..self.indent_level {\n            self.buffer.push_str(\"    \");\n        }\n    }\n\n    fn newline(&mut self) {\n        self.buffer.push('\\n');\n    }\n\n    fn write_literal(&mut self, mut item: &str) -> fmt::Result {\n        if self.buffer.ends_with('\\n') {\n            // we've just added a newline, make sure it's properly indented\n            self.write_indent();\n\n            // we've just added indentation, so we don't care about leading\n            // spaces\n            item = item.trim_start_matches(' ');\n        }\n\n        write!(self.buffer, \"{item}\")\n    }\n\n    fn write_char_into_indent(&mut self, ch: char) {\n        if self.buffer.ends_with('\\n') {\n            self.write_indent();\n        }\n        self.buffer.pop();\n        self.buffer.push(ch);\n    }\n}\n"
  },
  {
    "path": "ftl/src/string/copy.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::assert_ne;\nuse std::collections::HashMap;\nuse std::println;\n\nuse camino::Utf8PathBuf;\nuse clap::Args;\nuse fluent_syntax::ast::Entry;\n\nuse crate::string;\n\n#[derive(Args)]\npub struct CopyOrMoveArgs {\n    /// The folder which contains the different languages as subfolders, e.g.\n    /// ftl/core-repo/core\n    src_lang_folder: Utf8PathBuf,\n    dst_lang_folder: Utf8PathBuf,\n    /// E.g. 'actions-run'. File will be inferred from the prefix.\n    src_key: String,\n    /// If not specified, the key & file will be the same as the source key.\n    dst_key: Option<String>,\n}\n\n#[derive(Debug, Eq, PartialEq)]\npub(super) enum CopyOrMove {\n    Copy,\n    Move,\n}\n\npub(super) fn copy_or_move(mode: CopyOrMove, args: CopyOrMoveArgs) -> anyhow::Result<()> {\n    let old_key = &args.src_key;\n    let new_key = args.dst_key.as_ref().unwrap_or(old_key);\n    let src_ftl_file = string::ftl_file_from_key(old_key);\n    let dst_ftl_file = string::ftl_file_from_key(new_key);\n    let mut entries: HashMap<&str, Entry<String>> = HashMap::new();\n\n    // Fetch source strings\n    let src_langs = string::all_langs(&args.src_lang_folder)?;\n    for lang in &src_langs {\n        let ftl_path = lang.join(&src_ftl_file);\n        if !ftl_path.exists() {\n            continue;\n        }\n\n        let entry = string::get_entry(&ftl_path, old_key);\n        if let Some(entry) = entry {\n            entries.insert(lang.file_name().unwrap(), entry);\n        } else {\n            // the key might be missing from some languages, but it should not be missing\n            // from the template\n            assert_ne!(lang, \"templates\");\n        }\n    }\n\n    // Apply to destination\n    let dst_langs = string::all_langs(&args.dst_lang_folder)?;\n    for lang in &dst_langs {\n        let ftl_path = lang.join(&dst_ftl_file);\n        if !ftl_path.exists() {\n            continue;\n        }\n\n        if let Some(entry) = entries.get(lang.file_name().unwrap()) {\n            println!(\"Updating {ftl_path}\");\n            string::write_entry(&ftl_path, new_key, entry.clone())?;\n        }\n    }\n\n    if let Some(template_dir) = string::additional_template_folder(&args.dst_lang_folder) {\n        // Our templates are also stored in the source tree, and need to be updated too.\n        let ftl_path = template_dir.join(&dst_ftl_file);\n        println!(\"Updating {ftl_path}\");\n        string::write_entry(\n            &ftl_path,\n            new_key,\n            entries.get(\"templates\").unwrap().clone(),\n        )?;\n    }\n\n    if mode == CopyOrMove::Move {\n        // Delete the old key\n        for lang in &src_langs {\n            let ftl_path = lang.join(&src_ftl_file);\n            if !ftl_path.exists() {\n                continue;\n            }\n\n            if string::delete_entry(&ftl_path, old_key)? {\n                println!(\"Deleted entry from {ftl_path}\");\n            }\n        }\n        if let Some(template_dir) = string::additional_template_folder(&args.src_lang_folder) {\n            let ftl_path = template_dir.join(&src_ftl_file);\n            if string::delete_entry(&ftl_path, old_key)? {\n                println!(\"Deleted entry from {ftl_path}\");\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "ftl/src/string/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod copy;\nmod transform;\n\nuse std::fs;\nuse std::path::Path;\n\nuse anki_io::read_to_string;\nuse anki_io::write_file_if_changed;\nuse anki_io::ToUtf8PathBuf;\nuse anyhow::anyhow;\nuse anyhow::Context;\nuse anyhow::Result;\nuse camino::Utf8Component;\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\nuse clap::Subcommand;\nuse copy::CopyOrMoveArgs;\nuse fluent_syntax::ast::Entry;\nuse fluent_syntax::ast::Resource;\nuse fluent_syntax::parser;\nuse itertools::Itertools;\n\nuse crate::serialize;\nuse crate::string::copy::copy_or_move;\nuse crate::string::copy::CopyOrMove;\nuse crate::string::transform::transform;\nuse crate::string::transform::TransformArgs;\n\n#[derive(Subcommand)]\npub enum StringCommand {\n    /// Copy a key from one ftl file to another, including all its\n    /// translations. Source and destination should be e.g.\n    /// ftl/core-repo/core.\n    Copy(CopyOrMoveArgs),\n    /// Move a key from one ftl file to another, including all its\n    /// translations. Source and destination should be e.g.\n    /// ftl/core-repo/core.\n    Move(CopyOrMoveArgs),\n    /// Apply a regex find&replace to the template and translations.\n    Transform(TransformArgs),\n}\n\npub fn string_operation(args: StringCommand) -> anyhow::Result<()> {\n    match args {\n        StringCommand::Copy(args) => copy_or_move(CopyOrMove::Copy, args),\n        StringCommand::Move(args) => copy_or_move(CopyOrMove::Move, args),\n        StringCommand::Transform(args) => transform(args),\n    }\n}\nfn additional_template_folder(dst_folder: &Utf8Path) -> Option<Utf8PathBuf> {\n    // ftl/core-repo/core -> ftl/core\n    // ftl/qt-repo/qt -> ftl/qt\n    let adjusted_path = Utf8PathBuf::from_iter(\n        [Utf8Component::Normal(\"ftl\")]\n            .into_iter()\n            .chain(dst_folder.components().skip(2)),\n    );\n    if adjusted_path.exists() {\n        Some(adjusted_path)\n    } else {\n        None\n    }\n}\n\nfn all_langs(lang_folder: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {\n    std::fs::read_dir(lang_folder)\n        .with_context(|| format!(\"reading {lang_folder:?}\"))?\n        .filter_map(Result::ok)\n        .map(|e| Ok(e.path().utf8()?))\n        .collect()\n}\n\nfn ftl_file_from_key(old_key: &str) -> String {\n    for prefix in [\n        \"card-stats\",\n        \"card-template-rendering\",\n        \"card-templates\",\n        \"change-notetype\",\n        \"custom-study\",\n        \"database-check\",\n        \"deck-config\",\n        \"empty-cards\",\n        \"media-check\",\n        \"qt-misc\",\n    ] {\n        if old_key.starts_with(&format!(\"{prefix}-\")) {\n            return format!(\"{prefix}.ftl\");\n        }\n    }\n\n    format!(\"{}.ftl\", old_key.split('-').next().unwrap())\n}\n\nfn parse_file(ftl_path: &Utf8Path) -> Result<Resource<String>> {\n    let content = read_to_string(ftl_path).unwrap();\n    parser::parse(content).map_err(|(_, errs)| {\n        anyhow!(\n            \"while reading {ftl_path}: {}\",\n            errs.into_iter().map(|err| err.to_string()).join(\", \")\n        )\n    })\n}\n\n/// True if changed.\nfn serialize_file(path: &Utf8Path, resource: &Resource<String>) -> Result<bool> {\n    let mut text = serialize::serialize(resource);\n    // escape leading dots\n    text = text.replace(\" +.\", \" +{\\\".\\\"}\");\n    // ensure the resulting serialized file is valid by parsing again\n    let _ = parser::parse(text.clone()).unwrap();\n    // it's ok, write it out\n    Ok(write_file_if_changed(path, text)?)\n}\n\nfn get_entry(fname: &Utf8Path, key: &str) -> Option<Entry<String>> {\n    let resource = parse_file(fname).unwrap();\n    for entry in resource.body {\n        if let Entry::Message(message) = entry {\n            if message.id.name == key {\n                return Some(Entry::Message(message));\n            }\n        }\n    }\n\n    None\n}\n\nfn write_entry(path: &Utf8Path, key: &str, mut entry: Entry<String>) -> Result<()> {\n    if let Entry::Message(message) = &mut entry {\n        message.id.name = key.to_string();\n    }\n\n    let content = if Path::new(path).exists() {\n        fs::read_to_string(path).unwrap()\n    } else {\n        String::new()\n    };\n    let mut resource = parser::parse(content).unwrap();\n    resource.body.push(entry);\n\n    serialize_file(path, &resource)?;\n    Ok(())\n}\n\nfn delete_entry(path: &Utf8Path, key: &str) -> Result<bool> {\n    let mut resource = parse_file(path)?;\n    let mut did_change = false;\n    resource.body.retain(|entry| {\n        !if let Entry::Message(message) = entry {\n            if message.id.name == key {\n                did_change = true;\n                true\n            } else {\n                false\n            }\n        } else {\n            false\n        }\n    });\n    serialize_file(path, &resource)\n}\n"
  },
  {
    "path": "ftl/src/string/transform.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\n\nuse anki_io::paths_in_dir;\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\nuse clap::Args;\nuse clap::ValueEnum;\nuse fluent_syntax::ast::Entry;\nuse fluent_syntax::ast::Expression;\nuse fluent_syntax::ast::InlineExpression;\nuse fluent_syntax::ast::Message;\nuse fluent_syntax::ast::Pattern;\nuse fluent_syntax::ast::PatternElement;\nuse fluent_syntax::ast::Resource;\nuse regex::Regex;\n\nuse crate::string::parse_file;\nuse crate::string::serialize_file;\n\n#[derive(Args)]\npub struct TransformArgs {\n    /// The folder which contains the different languages as subfolders, e.g.\n    /// ftl/core-repo/core\n    lang_folder: Utf8PathBuf,\n    // What should be replaced.\n    target: TransformTarget,\n    regex: String,\n    replacement: String,\n    // limit replacement to a single key\n    // #[clap(long)]\n    // key: Option<String>,\n}\n\n#[derive(ValueEnum, Clone, PartialEq, Eq)]\npub enum TransformTarget {\n    Text,\n    Variable,\n}\n\npub fn transform(args: TransformArgs) -> Result<()> {\n    let regex = Regex::new(&args.regex)?;\n    for lang in super::all_langs(&args.lang_folder)? {\n        for ftl in paths_in_dir(&lang)? {\n            transform_ftl(&ftl, &regex, &args)?;\n        }\n    }\n    if let Some(template_dir) = super::additional_template_folder(&args.lang_folder) {\n        // Our templates are also stored in the source tree, and need to be updated too.\n        for ftl in paths_in_dir(template_dir)? {\n            transform_ftl(&ftl, &regex, &args)?;\n        }\n    }\n\n    Ok(())\n}\n\nfn transform_ftl(ftl: &Utf8Path, regex: &Regex, args: &TransformArgs) -> Result<()> {\n    let mut resource = parse_file(ftl)?;\n    if transform_ftl_inner(&mut resource, regex, args) {\n        println!(\"Updating {ftl}\");\n        serialize_file(ftl, &resource)?;\n    }\n    Ok(())\n}\n\nfn transform_ftl_inner(\n    resource: &mut Resource<String>,\n    regex: &Regex,\n    args: &TransformArgs,\n) -> bool {\n    let mut changed = false;\n    for entry in &mut resource.body {\n        if let Entry::Message(Message {\n            value: Some(value), ..\n        }) = entry\n        {\n            changed |= transform_pattern(value, regex, args);\n        }\n    }\n    changed\n}\n\n/// True if changed.\nfn transform_pattern(pattern: &mut Pattern<String>, regex: &Regex, args: &TransformArgs) -> bool {\n    let mut changed = false;\n    for element in &mut pattern.elements {\n        match args.target {\n            TransformTarget::Text => {\n                changed |= transform_text(element, regex, args);\n            }\n            TransformTarget::Variable => {\n                changed |= transform_variable(element, regex, args);\n            }\n        }\n    }\n    changed\n}\n\nfn transform_variable(\n    pattern: &mut PatternElement<String>,\n    regex: &Regex,\n    args: &TransformArgs,\n) -> bool {\n    let mut changed = false;\n    let mut maybe_update = |val: &mut String| {\n        if let Cow::Owned(new_val) = regex.replace_all(val, &args.replacement) {\n            changed = true;\n            *val = new_val;\n        }\n    };\n    if let PatternElement::Placeable { expression } = pattern {\n        match expression {\n            Expression::Select { selector, variants } => {\n                if let InlineExpression::VariableReference { id } = selector {\n                    maybe_update(&mut id.name)\n                }\n                for variant in variants {\n                    changed |= transform_pattern(&mut variant.value, regex, args);\n                }\n            }\n            Expression::Inline(expression) => {\n                if let InlineExpression::VariableReference { id } = expression {\n                    maybe_update(&mut id.name)\n                }\n            }\n        }\n    }\n    changed\n}\n\nfn transform_text(\n    pattern: &mut PatternElement<String>,\n    regex: &Regex,\n    args: &TransformArgs,\n) -> bool {\n    let mut changed = false;\n    let mut maybe_update = |val: &mut String| {\n        if let Cow::Owned(new_val) = regex.replace_all(val, &args.replacement) {\n            changed = true;\n            *val = new_val;\n        }\n    };\n    match pattern {\n        PatternElement::TextElement { value } => {\n            maybe_update(value);\n        }\n        PatternElement::Placeable { expression } => match expression {\n            Expression::Inline(val) => match val {\n                InlineExpression::StringLiteral { value } => maybe_update(value),\n                InlineExpression::NumberLiteral { value } => maybe_update(value),\n                InlineExpression::FunctionReference { .. } => {}\n                InlineExpression::MessageReference { .. } => {}\n                InlineExpression::TermReference { .. } => {}\n                InlineExpression::VariableReference { .. } => {}\n                InlineExpression::Placeable { .. } => {}\n            },\n            Expression::Select { variants, .. } => {\n                for variant in variants {\n                    changed |= transform_pattern(&mut variant.value, regex, args);\n                }\n            }\n        },\n    }\n    changed\n}\n\n#[cfg(test)]\nmod tests {\n    use fluent_syntax::parser::parse;\n\n    use super::*;\n    use crate::serialize::serialize;\n\n    #[test]\n    fn transform() -> Result<()> {\n        let mut resource = parse(\n            r#\"sample-1 = This is a sample\nsample-2 =\n    { $sample ->\n        [one] { $sample } sample done\n       *[other] { $sample } samples done\n    }\"#\n            .to_string(),\n        )\n        .unwrap();\n\n        let mut args = TransformArgs {\n            lang_folder: Default::default(),\n            target: TransformTarget::Text,\n            regex: \"\".to_string(),\n            replacement: \"replaced\".to_string(),\n        };\n        // no changes\n        assert!(!transform_ftl_inner(\n            &mut resource,\n            &Regex::new(\"aoeu\").unwrap(),\n            &args\n        ));\n        // text change\n        let regex = Regex::new(\"sample\").unwrap();\n        let mut resource2 = resource.clone();\n        assert!(transform_ftl_inner(&mut resource2, &regex, &args));\n        assert_eq!(\n            &serialize(&resource2),\n            r#\"sample-1 = This is a replaced\nsample-2 =\n    { $sample ->\n        [one] { $sample } replaced done\n       *[other] { $sample } replaceds done\n    }\n\"#\n        );\n        // variable change\n        let mut resource2 = resource.clone();\n        args.target = TransformTarget::Variable;\n        assert!(transform_ftl_inner(&mut resource2, &regex, &args));\n        assert_eq!(\n            &serialize(&resource2),\n            r#\"sample-1 = This is a sample\nsample-2 =\n    { $replaced ->\n        [one] { $replaced } sample done\n       *[other] { $replaced } samples done\n    }\n\"#\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "ftl/src/sync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::process::Command;\n\nuse anki_process::CommandExt;\nuse anyhow::bail;\nuse anyhow::Context;\nuse anyhow::Result;\nuse camino::Utf8Path;\n\n#[derive(Debug)]\nstruct Module {\n    template_folder: &'static Utf8Path,\n    translation_repo: &'static Utf8Path,\n}\n\n/// Our ftl submodules are checked out over unauthenticated https; a separate\n/// remote is used to push via authenticated ssh.\nconst GIT_REMOTE: &str = \"ssh\";\n\npub fn sync() -> Result<()> {\n    let modules = [\n        Module {\n            template_folder: \"ftl/core\".into(),\n            translation_repo: \"ftl/core-repo/core\".into(),\n        },\n        Module {\n            template_folder: \"ftl/qt\".into(),\n            translation_repo: \"ftl/qt-repo/desktop\".into(),\n        },\n    ];\n\n    check_clean()?;\n    for module in modules {\n        fetch_new_translations(&module)?;\n        push_new_templates(&module)?;\n    }\n    commit(\".\", \"Update translations\").context(\"failure expected if no translations changed\")?;\n    Ok(())\n}\n\nfn check_clean() -> Result<()> {\n    let output = Command::new(\"git\")\n        .arg(\"diff\")\n        .output()\n        .context(\"git diff\")?;\n    if !output.status.success() {\n        bail!(\"git diff\");\n    }\n    if !output.stdout.is_empty() {\n        bail!(\"please commit any outstanding changes first\");\n    }\n    Ok(())\n}\n\nfn fetch_new_translations(module: &Module) -> Result<()> {\n    Command::new(\"git\")\n        .current_dir(module.translation_repo)\n        .args([\"checkout\", \"main\"])\n        .ensure_success()?;\n    Command::new(\"git\")\n        .current_dir(module.translation_repo)\n        .args([\"pull\", \"origin\", \"main\"])\n        .ensure_success()?;\n    Ok(())\n}\n\nfn push_new_templates(module: &Module) -> Result<()> {\n    Command::new(\"rsync\")\n        .args([\"-ai\", \"--delete\", \"--no-perms\", \"--no-times\", \"-c\"])\n        .args([\n            format!(\"{}/\", module.template_folder),\n            format!(\"{}/\", module.translation_repo.join(\"templates\")),\n        ])\n        .ensure_success()?;\n    let changes_pending = !Command::new(\"git\")\n        .current_dir(module.translation_repo)\n        .args([\"diff\", \"--exit-code\"])\n        .status()\n        .context(\"git\")?\n        .success();\n    if changes_pending {\n        commit(module.translation_repo, \"Update templates\")?;\n        push(module.translation_repo)?;\n    }\n    Ok(())\n}\n\nfn push(repo: &Utf8Path) -> Result<()> {\n    Command::new(\"git\")\n        .current_dir(repo)\n        .args([\"push\", GIT_REMOTE, \"main\"])\n        .ensure_success()?;\n    // ensure origin matches ssh remote\n    Command::new(\"git\")\n        .current_dir(repo)\n        .args([\"fetch\"])\n        .ensure_success()?;\n    Ok(())\n}\n\nfn commit<F>(folder: F, message: &str) -> Result<()>\nwhere\n    F: AsRef<str>,\n{\n    Command::new(\"git\")\n        .current_dir(folder.as_ref())\n        .args([\"commit\", \"-a\", \"-m\", message])\n        .ensure_success()?;\n    Ok(())\n}\n"
  },
  {
    "path": "ftl/update-ankidroid-usage.sh",
    "content": "#!/bin/bash\n\ncargo run --bin write_ftl_json ftl/usage/ankidroid.json ~/Local/droid/Anki-Android\n"
  },
  {
    "path": "ftl/update-ankimobile-usage.sh",
    "content": "#!/bin/bash\n# This script can only be run by Damien, as it requires a copy of AnkiMobile's sources.\n\ncargo run --bin write_ftl_json ftl/usage/ankimobile.json ../../mobile/ankimobile/src\n"
  },
  {
    "path": "ftl/usage/no-deprecate.json",
    "content": "[\n    \"scheduling-update-soon\",\n    \"scheduling-update-later-button\"\n]\n"
  },
  {
    "path": "justfile",
    "content": "set windows-shell := [\"cmd.exe\", \"/c\"]\n\n# Show available commands\ndefault:\n    @just --list\n\n# Run all tests (Rust, Python, TypeScript)\ntest:\n    {{ ninja }} check:rust_test check:pytest check:vitest\n\n# Run format checks only (fast, no build needed)\nfmt:\n    {{ ninja }} check:format\n\n# Run linting and type checking (requires build outputs)\nlint:\n    {{ ninja }} \\\n        check:clippy \\\n        check:mypy \\\n        check:ruff \\\n        check:eslint \\\n        check:svelte \\\n        check:typescript\n\n# Run minilints (copyright, contributors, licenses)\nminilints:\n    {{ ninja }} check:minilints\n\n# Build the project\nbuild:\n    {{ ninja }} pylib qt\n\n# Build wheels (needed for some platforms)\nwheels:\n    {{ ninja }} wheels\n\n# Build and run all checks (lint + test) - lets ninja handle dependencies\ncheck:\n    {{ ninja }} pylib qt check\n\n# Helper to get the right ninja command for the platform\nninja := if os() == \"windows\" { \"tools\\\\ninja\" } else { \"./ninja\" }\n"
  },
  {
    "path": "ninja",
    "content": "#!/bin/bash\n\nset -e\n\nif [ \"$BUILD_ROOT\" == \"\" ]; then\n    out=$(pwd)/out\nelse\n    out=\"$BUILD_ROOT\"\nfi\nexport CARGO_TARGET_DIR=$out/rust\nexport RECONFIGURE_KEY=\"${MAC_X86};${LIN_ARM64};${SOURCEMAP};${HMR}\"\n\nif [ \"$SKIP_RUNNER_BUILD\" = \"1\" ]; then\n    echo \"Runner not rebuilt.\"\nelse\n    cargo build -p runner --release\nfi\nexec $out/rust/release/runner build -- $*\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"anki\",\n    \"version\": \"0.1.0\",\n    \"private\": true,\n    \"author\": \"Ankitects Pty Ltd and contributors\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"description\": \"Anki JS support files\",\n    \"scripts\": {\n        \"dev\": \"cd ts && vite dev\",\n        \"build\": \"cd ts && vite build\",\n        \"preview\": \"cd ts && vite preview\",\n        \"svelte-check:once\": \"cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning\",\n        \"svelte-check\": \"cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch\",\n        \"vitest:once\": \"cd ts && vitest run\",\n        \"vitest\": \"cd ts && vitest\"\n    },\n    \"devDependencies\": {\n        \"@bufbuild/protoc-gen-es\": \"^1.8.0\",\n        \"@poppanator/sveltekit-svg\": \"^5.0.0\",\n        \"@sqltools/formatter\": \"^1.2.2\",\n        \"@sveltejs/adapter-static\": \"^3.0.0\",\n        \"@sveltejs/kit\": \"^2.53.3\",\n        \"@sveltejs/vite-plugin-svelte\": \"5.1\",\n        \"@types/bootstrap\": \"^5.0.12\",\n        \"@types/codemirror\": \"^5.60.0\",\n        \"@types/d3\": \"^7.0.0\",\n        \"@types/diff\": \"^5.0.0\",\n        \"@types/fabric\": \"^5.3.7\",\n        \"@types/jquery\": \"^3.5.0\",\n        \"@types/jqueryui\": \"^1.12.13\",\n        \"@types/lodash-es\": \"^4.17.4\",\n        \"@types/marked\": \"^5.0.0\",\n        \"@types/node\": \"^22\",\n        \"@typescript-eslint/eslint-plugin\": \"^5.60.1\",\n        \"@typescript-eslint/parser\": \"^5.60.1\",\n        \"caniuse-lite\": \"^1.0.30001431\",\n        \"cross-env\": \"^7.0.2\",\n        \"diff\": \"^5.0.0\",\n        \"dprint\": \"^0.47.2\",\n        \"esbuild\": \"^0.25.3\",\n        \"esbuild-sass-plugin\": \"^3.3.1\",\n        \"esbuild-svelte\": \"^0.9.2\",\n        \"eslint\": \"^8.44.0\",\n        \"eslint-plugin-compat\": \"^4.1.4\",\n        \"eslint-plugin-import\": \"^2.25.4\",\n        \"eslint-plugin-svelte\": \"^2\",\n        \"license-checker-rseidelsohn\": \"=4.3.0\",\n        \"prettier\": \"^3.4.2\",\n        \"prettier-plugin-svelte\": \"^3.3.2\",\n        \"sass\": \"<1.77\",\n        \"svelte\": \"^5.53.5\",\n        \"svelte-check\": \"^4.2.2\",\n        \"svelte-preprocess\": \"^6.0.3\",\n        \"svelte-preprocess-esbuild\": \"^3.0.1\",\n        \"svgo\": \"^3.3.3\",\n        \"tslib\": \"^2.0.3\",\n        \"tsx\": \"^4.8.1\",\n        \"typescript\": \"^5.0.4\",\n        \"vite\": \"6\",\n        \"vitest\": \"^3\"\n    },\n    \"dependencies\": {\n        \"@bufbuild/protobuf\": \"^1.2.1\",\n        \"@floating-ui/dom\": \"^1.4.3\",\n        \"@fluent/bundle\": \"^0.18.0\",\n        \"@mdi/svg\": \"^7.0.96\",\n        \"@popperjs/core\": \"^2.11.8\",\n        \"bootstrap\": \"^5.3.0\",\n        \"bootstrap-icons\": \"^1.10.5\",\n        \"codemirror\": \"^5.63.1\",\n        \"d3\": \"^7.0.0\",\n        \"fabric\": \"^5.3.0\",\n        \"hammerjs\": \"^2.0.8\",\n        \"intl-pluralrules\": \"^2.0.0\",\n        \"jquery\": \"^3.5.1\",\n        \"jquery-ui-dist\": \"^1.12.1\",\n        \"lodash-es\": \"^4.17.23\",\n        \"lru-cache\": \"^10.2.0\",\n        \"marked\": \"^5.1.0\",\n        \"mathjax\": \"^3.1.2\"\n    },\n    \"resolutions\": {\n        \"canvas\": \"npm:empty-npm-package@1.0.0\",\n        \"cookie\": \"0.7.0\",\n        \"devalue\": \"^5.6.2\",\n        \"tar\": \"^7.5.7\",\n        \"vite\": \"6\",\n        \"js-yaml\": \"^4.1.1\",\n        \"glob\": \"^10.5.0\"\n    },\n    \"browserslist\": [\n        \"defaults\",\n        \"not op_mini all\",\n        \"not < 1%\",\n        \"Chrome 77\",\n        \"iOS 14.5\"\n    ],\n    \"type\": \"module\",\n    \"packageManager\": \"yarn@4.6.0\"\n}\n"
  },
  {
    "path": "pkgkey.asc",
    "content": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFueX68BEAClpx+Szt1cSTWJTCTpn9E+tGhYUKVpj1O4KGAj7qYKs651LPOA\nen1Ng0MoK4Avq4zW3PpXxtp14q+CBpEP3AE3omJkKD42cmBvxqdMNiWnFZUbRal8\nL7LrkVFVV/C1Cq7pJR5xAORc2GCKKTE6Ybqdqj2lQKwZEJpM+GQPQqSUQjWpmO2n\nYQ8OSftr58Nqm5N2j2i2BHvchpOUtoN4L5qlYtkPBFltBDVOKglnQE4N9pZjBX76\nD2Q4/6khfIx1kJ3xt8b30cPlDMATdnB6bDUr17vsofhPIY1N07ztyDLl2PyeeqVa\nIrJEh9XvhwnN5RqM10PZDSEDVGLk4Mkbu5dwsbvXbdMrLbAaWoBDEmloVmM+rw+t\ng76ldYu2FIxIVHDdgqJWSw5+JTQk+GlAwxAve3yluxvWrxKR1kG66XB8rX0G47Qd\nDLmnj9PISi5rRJzQXTrIG93aKKBqz6vMS1n9V3BhEKSzJ2zH6Nhog/SIwFqfSvNL\n+vJLmjPlOdVL4cBSa7EltOGevkeU5DCTV0PNz78TpBMTaduKxFyFfepyxkYrFrjR\nhKR2HLFD0jecw36UoCETJm5/VbYE312qqWJxuuvsWtaU2I6SRv8rTRJ7prlo5zI1\n6pfUk3QCt1zDZC3v3NszhYIhLBIVv72iVo3DEbuqOyjGJnF1IOUv6XRvbwARAQAB\ntCFBbmtpIFNpZ25hdHVyZXMgPGdwZ0Bhbmtpd2ViLm5ldD6JAk4EEwEKADgWIQSB\nTqTpDDSvOacS3nA/VWai0WiZ+wUCW55frwIbAQULCQgHAwUVCgkICwUWAgMBAAIe\nAQIXgAAKCRA/VWai0WiZ+yM6EACLvzNwwgXVE6KA9NA+Xn9z/5CEy894gNUXBdyP\nx2peUZmvqZYJsWrq1EdwvyNPVnfxerzRPzzO1/+UFs9lyrVJBOIXRe790xUDEAOt\nd67eIHk0/mwR8HA4EzBM3VhK90DzfdVl8CGjn7QMcgXZk8qp9ogSh4qoPq9slXjs\nAy8pdDKBQthR3jFoAX8tX8x3vrQPBFIA1xRX0Pr9w6CTR7lto0HTP1o4weB2AFVM\ncshUnPWv7UkJsKDgsS0JpK46AS1y0z8TgGqZxPiXyiSw+r+uBOu5243ujfFKHfsR\n26h23BO+9niHKIMkThTlYweUj0pqqcS9dZ1RBFFtW5/0+c/WA7Jg59XELg34jbvr\nDJjW0kXkvH3TP0rxDNlzQivh48PTHovng/m0Ah6XoW6APBK04xTOPPsW8mILolwi\nPYcd3frQx2gYCKiUDgXhn/0pHy35Qf2UMCpWMljNF3uaoeBDmRjmhaUDnldDWeYo\npg48bwe4utKeJK9mIl1tg24jOiZPWB7Yg5UOSa9qG9Z8O/Vvgmi9z4ujp4g37jUZ\nPGAlsEagVUAenVbNpS07X2KtGuP3CKc1akN9I4YArH604lB6rJYVl7c1mZw6YZf1\nlDuFs+sorr+Qh6ivBvhCOZwC+cAfUHXm0Td6mBsnnCJl3Pe0w49x2ODdabjyM/d+\n19lNz4hdBBMRCgAdFiEErE89EK5NqyDOk9jPuclDRDSScSsFAlufSloACgkQuclD\nRDSScStmmgCfZBxtwxhNHKDdSVbMUlFmPq3Ww5gAn1A0OzZEPJkj+gdq0bWbEePA\nNa/+iQEzBBMBCgAdFiEE5As5X+DAzGQR/QaHdD/TCLkKzHEFAlufSu4ACgkQdD/T\nCLkKzHH2HwgAj9jcRBL3Rsu4r7ZifbAPOlB/zQLos5Hmt70DzheWpU6hMcxkgnAs\nCB3gutZAQ36yKgBzOFWDfo5X3ivhAm23VXkFKswgHmA1DLypmFPh2rm/Sh3G9khr\noogQmwErZRLNJ7QY9Q3sIxaSvZArWSRaysSaVG9CiSq+N4yXnhETS8uieWV9k+qk\nRs1eaJCjOYPgaxQXXL18RgkzuDKuSqmWW0AvmVNaAsX27diAxqcVysGyoJIqA4Dw\nVaMJU+hSZZSryKTLWHZpGMMLLjt5oLyW3y1HvopVAuGRrAXLzqgvLv/WSXHWfSrL\nl+VeP2jDU3mF43BtjGRMrVgAf0DGH1zg57kCDQRbnmAYARAAsvvWoYPy13YFqOsR\nsgaJ1sW6hyGGOjhlHcfcc++CgYwowQ8jn0ZkYdcDs+zbJI8+BUCdVgO8kpJFVlmC\nvpBeO0bKoqc63W6NIG7qrhgDoODO3J3CV4LJm5ipj9tcuXCW2o7GzrgJMaps8NQK\naUywSwZcV15aERrw9viHEPUHHAQkKBANv/cJ+YWD1SOZyI978yER0/qdby8cnLp+\nvwzRo+OB3ubZL3iFKKd716eSOJQOO1XbxsfF9RagFmGn8lq4tii9nU9c7BS3ajC3\nFJNNsNphe7DezAeV7IZZrmcSTl+h3n045yRJjisxqG74dSqJ8aIkuQvCTRn8MhIL\nulgX3W0Bl4xLkRDhskIOdO8d2h8nOzoNJ2yDrJp1JHEG6G3J3ZsQ1H1uhVCwvFAM\nSMBo0kfnBfgsu7qyb3Lu5wOJ4Kh8+DZlgUnV8k/Gy21nvqjHCB4to/XSt5ZxWyBs\nmyZTxbA3w4zmkKhIdXBXEHT1+giM2n/xA7vnAUHYdGcRUza3fKXZFoBe3sOp2f6y\niF0kkO75Vkbshew8LymowU7eioiNLjIhVeOw0ICJVdHTreAFvkPo5e1N3z7Bov1E\nfqLZA1p7sUJ68sNrIo3UNeADsy5YfTsSB/2zxHi1WEbhcGA2fICJfhNCOjrFKTqR\nSb+9EdKZj3Yl5fVRaMwYtqC2IKMAEQEAAYkEbAQYAQoAIBYhBIFOpOkMNK85pxLe\ncD9VZqLRaJn7BQJbnmAYAhsCAkAJED9VZqLRaJn7wXQgBBkBCgAdFiEEHKk0tAuE\n9EgxbOL8wKNRl5s347kFAlueYBgACgkQwKNRl5s347lE/A/+Mxd1Cf8aRkE8Pq8m\nqAnMPNsuA0TpBxP7NMqi9VLpcQNU8fP5b2/7GPcz/bBsryGqQ0PxF/unHJh7Ei7V\nGM6lYKkboWQNRC8jgRgwGxmoRMQZHocKojPlAPqJ9RiiGM6Hj6QP1oyYGp596osP\nHW2FF8fNnoFFaghRVJmpNBnkUlRZhJDoJQmzQlSnbZlnphGMe4J6Eioj1dwoDPow\naBOXdWbo7j6VXvVjz3WpQ1InHuYEW3/rkuuTaxsiyq0/Kn1MT6G1uDrg9BczUDSF\ntxRoVzE3oR6gp+XfVSyfGQOeSXIi9pAENM0nsxLDf2mTqDFZGpP2Ja9T8RPJB2mh\nwdfIfHWQu8+Yywmmbb/BA4ebF0zlGx7NWTc12NYqe+bMlHvZSacm6PVkg6ob6DtD\nPAZbYg8oqzi7Mz4m3Z5lTRrTP5bul2Mx18XZH0gnKPHkFuSgSTh+0zVQi/OL8gED\n63dbH+AHavua4ORhPXaOvcyGCzqoOMY3NXkYegB0lfj5uv71DzJWniabDfwMAC7T\nO2rnhPk+iss2dCIpTFI6EqFL0BgFXpAV07nTCVyAYkrXnwlBusqc1TdZt8cVGoww\nlD284T9WhDsuFST7iDpZWW4LyqsPlW/TMkHFtHfYnR+Ta1SgFCE3CVNhiym9x+BD\nT51zPo1vfCMlz7yXCXTRaZ5eRJb3Bw/8DF5LwrFDMMuzHaEJCQGue9N4+ls+zc6J\nEzKZNToerXWrn4S+dvu/1ZMscb8g9Cq5CqJ5ZxOgu0nitbskbrY9UvfVBUIRBfdV\nm8c3ZO7LES5glVxF3zc1wouCwbTBFMb+sufukeGyIs62crDS6jwhIoKUPZcLOv9o\nHVHo2pjymwy8lKqI3TH+uyCl8xxVmUTfSonmJXmZU00AOc3fVI6E7pmniqXywgHF\nxSHTg3OiAzpd19jR611rX1shHvh1NFjTj4eeOduOCSbyempFJIIYf2uBxF5Q0uJ6\nYK1/bKWIxdJG/qsxTW1duE1Jf+uHs2LTMwNp7HgSQhVHywPK4uJnL6aZFOjLYXBP\n8V9qJpBTQJfepCCuWZaeL6FV98cldKh3Eb6nEQbZVTN54RGDPYUH+diveZZVtbgD\nkfRcIp1XBi092REpDGyrA5FA2UQA2dj4aNu+ml1QLdb0GxECAdRoXpIpYHDxOv4s\nQXQZMF1O0bJmvUHaorqRD50K8L2B43yLspINbcv4+fuSHkq0mRaGQw6AVZiG9yO+\nLYosc0PWkifPjGAsAZieN/Cm0oYnzkn2LOD3ugR0OivAvocPVPEO2xOlMV5hTUOv\nebKxn40Zl7dNp/ZWjP3EMEXYB6K+Ol9uLXc5msvT8bw98xlCYMnuj0r3EBxcLN+T\n8ZZrlhWMCzg=\n=clm/\n-----END PGP PUBLIC KEY BLOCK-----\n"
  },
  {
    "path": "proto/.clang-format",
    "content": "BasedOnStyle: google\n"
  },
  {
    "path": "proto/.top_level",
    "content": ""
  },
  {
    "path": "proto/README.md",
    "content": "Protobuf files defining the interface the frontend and backend components use to talk to each other,\nand how Anki stores some of the data inside its SQLite database. These files are used to generate Rust,\nPython and TypeScript bindings.\n"
  },
  {
    "path": "proto/anki/ankidroid.proto",
    "content": "syntax = \"proto3\";\n\noption java_multiple_files = true;\n\nimport \"anki/generic.proto\";\nimport \"anki/scheduler.proto\";\n\npackage anki.ankidroid;\n\nservice AnkidroidService {\n  rpc RunDbCommand(generic.Json) returns (generic.Json);\n  rpc RunDbCommandProto(generic.Json) returns (DbResponse);\n  rpc InsertForId(generic.Json) returns (generic.Int64);\n  rpc RunDbCommandForRowCount(generic.Json) returns (generic.Int64);\n  rpc FlushAllQueries(generic.Empty) returns (generic.Empty);\n  rpc FlushQuery(generic.Int32) returns (generic.Empty);\n  rpc GetNextResultPage(GetNextResultPageRequest) returns (DbResponse);\n  rpc GetColumnNamesFromQuery(generic.String) returns (generic.StringList);\n  rpc GetActiveSequenceNumbers(generic.Empty)\n      returns (GetActiveSequenceNumbersResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendAnkidroidService {\n  rpc SchedTimingTodayLegacy(SchedTimingTodayLegacyRequest)\n      returns (scheduler.SchedTimingTodayResponse);\n  rpc LocalMinutesWestLegacy(generic.Int64) returns (generic.Int32);\n  rpc SetPageSize(generic.Int64) returns (generic.Empty);\n  rpc DebugProduceError(generic.String) returns (generic.Empty);\n}\n\nmessage DebugActiveDatabaseSequenceNumbersResponse {\n  repeated int32 sequence_numbers = 1;\n}\n\nmessage SchedTimingTodayLegacyRequest {\n  int64 created_secs = 1;\n  optional sint32 created_mins_west = 2;\n  int64 now_secs = 3;\n  sint32 now_mins_west = 4;\n  sint32 rollover_hour = 5;\n}\n\n// We expect in Java: Null, String, Short, Int, Long, Float, Double, Boolean,\n// Blob (unused) We get: DbResult (Null, String, i64, f64, Vec<u8>), which\n// matches SQLite documentation\nmessage SqlValue {\n  oneof Data {\n    string stringValue = 1;\n    int64 longValue = 2;\n    double doubleValue = 3;\n    bytes blobValue = 4;\n  }\n}\n\nmessage Row {\n  repeated SqlValue fields = 1;\n}\n\nmessage DbResult {\n  repeated Row rows = 1;\n}\n\nmessage DbResponse {\n  DbResult result = 1;\n  int32 sequenceNumber = 2;\n  int32 rowCount = 3;\n  int64 startIndex = 4;\n}\n\nmessage GetNextResultPageRequest {\n  int32 sequence = 1;\n  int64 index = 2;\n}\n\nmessage GetActiveSequenceNumbersResponse {\n  repeated int32 numbers = 1;\n}"
  },
  {
    "path": "proto/anki/ankihub.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\nimport \"anki/generic.proto\";\n\npackage anki.ankihub;\n\nservice AnkiHubService {}\n\nservice BackendAnkiHubService {\n  rpc AnkihubLogin(LoginRequest) returns (LoginResponse);\n  rpc AnkihubLogout(LogoutRequest) returns (generic.Empty);\n}\n\nmessage LoginResponse {\n  string token = 1;\n}\n\nmessage LoginRequest {\n  string id = 1;\n  string password = 2;\n}\n\nmessage LogoutRequest {\n  string token = 1;\n}\n"
  },
  {
    "path": "proto/anki/ankiweb.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.ankiweb;\n\nservice AnkiwebService {}\n\nservice BackendAnkiwebService {\n  // Fetch info on add-ons from AnkiWeb. A maximum of 25 can be queried at one\n  // time. If an add-on doesn't have a branch compatible with the provided\n  // version, that add-on will not be included in the returned list.\n  rpc GetAddonInfo(GetAddonInfoRequest) returns (GetAddonInfoResponse);\n  rpc CheckForUpdate(CheckForUpdateRequest) returns (CheckForUpdateResponse);\n}\n\nmessage GetAddonInfoRequest {\n  uint32 client_version = 1;\n  repeated uint32 addon_ids = 2;\n}\n\nmessage GetAddonInfoResponse {\n  repeated AddonInfo info = 1;\n}\n\nmessage AddonInfo {\n  uint32 id = 1;\n  int64 modified = 2;\n  uint32 min_version = 3;\n  uint32 max_version = 4;\n}\n\nmessage CheckForUpdateRequest {\n  uint32 version = 1;\n  string buildhash = 2;\n  string os = 3;\n  int64 install_id = 4;\n  uint32 last_message_id = 5;\n}\n\nmessage CheckForUpdateResponse {\n  optional string new_version = 1;\n  int64 current_time = 2;\n  optional string message = 3;\n  uint32 last_message_id = 4;\n}\n"
  },
  {
    "path": "proto/anki/backend.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.backend;\n\nimport \"anki/links.proto\";\n\nmessage BackendInit {\n  repeated string preferred_langs = 1;\n  string locale_folder_path = 2;\n  bool server = 3;\n}\n\nmessage I18nBackendInit {\n  repeated string preferred_langs = 4;\n  string locale_folder_path = 5;\n}\n\nmessage BackendError {\n  enum Kind {\n    INVALID_INPUT = 0;\n    UNDO_EMPTY = 1;\n    INTERRUPTED = 2;\n    TEMPLATE_PARSE = 3;\n    IO_ERROR = 4;\n    DB_ERROR = 5;\n    NETWORK_ERROR = 6;\n    SYNC_AUTH_ERROR = 7;\n    SYNC_SERVER_MESSAGE = 23;\n    SYNC_OTHER_ERROR = 8;\n    JSON_ERROR = 9;\n    PROTO_ERROR = 10;\n    NOT_FOUND_ERROR = 11;\n    EXISTS = 12;\n    FILTERED_DECK_ERROR = 13;\n    SEARCH_ERROR = 14;\n    CUSTOM_STUDY_ERROR = 15;\n    IMPORT_ERROR = 16;\n    DELETED = 17;\n    CARD_TYPE_ERROR = 18;\n    ANKIDROID_PANIC_ERROR = 19;\n    // Originated from and usually specific to the OS.\n    OS_ERROR = 20;\n    SCHEDULER_UPGRADE_REQUIRED = 21;\n    INVALID_CERTIFICATE_FORMAT = 22;\n  }\n\n  // error description, usually localized, suitable for displaying to the user\n  string message = 1;\n  // the error subtype\n  Kind kind = 2;\n  // optional page in the manual\n  optional links.HelpPageLinkRequest.HelpPage help_page = 3;\n  // additional information about the context in which the error occurred\n  string context = 4;\n  // a backtrace of the underlying error; requires RUST_BACKTRACE to be set\n  string backtrace = 5;\n}\n"
  },
  {
    "path": "proto/anki/card_rendering.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.card_rendering;\n\nimport \"anki/generic.proto\";\nimport \"anki/notes.proto\";\nimport \"anki/notetypes.proto\";\n\nservice CardRenderingService {\n  rpc ExtractAvTags(ExtractAvTagsRequest) returns (ExtractAvTagsResponse);\n  rpc ExtractLatex(ExtractLatexRequest) returns (ExtractLatexResponse);\n  rpc GetEmptyCards(generic.Empty) returns (EmptyCardsReport);\n  rpc RenderExistingCard(RenderExistingCardRequest)\n      returns (RenderCardResponse);\n  rpc RenderUncommittedCard(RenderUncommittedCardRequest)\n      returns (RenderCardResponse);\n  rpc RenderUncommittedCardLegacy(RenderUncommittedCardLegacyRequest)\n      returns (RenderCardResponse);\n  rpc StripAvTags(generic.String) returns (generic.String);\n  rpc RenderMarkdown(RenderMarkdownRequest) returns (generic.String);\n  rpc EncodeIriPaths(generic.String) returns (generic.String);\n  rpc DecodeIriPaths(generic.String) returns (generic.String);\n  rpc StripHtml(StripHtmlRequest) returns (generic.String);\n  rpc HtmlToTextLine(HtmlToTextLineRequest) returns (generic.String);\n  rpc CompareAnswer(CompareAnswerRequest) returns (generic.String);\n  rpc ExtractClozeForTyping(ExtractClozeForTypingRequest)\n      returns (generic.String);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendCardRenderingService {\n  rpc StripHtml(StripHtmlRequest) returns (generic.String);\n  rpc AllTtsVoices(AllTtsVoicesRequest) returns (AllTtsVoicesResponse);\n  rpc WriteTtsStream(WriteTtsStreamRequest) returns (generic.Empty);\n}\n\nmessage ExtractAvTagsRequest {\n  string text = 1;\n  bool question_side = 2;\n}\n\nmessage ExtractAvTagsResponse {\n  string text = 1;\n  repeated AVTag av_tags = 2;\n}\n\nmessage AVTag {\n  oneof value {\n    string sound_or_video = 1;\n    TTSTag tts = 2;\n  }\n}\n\nmessage TTSTag {\n  string field_text = 1;\n  string lang = 2;\n  repeated string voices = 3;\n  float speed = 4;\n  repeated string other_args = 5;\n}\n\nmessage ExtractLatexRequest {\n  string text = 1;\n  bool svg = 2;\n  bool expand_clozes = 3;\n}\n\nmessage ExtractLatexResponse {\n  string text = 1;\n  repeated ExtractedLatex latex = 2;\n}\n\nmessage ExtractedLatex {\n  string filename = 1;\n  string latex_body = 2;\n}\n\nmessage EmptyCardsReport {\n  message NoteWithEmptyCards {\n    int64 note_id = 1;\n    repeated int64 card_ids = 2;\n    bool will_delete_note = 3;\n  }\n  string report = 1;\n  repeated NoteWithEmptyCards notes = 2;\n}\n\nmessage RenderExistingCardRequest {\n  int64 card_id = 1;\n  bool browser = 2;\n  // If true, rendering will stop when an unknown filter is encountered,\n  // and caller will need to complete rendering. This is done to allow\n  // Python code to modify the rendering.\n  bool partial_render = 3;\n}\n\nmessage RenderUncommittedCardRequest {\n  notes.Note note = 1;\n  uint32 card_ord = 2;\n  notetypes.Notetype.Template template = 3;\n  bool fill_empty = 4;\n  // If true, rendering will stop when an unknown filter is encountered,\n  // and caller will need to complete rendering. This is done to allow\n  // Python code to modify the rendering.\n  bool partial_render = 5;\n}\n\nmessage RenderUncommittedCardLegacyRequest {\n  notes.Note note = 1;\n  uint32 card_ord = 2;\n  bytes template = 3;\n  bool fill_empty = 4;\n  // If true, rendering will stop when an unknown filter is encountered,\n  // and caller will need to complete rendering. This is done to allow\n  // Python code to modify the rendering.\n  bool partial_render = 5;\n}\n\nmessage RenderCardResponse {\n  repeated RenderedTemplateNode question_nodes = 1;\n  repeated RenderedTemplateNode answer_nodes = 2;\n  string css = 3;\n  bool latex_svg = 4;\n  bool is_empty = 5;\n}\n\nmessage RenderedTemplateNode {\n  oneof value {\n    string text = 1;\n    RenderedTemplateReplacement replacement = 2;\n  }\n}\n\nmessage RenderedTemplateReplacement {\n  string field_name = 1;\n  string current_text = 2;\n  repeated string filters = 3;\n}\n\nmessage RenderMarkdownRequest {\n  string markdown = 1;\n  bool sanitize = 2;\n}\n\nmessage StripHtmlRequest {\n  enum Mode {\n    NORMAL = 0;\n    PRESERVE_MEDIA_FILENAMES = 1;\n  }\n\n  string text = 1;\n  Mode mode = 2;\n}\n\nmessage HtmlToTextLineRequest {\n  string text = 1;\n  bool preserve_media_filenames = 2;\n}\n\nmessage CompareAnswerRequest {\n  string expected = 1;\n  string provided = 2;\n  bool combining = 3;\n}\n\nmessage ExtractClozeForTypingRequest {\n  string text = 1;\n  uint32 ordinal = 2;\n}\n\nmessage AllTtsVoicesRequest {\n  bool validate = 1;\n}\n\nmessage AllTtsVoicesResponse {\n  message TtsVoice {\n    string id = 1;\n    string name = 2;\n    string language = 3;\n    optional bool available = 4;\n  }\n  repeated TtsVoice voices = 1;\n}\n\nmessage WriteTtsStreamRequest {\n  string path = 1;\n  string voice_id = 2;\n  float speed = 3;\n  string text = 4;\n}\n"
  },
  {
    "path": "proto/anki/cards.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.cards;\n\nimport \"anki/collection.proto\";\n\nservice CardsService {\n  rpc GetCard(CardId) returns (Card);\n  rpc UpdateCards(UpdateCardsRequest) returns (collection.OpChanges);\n  rpc RemoveCards(RemoveCardsRequest) returns (collection.OpChangesWithCount);\n  rpc SetDeck(SetDeckRequest) returns (collection.OpChangesWithCount);\n  rpc SetFlag(SetFlagRequest) returns (collection.OpChangesWithCount);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendCardsService {}\n\nmessage CardId {\n  int64 cid = 1;\n}\n\nmessage CardIds {\n  repeated int64 cids = 1;\n}\n\nmessage Card {\n  int64 id = 1;\n  int64 note_id = 2;\n  int64 deck_id = 3;\n  uint32 template_idx = 4;\n  int64 mtime_secs = 5;\n  sint32 usn = 6;\n  uint32 ctype = 7;\n  sint32 queue = 8;\n  sint32 due = 9;\n  uint32 interval = 10;\n  uint32 ease_factor = 11;\n  uint32 reps = 12;\n  uint32 lapses = 13;\n  uint32 remaining_steps = 14;\n  sint32 original_due = 15;\n  int64 original_deck_id = 16;\n  uint32 flags = 17;\n  optional uint32 original_position = 18;\n  optional FsrsMemoryState memory_state = 20;\n  optional float desired_retention = 21;\n  optional float decay = 22;\n  optional int64 last_review_time_secs = 23;\n  string custom_data = 19;\n}\n\nmessage FsrsMemoryState {\n  float stability = 1;\n  float difficulty = 2;\n}\n\nmessage UpdateCardsRequest {\n  repeated Card cards = 1;\n  bool skip_undo_entry = 2;\n}\n\nmessage RemoveCardsRequest {\n  repeated int64 card_ids = 1;\n}\n\nmessage SetDeckRequest {\n  repeated int64 card_ids = 1;\n  int64 deck_id = 2;\n}\n\nmessage SetFlagRequest {\n  repeated int64 card_ids = 1;\n  uint32 flag = 2;\n}\n"
  },
  {
    "path": "proto/anki/collection.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.collection;\n\nimport \"anki/generic.proto\";\nimport \"anki/sync.proto\";\n\nservice CollectionService {\n  rpc CheckDatabase(generic.Empty) returns (CheckDatabaseResponse);\n  rpc GetUndoStatus(generic.Empty) returns (UndoStatus);\n  rpc Undo(generic.Empty) returns (OpChangesAfterUndo);\n  rpc Redo(generic.Empty) returns (OpChangesAfterUndo);\n  rpc AddCustomUndoEntry(generic.String) returns (generic.UInt32);\n  rpc MergeUndoEntries(generic.UInt32) returns (OpChanges);\n  rpc LatestProgress(generic.Empty) returns (Progress);\n  rpc SetWantsAbort(generic.Empty) returns (generic.Empty);\n  rpc SetLoadBalancerEnabled(generic.Bool) returns (OpChanges);\n  rpc GetCustomColours(generic.Empty) returns (GetCustomColoursResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendCollectionService {\n  rpc OpenCollection(OpenCollectionRequest) returns (generic.Empty);\n  rpc CloseCollection(CloseCollectionRequest) returns (generic.Empty);\n  // Create a no-media backup. Caller must ensure there is no active\n  // transaction. Unlike a collection export, does not require reopening the DB,\n  // as there is no downgrade step.\n  // Returns false if it's not time to make a backup yet.\n  rpc CreateBackup(CreateBackupRequest) returns (generic.Bool);\n  // If a backup is running, wait for it to complete. Will return an error\n  // if the backup encountered an error.\n  rpc AwaitBackupCompletion(generic.Empty) returns (generic.Empty);\n  rpc LatestProgress(generic.Empty) returns (Progress);\n  rpc SetWantsAbort(generic.Empty) returns (generic.Empty);\n}\n\nmessage OpenCollectionRequest {\n  string collection_path = 1;\n  string media_folder_path = 2;\n  string media_db_path = 3;\n}\n\nmessage CloseCollectionRequest {\n  bool downgrade_to_schema11 = 1;\n}\n\nmessage CheckDatabaseResponse {\n  repeated string problems = 1;\n}\n\nmessage OpChanges {\n  bool card = 1;\n  bool note = 2;\n  bool deck = 3;\n  bool tag = 4;\n  bool notetype = 5;\n  bool config = 6;\n  bool deck_config = 11;\n  bool mtime = 12;\n\n  bool browser_table = 7;\n  bool browser_sidebar = 8;\n  // editor and displayed card in review screen\n  bool note_text = 9;\n  // whether to call .reset() and getCard()\n  bool study_queues = 10;\n}\n\n// Allows frontend code to extract changes from other messages like\n// ImportResponse without decoding other potentially large fields.\nmessage OpChangesOnly {\n  collection.OpChanges changes = 1;\n}\n\nmessage OpChangesWithCount {\n  OpChanges changes = 1;\n  uint32 count = 2;\n}\n\nmessage OpChangesWithId {\n  OpChanges changes = 1;\n  int64 id = 2;\n}\n\nmessage UndoStatus {\n  string undo = 1;\n  string redo = 2;\n  uint32 last_step = 3;\n}\n\nmessage OpChangesAfterUndo {\n  OpChanges changes = 1;\n  string operation = 2;\n  int64 reverted_to_timestamp = 3;\n  UndoStatus new_status = 4;\n  uint32 counter = 5;\n}\n\nmessage Progress {\n  message FullSync {\n    uint32 transferred = 1;\n    uint32 total = 2;\n  }\n\n  message NormalSync {\n    string stage = 1;\n    string added = 2;\n    string removed = 3;\n  }\n\n  message DatabaseCheck {\n    string stage = 1;\n    uint32 stage_total = 2;\n    uint32 stage_current = 3;\n  }\n\n  oneof value {\n    generic.Empty none = 1;\n    sync.MediaSyncProgress media_sync = 2;\n    string media_check = 3;\n    FullSync full_sync = 4;\n    NormalSync normal_sync = 5;\n    DatabaseCheck database_check = 6;\n    string importing = 7;\n    string exporting = 8;\n    ComputeParamsProgress compute_params = 9;\n    ComputeRetentionProgress compute_retention = 10;\n    ComputeMemoryProgress compute_memory = 11;\n  }\n}\n\nmessage ComputeParamsProgress {\n  // Current iteration\n  uint32 current = 1;\n  // Total iterations\n  uint32 total = 2;\n  uint32 reviews = 3;\n  // Only used in 'compute all params' case\n  uint32 current_preset = 4;\n  // Only used in 'compute all params' case\n  uint32 total_presets = 5;\n}\n\nmessage ComputeRetentionProgress {\n  uint32 current = 1;\n  uint32 total = 2;\n}\n\nmessage ComputeMemoryProgress {\n  uint32 current_cards = 1;\n  uint32 total_cards = 2;\n  string label = 3;\n}\n\nmessage CreateBackupRequest {\n  string backup_folder = 1;\n  // Create a backup even if the configured interval hasn't elapsed yet.\n  bool force = 2;\n  bool wait_for_completion = 3;\n}\n\nmessage GetCustomColoursResponse {\n  repeated string colours = 1;\n}\n"
  },
  {
    "path": "proto/anki/config.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.config;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\n\nservice ConfigService {\n  rpc GetConfigJson(generic.String) returns (generic.Json);\n  rpc SetConfigJson(SetConfigJsonRequest) returns (collection.OpChanges);\n  rpc SetConfigJsonNoUndo(SetConfigJsonRequest) returns (generic.Empty);\n  rpc RemoveConfig(generic.String) returns (collection.OpChanges);\n  rpc GetAllConfig(generic.Empty) returns (generic.Json);\n  rpc GetConfigBool(GetConfigBoolRequest) returns (generic.Bool);\n  rpc SetConfigBool(SetConfigBoolRequest) returns (collection.OpChanges);\n  rpc GetConfigString(GetConfigStringRequest) returns (generic.String);\n  rpc SetConfigString(SetConfigStringRequest) returns (collection.OpChanges);\n  rpc GetPreferences(generic.Empty) returns (Preferences);\n  rpc SetPreferences(Preferences) returns (collection.OpChanges);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendConfigService {}\n\nmessage ConfigKey {\n  enum Bool {\n    BROWSER_TABLE_SHOW_NOTES_MODE = 0;\n    PREVIEW_BOTH_SIDES = 3;\n    COLLAPSE_TAGS = 4;\n    COLLAPSE_NOTETYPES = 5;\n    COLLAPSE_DECKS = 6;\n    COLLAPSE_SAVED_SEARCHES = 7;\n    COLLAPSE_TODAY = 8;\n    COLLAPSE_CARD_STATE = 9;\n    COLLAPSE_FLAGS = 10;\n    SCHED_2021 = 11;\n    ADDING_DEFAULTS_TO_CURRENT_DECK = 12;\n    HIDE_AUDIO_PLAY_BUTTONS = 13;\n    INTERRUPT_AUDIO_WHEN_ANSWERING = 14;\n    PASTE_IMAGES_AS_PNG = 15;\n    PASTE_STRIPS_FORMATTING = 16;\n    NORMALIZE_NOTE_TEXT = 17;\n    IGNORE_ACCENTS_IN_SEARCH = 18;\n    RESTORE_POSITION_BROWSER = 19;\n    RESTORE_POSITION_REVIEWER = 20;\n    RESET_COUNTS_BROWSER = 21;\n    RESET_COUNTS_REVIEWER = 22;\n    RANDOM_ORDER_REPOSITION = 23;\n    SHIFT_POSITION_OF_EXISTING_CARDS = 24;\n    RENDER_LATEX = 25;\n    LOAD_BALANCER_ENABLED = 26;\n    FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27;\n    FSRS_LEGACY_EVALUATE = 28;\n  }\n  enum String {\n    SET_DUE_BROWSER = 0;\n    SET_DUE_REVIEWER = 1;\n    DEFAULT_SEARCH_TEXT = 2;\n    CARD_STATE_CUSTOMIZER = 3;\n  }\n}\n\nmessage GetConfigBoolRequest {\n  ConfigKey.Bool key = 1;\n}\n\nmessage SetConfigBoolRequest {\n  ConfigKey.Bool key = 1;\n  bool value = 2;\n  bool undoable = 3;\n}\n\nmessage GetConfigStringRequest {\n  ConfigKey.String key = 1;\n}\n\nmessage SetConfigStringRequest {\n  ConfigKey.String key = 1;\n  string value = 2;\n  bool undoable = 3;\n}\n\nmessage OptionalStringConfigKey {\n  ConfigKey.String key = 1;\n}\n\nmessage SetConfigJsonRequest {\n  string key = 1;\n  bytes value_json = 2;\n  bool undoable = 3;\n}\n\nmessage Preferences {\n  message Scheduling {\n    enum NewReviewMix {\n      DISTRIBUTE = 0;\n      REVIEWS_FIRST = 1;\n      NEW_FIRST = 2;\n    }\n\n    uint32 rollover = 2;\n    uint32 learn_ahead_secs = 3;\n    NewReviewMix new_review_mix = 4;\n\n    // v2 only\n    bool new_timezone = 5;\n    bool day_learn_first = 6;\n  }\n  message Reviewing {\n    bool hide_audio_play_buttons = 1;\n    bool interrupt_audio_when_answering = 2;\n    bool show_remaining_due_counts = 3;\n    bool show_intervals_on_buttons = 4;\n    uint32 time_limit_secs = 5;\n    bool load_balancer_enabled = 6;\n    bool fsrs_short_term_with_steps_enabled = 7;\n  }\n  message Editing {\n    bool adding_defaults_to_current_deck = 1;\n    bool paste_images_as_png = 2;\n    bool paste_strips_formatting = 3;\n    string default_search_text = 4;\n    bool ignore_accents_in_search = 5;\n    bool render_latex = 6;\n  }\n  message BackupLimits {\n    uint32 daily = 1;\n    uint32 weekly = 2;\n    uint32 monthly = 3;\n    uint32 minimum_interval_mins = 4;\n  }\n\n  Scheduling scheduling = 1;\n  Reviewing reviewing = 2;\n  Editing editing = 3;\n  BackupLimits backups = 4;\n}\n"
  },
  {
    "path": "proto/anki/deck_config.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n// the DeckConfig message clashes with the name of the file\noption java_outer_classname = \"DeckConf\";\n\npackage anki.deck_config;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\nimport \"anki/decks.proto\";\n\nservice DeckConfigService {\n  rpc AddOrUpdateDeckConfigLegacy(generic.Json) returns (DeckConfigId);\n  rpc GetDeckConfig(DeckConfigId) returns (DeckConfig);\n  rpc AllDeckConfigLegacy(generic.Empty) returns (generic.Json);\n  rpc GetDeckConfigLegacy(DeckConfigId) returns (generic.Json);\n  rpc NewDeckConfigLegacy(generic.Empty) returns (generic.Json);\n  rpc RemoveDeckConfig(DeckConfigId) returns (generic.Empty);\n  rpc GetDeckConfigsForUpdate(decks.DeckId) returns (DeckConfigsForUpdate);\n  rpc UpdateDeckConfigs(UpdateDeckConfigsRequest)\n      returns (collection.OpChanges);\n  rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)\n      returns (GetIgnoredBeforeCountResponse);\n  rpc GetRetentionWorkload(GetRetentionWorkloadRequest)\n      returns (GetRetentionWorkloadResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendDeckConfigService {}\n\nmessage DeckConfigId {\n  int64 dcid = 1;\n}\n\nmessage GetRetentionWorkloadRequest {\n  repeated float w = 1;\n  string search = 2;\n}\n\nmessage GetRetentionWorkloadResponse {\n  map<uint32, float> costs = 1;\n}\n\nmessage GetIgnoredBeforeCountRequest {\n  string ignore_revlogs_before_date = 1;\n  string search = 2;\n}\n\nmessage GetIgnoredBeforeCountResponse {\n  uint64 included = 1;\n  uint64 total = 2;\n}\n\nmessage DeckConfig {\n  message Config {\n    enum NewCardInsertOrder {\n      NEW_CARD_INSERT_ORDER_DUE = 0;\n      NEW_CARD_INSERT_ORDER_RANDOM = 1;\n    }\n    enum NewCardGatherPriority {\n      // Decks in alphabetical order (preorder), then ascending position.\n      // Siblings are consecutive, provided they have the same position.\n      NEW_CARD_GATHER_PRIORITY_DECK = 0;\n      // Notes are randomly picked from each deck in alphabetical order.\n      // Siblings are consecutive, provided they have the same position.\n      NEW_CARD_GATHER_PRIORITY_DECK_THEN_RANDOM_NOTES = 5;\n      // Ascending position.\n      // Siblings are consecutive, provided they have the same position.\n      NEW_CARD_GATHER_PRIORITY_LOWEST_POSITION = 1;\n      // Descending position.\n      // Siblings are consecutive, provided they have the same position.\n      NEW_CARD_GATHER_PRIORITY_HIGHEST_POSITION = 2;\n      // Siblings are consecutive.\n      NEW_CARD_GATHER_PRIORITY_RANDOM_NOTES = 3;\n      // Siblings are neither grouped nor ordered.\n      NEW_CARD_GATHER_PRIORITY_RANDOM_CARDS = 4;\n    }\n    enum NewCardSortOrder {\n      // Ascending card template ordinal.\n      // For a given ordinal, cards appear in gather order.\n      NEW_CARD_SORT_ORDER_TEMPLATE = 0;\n      // Preserves original gather order (eg deck order).\n      NEW_CARD_SORT_ORDER_NO_SORT = 1;\n      // Ascending card template ordinal.\n      // For a given ordinal, cards appear in random order.\n      NEW_CARD_SORT_ORDER_TEMPLATE_THEN_RANDOM = 2;\n      // Random note order. For a given note, cards appear in template order.\n      NEW_CARD_SORT_ORDER_RANDOM_NOTE_THEN_TEMPLATE = 3;\n      // Fully randomized order.\n      NEW_CARD_SORT_ORDER_RANDOM_CARD = 4;\n    }\n    enum ReviewCardOrder {\n      REVIEW_CARD_ORDER_DAY = 0;\n      REVIEW_CARD_ORDER_DAY_THEN_DECK = 1;\n      REVIEW_CARD_ORDER_DECK_THEN_DAY = 2;\n      REVIEW_CARD_ORDER_INTERVALS_ASCENDING = 3;\n      REVIEW_CARD_ORDER_INTERVALS_DESCENDING = 4;\n      REVIEW_CARD_ORDER_EASE_ASCENDING = 5;\n      REVIEW_CARD_ORDER_EASE_DESCENDING = 6;\n      REVIEW_CARD_ORDER_RETRIEVABILITY_ASCENDING = 7;\n      REVIEW_CARD_ORDER_RETRIEVABILITY_DESCENDING = 11;\n      REVIEW_CARD_ORDER_RELATIVE_OVERDUENESS = 12;\n      REVIEW_CARD_ORDER_RANDOM = 8;\n      REVIEW_CARD_ORDER_ADDED = 9;\n      REVIEW_CARD_ORDER_REVERSE_ADDED = 10;\n    }\n    enum ReviewMix {\n      REVIEW_MIX_MIX_WITH_REVIEWS = 0;\n      REVIEW_MIX_AFTER_REVIEWS = 1;\n      REVIEW_MIX_BEFORE_REVIEWS = 2;\n    }\n    enum LeechAction {\n      LEECH_ACTION_SUSPEND = 0;\n      LEECH_ACTION_TAG_ONLY = 1;\n    }\n    enum AnswerAction {\n      ANSWER_ACTION_BURY_CARD = 0;\n      ANSWER_ACTION_ANSWER_AGAIN = 1;\n      ANSWER_ACTION_ANSWER_GOOD = 2;\n      ANSWER_ACTION_ANSWER_HARD = 3;\n      ANSWER_ACTION_SHOW_REMINDER = 4;\n    }\n    enum QuestionAction {\n      QUESTION_ACTION_SHOW_ANSWER = 0;\n      QUESTION_ACTION_SHOW_REMINDER = 1;\n    }\n    repeated float learn_steps = 1;\n    repeated float relearn_steps = 2;\n\n    repeated float fsrs_params_4 = 3;\n    repeated float fsrs_params_5 = 5;\n    repeated float fsrs_params_6 = 6;\n\n    // consider saving remaining ones for fsrs param changes\n    reserved 7 to 8;\n\n    uint32 new_per_day = 9;\n    uint32 reviews_per_day = 10;\n\n    // not currently used\n    uint32 new_per_day_minimum = 35;\n\n    float initial_ease = 11;\n    float easy_multiplier = 12;\n    float hard_multiplier = 13;\n    float lapse_multiplier = 14;\n    float interval_multiplier = 15;\n\n    uint32 maximum_review_interval = 16;\n    uint32 minimum_lapse_interval = 17;\n\n    uint32 graduating_interval_good = 18;\n    uint32 graduating_interval_easy = 19;\n\n    NewCardInsertOrder new_card_insert_order = 20;\n    NewCardGatherPriority new_card_gather_priority = 34;\n    NewCardSortOrder new_card_sort_order = 32;\n    ReviewMix new_mix = 30;\n\n    ReviewCardOrder review_order = 33;\n\n    ReviewMix interday_learning_mix = 31;\n\n    LeechAction leech_action = 21;\n    uint32 leech_threshold = 22;\n\n    bool disable_autoplay = 23;\n    uint32 cap_answer_time_to_secs = 24;\n    bool show_timer = 25;\n    bool stop_timer_on_answer = 38;\n    float seconds_to_show_question = 41;\n    float seconds_to_show_answer = 42;\n    QuestionAction question_action = 36;\n    AnswerAction answer_action = 43;\n    bool wait_for_audio = 44;\n    bool skip_question_when_replaying_answer = 26;\n\n    bool bury_new = 27;\n    bool bury_reviews = 28;\n    bool bury_interday_learning = 29;\n\n    // for fsrs\n    float desired_retention = 37;\n    string ignore_revlogs_before_date = 46;\n    repeated float easy_days_percentages = 4;\n    // used for fsrs_reschedule in the past\n    reserved 39;\n    float historical_retention = 40;\n    string param_search = 45;\n\n    bytes other = 255;\n  }\n\n  int64 id = 1;\n  string name = 2;\n  int64 mtime_secs = 3;\n  int32 usn = 4;\n  Config config = 5;\n}\n\nmessage DeckConfigsForUpdate {\n  message ConfigWithExtra {\n    DeckConfig config = 1;\n    uint32 use_count = 2;\n  }\n  message CurrentDeck {\n    message Limits {\n      optional uint32 review = 1;\n      optional uint32 new = 2;\n      optional uint32 review_today = 3;\n      optional uint32 new_today = 4;\n      // Whether review_today applies to today or a past day.\n      bool review_today_active = 5;\n      // Whether new_today applies to today or a past day.\n      bool new_today_active = 6;\n      // Deck-specific desired retention override\n      optional float desired_retention = 7;\n    }\n    string name = 1;\n    int64 config_id = 2;\n    repeated int64 parent_config_ids = 3;\n    Limits limits = 4;\n  }\n\n  repeated ConfigWithExtra all_config = 1;\n  CurrentDeck current_deck = 2;\n  DeckConfig defaults = 3;\n  bool schema_modified = 4;\n  // only applies to v3 scheduler\n  string card_state_customizer = 6;\n  // only applies to v3 scheduler\n  bool new_cards_ignore_review_limit = 7;\n  bool fsrs = 8;\n  bool fsrs_health_check = 11;\n  bool fsrs_legacy_evaluate = 12;\n  bool apply_all_parent_limits = 9;\n  uint32 days_since_last_fsrs_optimize = 10;\n}\n\nenum UpdateDeckConfigsMode {\n  UPDATE_DECK_CONFIGS_MODE_NORMAL = 0;\n  UPDATE_DECK_CONFIGS_MODE_APPLY_TO_CHILDREN = 1;\n  UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS = 2;\n}\n\nmessage UpdateDeckConfigsRequest {\n  int64 target_deck_id = 1;\n  /// Unchanged, non-selected configs can be omitted. Deck will\n  /// be set to whichever entry comes last.\n  repeated DeckConfig configs = 2;\n  repeated int64 removed_config_ids = 3;\n  UpdateDeckConfigsMode mode = 4;\n  string card_state_customizer = 5;\n  DeckConfigsForUpdate.CurrentDeck.Limits limits = 6;\n  bool new_cards_ignore_review_limit = 7;\n  bool fsrs = 8;\n  bool apply_all_parent_limits = 9;\n  bool fsrs_reschedule = 10;\n  bool fsrs_health_check = 11;\n}\n"
  },
  {
    "path": "proto/anki/decks.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.decks;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\n\nservice DecksService {\n  rpc NewDeck(generic.Empty) returns (Deck);\n  rpc AddDeck(Deck) returns (collection.OpChangesWithId);\n  rpc AddDeckLegacy(generic.Json) returns (collection.OpChangesWithId);\n  rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyRequest) returns (DeckId);\n  rpc DeckTree(DeckTreeRequest) returns (DeckTreeNode);\n  rpc DeckTreeLegacy(generic.Empty) returns (generic.Json);\n  rpc GetAllDecksLegacy(generic.Empty) returns (generic.Json);\n  rpc GetDeckIdByName(generic.String) returns (DeckId);\n  rpc GetDeck(DeckId) returns (Deck);\n  rpc UpdateDeck(Deck) returns (collection.OpChanges);\n  rpc UpdateDeckLegacy(generic.Json) returns (collection.OpChanges);\n  rpc SetDeckCollapsed(SetDeckCollapsedRequest) returns (collection.OpChanges);\n  rpc GetDeckLegacy(DeckId) returns (generic.Json);\n  rpc GetDeckNames(GetDeckNamesRequest) returns (DeckNames);\n  rpc GetDeckAndChildNames(DeckId) returns (DeckNames);\n  rpc NewDeckLegacy(generic.Bool) returns (generic.Json);\n  rpc RemoveDecks(DeckIds) returns (collection.OpChangesWithCount);\n  rpc ReparentDecks(ReparentDecksRequest)\n      returns (collection.OpChangesWithCount);\n  rpc RenameDeck(RenameDeckRequest) returns (collection.OpChanges);\n  rpc GetOrCreateFilteredDeck(DeckId) returns (FilteredDeckForUpdate);\n  rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate)\n      returns (collection.OpChangesWithId);\n  rpc FilteredDeckOrderLabels(generic.Empty) returns (generic.StringList);\n  rpc SetCurrentDeck(DeckId) returns (collection.OpChanges);\n  rpc GetCurrentDeck(generic.Empty) returns (Deck);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendDecksService {}\n\nmessage DeckId {\n  int64 did = 1;\n}\n\nmessage DeckIds {\n  repeated int64 dids = 1;\n}\n\nmessage Deck {\n  message Common {\n    bool study_collapsed = 1;\n    bool browser_collapsed = 2;\n\n    uint32 last_day_studied = 3;\n    int32 new_studied = 4;\n    int32 review_studied = 5;\n    int32 milliseconds_studied = 7;\n\n    // previously set in the v1 scheduler,\n    // but not currently used for anything\n    int32 learning_studied = 6;\n\n    reserved 8 to 13;\n\n    bytes other = 255;\n  }\n  message Normal {\n    message DayLimit {\n      uint32 limit = 1;\n      uint32 today = 2;\n    }\n    int64 config_id = 1;\n    uint32 extend_new = 2;\n    uint32 extend_review = 3;\n    string description = 4;\n    bool markdown_description = 5;\n    optional uint32 review_limit = 6;\n    optional uint32 new_limit = 7;\n    DayLimit review_limit_today = 8;\n    DayLimit new_limit_today = 9;\n    // Deck-specific desired retention override\n    optional float desired_retention = 10;\n\n    reserved 12 to 15;\n  }\n  message Filtered {\n    message SearchTerm {\n      enum Order {\n        OLDEST_REVIEWED_FIRST = 0;\n        RANDOM = 1;\n        INTERVALS_ASCENDING = 2;\n        INTERVALS_DESCENDING = 3;\n        LAPSES = 4;\n        ADDED = 5;\n        DUE = 6;\n        REVERSE_ADDED = 7;\n        RETRIEVABILITY_ASCENDING = 8;\n        RETRIEVABILITY_DESCENDING = 9;\n        RELATIVE_OVERDUENESS = 10;\n      }\n\n      string search = 1;\n      uint32 limit = 2;\n      Order order = 3;\n    }\n\n    bool reschedule = 1;\n    repeated SearchTerm search_terms = 2;\n    // v1 scheduler only\n    repeated float delays = 3;\n    // v2 and old v3 scheduler only\n    uint32 preview_delay = 4;\n    // recent v3 scheduler only; 0 means card will be returned\n    uint32 preview_again_secs = 7;\n    // recent v3 scheduler only; 0 means card will be returned\n    uint32 preview_hard_secs = 5;\n    // recent v3 scheduler only; 0 means card will be returned\n    uint32 preview_good_secs = 6;\n  }\n  // a container to store the deck specifics in the DB\n  // as a tagged enum\n  message KindContainer {\n    oneof kind {\n      Normal normal = 1;\n      Filtered filtered = 2;\n    }\n  }\n\n  int64 id = 1;\n  string name = 2;\n  int64 mtime_secs = 3;\n  int32 usn = 4;\n  Common common = 5;\n  // the specifics are inlined here when sending data to clients,\n  // as otherwise an extra level of indirection would be required\n  oneof kind {\n    Normal normal = 6;\n    Filtered filtered = 7;\n  }\n}\n\nmessage AddOrUpdateDeckLegacyRequest {\n  bytes deck = 1;\n  bool preserve_usn_and_mtime = 2;\n}\n\nmessage DeckTreeRequest {\n  // if non-zero, counts for the provided timestamp will be included\n  int64 now = 1;\n}\n\nmessage DeckTreeNode {\n  int64 deck_id = 1;\n  string name = 2;\n  uint32 level = 4;\n  bool collapsed = 5;\n\n  // counts after adding children+applying limits\n  uint32 review_count = 6;\n  uint32 learn_count = 7;\n  uint32 new_count = 8;\n\n  // card counts without children or limits applied\n  uint32 intraday_learning = 9;\n  uint32 interday_learning_uncapped = 10;\n  uint32 new_uncapped = 11;\n  uint32 review_uncapped = 12;\n  uint32 total_in_deck = 13;\n\n  // with children, without any limits\n  uint32 total_including_children = 14;\n\n  bool filtered = 16;\n\n  // low index so key can be packed into a byte, but at bottom\n  // to make debug output easier to read\n  repeated DeckTreeNode children = 3;\n}\n\nmessage SetDeckCollapsedRequest {\n  enum Scope {\n    REVIEWER = 0;\n    BROWSER = 1;\n  }\n\n  int64 deck_id = 1;\n  bool collapsed = 2;\n  Scope scope = 3;\n}\n\nmessage GetDeckNamesRequest {\n  bool skip_empty_default = 1;\n  // if unset, implies skip_empty_default\n  bool include_filtered = 2;\n}\n\nmessage DeckNames {\n  repeated DeckNameId entries = 1;\n}\n\nmessage DeckNameId {\n  int64 id = 1;\n  string name = 2;\n}\n\nmessage ReparentDecksRequest {\n  repeated int64 deck_ids = 1;\n  int64 new_parent = 2;\n}\n\nmessage RenameDeckRequest {\n  int64 deck_id = 1;\n  string new_name = 2;\n}\n\nmessage FilteredDeckForUpdate {\n  int64 id = 1;\n  string name = 2;\n  Deck.Filtered config = 3;\n  bool allow_empty = 4;\n}\n"
  },
  {
    "path": "proto/anki/frontend.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.frontend;\n\nimport \"anki/scheduler.proto\";\nimport \"anki/generic.proto\";\nimport \"anki/search.proto\";\n\nservice FrontendService {\n  // Returns values from the reviewer\n  rpc GetSchedulingStatesWithContext(generic.Empty)\n      returns (SchedulingStatesWithContext);\n  // Updates reviewer state\n  rpc SetSchedulingStates(SetSchedulingStatesRequest) returns (generic.Empty);\n\n  // Notify Qt layer so window modality can be updated.\n  rpc ImportDone(generic.Empty) returns (generic.Empty);\n\n  rpc SearchInBrowser(search.SearchNode) returns (generic.Empty);\n\n  // Force closing the deck options.\n  rpc deckOptionsRequireClose(generic.Empty) returns (generic.Empty);\n  // Warns python that the deck option web view is ready to receive requests.\n  rpc deckOptionsReady(generic.Empty) returns (generic.Empty);\n\n  // Save colour picker's custom colour palette\n  rpc SaveCustomColours(generic.Empty) returns (generic.Empty);\n}\n\nservice BackendFrontendService {}\n\nmessage SchedulingStatesWithContext {\n  scheduler.SchedulingStates states = 1;\n  scheduler.SchedulingContext context = 2;\n}\n\nmessage SetSchedulingStatesRequest {\n  string key = 1;\n  scheduler.SchedulingStates states = 2;\n}\n"
  },
  {
    "path": "proto/anki/generic.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.generic;\n\nmessage Empty {}\n\nmessage Int32 {\n  sint32 val = 1;\n}\n\nmessage UInt32 {\n  uint32 val = 1;\n}\n\nmessage Int64 {\n  int64 val = 1;\n}\n\nmessage String {\n  string val = 1;\n}\n\nmessage Json {\n  bytes json = 1;\n}\n\nmessage Bool {\n  bool val = 1;\n}\n\nmessage StringList {\n  repeated string vals = 1;\n}\n"
  },
  {
    "path": "proto/anki/i18n.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.i18n;\n\nimport \"anki/generic.proto\";\n\nservice I18nService {\n  rpc TranslateString(TranslateStringRequest) returns (generic.String);\n  rpc FormatTimespan(FormatTimespanRequest) returns (generic.String);\n  rpc I18nResources(I18nResourcesRequest) returns (generic.Json);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendI18nService {\n  rpc TranslateString(TranslateStringRequest) returns (generic.String);\n  rpc FormatTimespan(FormatTimespanRequest) returns (generic.String);\n  rpc I18nResources(I18nResourcesRequest) returns (generic.Json);\n}\n\nmessage TranslateStringRequest {\n  uint32 module_index = 1;\n  uint32 message_index = 2;\n  map<string, TranslateArgValue> args = 3;\n}\n\nmessage TranslateArgValue {\n  oneof value {\n    string str = 1;\n    double number = 2;\n  }\n}\n\nmessage FormatTimespanRequest {\n  enum Context {\n    PRECISE = 0;\n    ANSWER_BUTTONS = 1;\n    INTERVALS = 2;\n  }\n\n  float seconds = 1;\n  Context context = 2;\n}\n\nmessage I18nResourcesRequest {\n  repeated string modules = 1;\n}\n"
  },
  {
    "path": "proto/anki/image_occlusion.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.image_occlusion;\n\nimport \"anki/collection.proto\";\nimport \"anki/generic.proto\";\n\nservice ImageOcclusionService {\n  rpc GetImageForOcclusion(GetImageForOcclusionRequest)\n      returns (GetImageForOcclusionResponse);\n  rpc GetImageOcclusionNote(GetImageOcclusionNoteRequest)\n      returns (GetImageOcclusionNoteResponse);\n  rpc GetImageOcclusionFields(GetImageOcclusionFieldsRequest)\n      returns (GetImageOcclusionFieldsResponse);\n  // Adds an I/O notetype if none exists in the collection.\n  rpc AddImageOcclusionNotetype(generic.Empty) returns (collection.OpChanges);\n  // These two are used by the standalone I/O page, but not used when using\n  // I/O inside Anki's editor\n  rpc AddImageOcclusionNote(AddImageOcclusionNoteRequest)\n      returns (collection.OpChanges);\n  rpc UpdateImageOcclusionNote(UpdateImageOcclusionNoteRequest)\n      returns (collection.OpChanges);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendImageOcclusionService {}\n\nmessage GetImageForOcclusionRequest {\n  string path = 1;\n}\n\nmessage GetImageForOcclusionResponse {\n  bytes data = 1;\n  string name = 2;\n}\n\nmessage AddImageOcclusionNoteRequest {\n  string image_path = 1;\n  string occlusions = 2;\n  string header = 3;\n  string back_extra = 4;\n  repeated string tags = 5;\n  int64 notetype_id = 6;\n}\n\nmessage GetImageOcclusionNoteRequest {\n  int64 note_id = 1;\n}\n\nmessage GetImageOcclusionNoteResponse {\n  message ImageOcclusionProperty {\n    string name = 1;\n    string value = 2;\n  }\n\n  message ImageOcclusionShape {\n    string shape = 1;\n    repeated ImageOcclusionProperty properties = 2;\n  }\n\n  message ImageOcclusion {\n    repeated ImageOcclusionShape shapes = 1;\n    uint32 ordinal = 2;\n  }\n\n  message ImageOcclusionNote {\n    bytes image_data = 1;\n    repeated ImageOcclusion occlusions = 2;\n    string header = 3;\n    string back_extra = 4;\n    repeated string tags = 5;\n    string image_file_name = 6;\n    bool occlude_inactive = 7;\n  }\n\n  oneof value {\n    ImageOcclusionNote note = 1;\n    string error = 2;\n  }\n}\n\nmessage UpdateImageOcclusionNoteRequest {\n  int64 note_id = 1;\n  string occlusions = 2;\n  string header = 3;\n  string back_extra = 4;\n  repeated string tags = 5;\n}\n\nmessage GetImageOcclusionFieldsRequest {\n  int64 notetype_id = 1;\n}\n\nmessage GetImageOcclusionFieldsResponse {\n  ImageOcclusionFieldIndexes fields = 1;\n}\n\nmessage ImageOcclusionFieldIndexes {\n  uint32 occlusions = 1;\n  uint32 image = 2;\n  uint32 header = 3;\n  uint32 back_extra = 4;\n}\n"
  },
  {
    "path": "proto/anki/import_export.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.import_export;\n\nimport \"anki/cards.proto\";\nimport \"anki/collection.proto\";\nimport \"anki/notes.proto\";\nimport \"anki/generic.proto\";\n\nservice ImportExportService {\n  rpc ImportAnkiPackage(ImportAnkiPackageRequest) returns (ImportResponse);\n  rpc GetImportAnkiPackagePresets(generic.Empty)\n      returns (ImportAnkiPackageOptions);\n  rpc ExportAnkiPackage(ExportAnkiPackageRequest) returns (generic.UInt32);\n  rpc GetCsvMetadata(CsvMetadataRequest) returns (CsvMetadata);\n  rpc ImportCsv(ImportCsvRequest) returns (ImportResponse);\n  rpc ExportNoteCsv(ExportNoteCsvRequest) returns (generic.UInt32);\n  rpc ExportCardCsv(ExportCardCsvRequest) returns (generic.UInt32);\n  rpc ImportJsonFile(generic.String) returns (ImportResponse);\n  rpc ImportJsonString(generic.String) returns (ImportResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendImportExportService {\n  rpc ImportCollectionPackage(ImportCollectionPackageRequest)\n      returns (generic.Empty);\n  rpc ExportCollectionPackage(ExportCollectionPackageRequest)\n      returns (generic.Empty);\n}\n\nmessage ImportCollectionPackageRequest {\n  string col_path = 1;\n  string backup_path = 2;\n  string media_folder = 3;\n  string media_db = 4;\n}\n\nmessage ExportCollectionPackageRequest {\n  string out_path = 1;\n  bool include_media = 2;\n  bool legacy = 3;\n}\n\nenum ImportAnkiPackageUpdateCondition {\n  IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_IF_NEWER = 0;\n  IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_ALWAYS = 1;\n  IMPORT_ANKI_PACKAGE_UPDATE_CONDITION_NEVER = 2;\n}\n\nmessage ImportAnkiPackageOptions {\n  bool merge_notetypes = 1;\n  ImportAnkiPackageUpdateCondition update_notes = 2;\n  ImportAnkiPackageUpdateCondition update_notetypes = 3;\n  bool with_scheduling = 4;\n  bool with_deck_configs = 5;\n}\n\nmessage ImportAnkiPackageRequest {\n  string package_path = 1;\n  ImportAnkiPackageOptions options = 2;\n}\n\nmessage ImportResponse {\n  message Note {\n    notes.NoteId id = 1;\n    repeated string fields = 2;\n  }\n  message Log {\n    repeated Note new = 1;\n    repeated Note updated = 2;\n    repeated Note duplicate = 3;\n    repeated Note conflicting = 4;\n    repeated Note first_field_match = 5;\n    repeated Note missing_notetype = 6;\n    repeated Note missing_deck = 7;\n    repeated Note empty_first_field = 8;\n    CsvMetadata.DupeResolution dupe_resolution = 9;\n    uint32 found_notes = 10;\n  }\n  collection.OpChanges changes = 1;\n  Log log = 2;\n}\n\nmessage ExportAnkiPackageRequest {\n  string out_path = 1;\n  ExportAnkiPackageOptions options = 2;\n  ExportLimit limit = 3;\n}\n\nmessage ExportAnkiPackageOptions {\n  bool with_scheduling = 1;\n  bool with_deck_configs = 2;\n  bool with_media = 3;\n  bool legacy = 4;\n}\n\nmessage PackageMetadata {\n  enum Version {\n    VERSION_UNKNOWN = 0;\n    // When `meta` missing, and collection.anki2 file present.\n    VERSION_LEGACY_1 = 1;\n    // When `meta` missing, and collection.anki21 file present.\n    VERSION_LEGACY_2 = 2;\n    // Implies MediaEntry media map, and zstd compression.\n    // collection.21b file\n    VERSION_LATEST = 3;\n  }\n\n  Version version = 1;\n}\n\nmessage MediaEntries {\n  message MediaEntry {\n    string name = 1;\n    uint32 size = 2;\n    bytes sha1 = 3;\n\n    /// Legacy media maps may include gaps in the media list, so the original\n    /// file index is recorded when importing from a HashMap. This field is not\n    /// set when exporting.\n    optional uint32 legacy_zip_filename = 255;\n  }\n\n  repeated MediaEntry entries = 1;\n}\n\nmessage ImportCsvRequest {\n  string path = 1;\n  CsvMetadata metadata = 2;\n}\n\nmessage CsvMetadataRequest {\n  string path = 1;\n  optional CsvMetadata.Delimiter delimiter = 2;\n  optional int64 notetype_id = 3;\n  optional int64 deck_id = 4;\n  optional bool is_html = 5;\n}\n\n// Column indices are 1-based to make working with them in TS easier, where\n// unset numerical fields default to 0.\nmessage CsvMetadata {\n  enum DupeResolution {\n    UPDATE = 0;\n    PRESERVE = 1;\n    DUPLICATE = 2;\n    // UPDATE_IF_NEWER = 3;\n  }\n  // Order roughly in ascending expected frequency in note text, because the\n  // delimiter detection algorithm is stupidly picking the first one it\n  // encounters.\n  enum Delimiter {\n    TAB = 0;\n    PIPE = 1;\n    SEMICOLON = 2;\n    COLON = 3;\n    COMMA = 4;\n    SPACE = 5;\n  }\n  message MappedNotetype {\n    int64 id = 1;\n    // Source column indices for note fields. One-based. 0 means n/a.\n    repeated uint32 field_columns = 2;\n  }\n  Delimiter delimiter = 1;\n  bool is_html = 2;\n  repeated string global_tags = 3;\n  repeated string updated_tags = 4;\n  // Column names as defined by the file or empty strings otherwise. Also used\n  // to determine the number of columns.\n  repeated string column_labels = 5;\n  oneof deck {\n    // id of an existing deck\n    int64 deck_id = 6;\n    // One-based. 0 means n/a.\n    uint32 deck_column = 7;\n    // name of new deck to be created\n    string deck_name = 17;\n  }\n  oneof notetype {\n    // One notetype for all rows with given column mapping.\n    MappedNotetype global_notetype = 8;\n    // Row-specific notetypes with automatic mapping by index.\n    // One-based. 0 means n/a.\n    uint32 notetype_column = 9;\n  }\n  enum MatchScope {\n    NOTETYPE = 0;\n    NOTETYPE_AND_DECK = 1;\n  }\n  // One-based. 0 means n/a.\n  uint32 tags_column = 10;\n  bool force_delimiter = 11;\n  bool force_is_html = 12;\n  repeated generic.StringList preview = 13;\n  uint32 guid_column = 14;\n  DupeResolution dupe_resolution = 15;\n  MatchScope match_scope = 16;\n}\n\nmessage ExportCardCsvRequest {\n  string out_path = 1;\n  bool with_html = 2;\n  ExportLimit limit = 3;\n}\n\nmessage ExportNoteCsvRequest {\n  string out_path = 1;\n  bool with_html = 2;\n  bool with_tags = 3;\n  bool with_deck = 4;\n  bool with_notetype = 5;\n  bool with_guid = 6;\n  ExportLimit limit = 7;\n}\n\nmessage ExportLimit {\n  oneof limit {\n    generic.Empty whole_collection = 1;\n    int64 deck_id = 2;\n    notes.NoteIds note_ids = 3;\n    cards.CardIds card_ids = 4;\n  }\n}\n"
  },
  {
    "path": "proto/anki/links.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.links;\n\nimport \"anki/generic.proto\";\n\nservice LinksService {\n  rpc HelpPageLink(HelpPageLinkRequest) returns (generic.String);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendLinksService {}\n\nmessage HelpPageLinkRequest {\n  enum HelpPage {\n    NOTE_TYPE = 0;\n    BROWSING = 1;\n    BROWSING_FIND_AND_REPLACE = 2;\n    BROWSING_NOTES_MENU = 3;\n    KEYBOARD_SHORTCUTS = 4;\n    EDITING = 5;\n    ADDING_CARD_AND_NOTE = 6;\n    ADDING_A_NOTE_TYPE = 7;\n    LATEX = 8;\n    PREFERENCES = 9;\n    INDEX = 10;\n    TEMPLATES = 11;\n    FILTERED_DECK = 12;\n    IMPORTING = 13;\n    CUSTOMIZING_FIELDS = 14;\n    DECK_OPTIONS = 15;\n    EDITING_FEATURES = 16;\n    FULL_SCREEN_ISSUE = 17;\n    CARD_TYPE_DUPLICATE = 18;\n    CARD_TYPE_NO_FRONT_FIELD = 19;\n    CARD_TYPE_MISSING_CLOZE = 20;\n    TROUBLESHOOTING = 21;\n    CARD_TYPE_TEMPLATE_ERROR = 22;\n  }\n  HelpPage page = 1;\n}\n"
  },
  {
    "path": "proto/anki/media.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.media;\n\nimport \"anki/generic.proto\";\nimport \"anki/notetypes.proto\";\n\nservice MediaService {\n  rpc CheckMedia(generic.Empty) returns (CheckMediaResponse);\n  rpc AddMediaFile(AddMediaFileRequest) returns (generic.String);\n  rpc TrashMediaFiles(TrashMediaFilesRequest) returns (generic.Empty);\n  rpc EmptyTrash(generic.Empty) returns (generic.Empty);\n  rpc RestoreTrash(generic.Empty) returns (generic.Empty);\n  rpc ExtractStaticMediaFiles(notetypes.NotetypeId)\n      returns (generic.StringList);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendMediaService {}\n\nmessage CheckMediaResponse {\n  repeated string unused = 1;\n  repeated string missing = 2;\n  repeated int64 missing_media_notes = 3;\n  string report = 4;\n  bool have_trash = 5;\n}\n\nmessage TrashMediaFilesRequest {\n  repeated string fnames = 1;\n}\n\nmessage AddMediaFileRequest {\n  string desired_name = 1;\n  bytes data = 2;\n}\n"
  },
  {
    "path": "proto/anki/notes.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.notes;\n\nimport \"anki/notetypes.proto\";\nimport \"anki/collection.proto\";\nimport \"anki/decks.proto\";\nimport \"anki/cards.proto\";\n\nservice NotesService {\n  rpc NewNote(notetypes.NotetypeId) returns (Note);\n  rpc AddNote(AddNoteRequest) returns (AddNoteResponse);\n  rpc AddNotes(AddNotesRequest) returns (AddNotesResponse);\n  rpc DefaultsForAdding(DefaultsForAddingRequest) returns (DeckAndNotetype);\n  rpc DefaultDeckForNotetype(notetypes.NotetypeId) returns (decks.DeckId);\n  rpc UpdateNotes(UpdateNotesRequest) returns (collection.OpChanges);\n  rpc GetNote(NoteId) returns (Note);\n  rpc RemoveNotes(RemoveNotesRequest) returns (collection.OpChangesWithCount);\n  rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteResponse);\n  rpc AfterNoteUpdates(AfterNoteUpdatesRequest)\n      returns (collection.OpChangesWithCount);\n  rpc FieldNamesForNotes(FieldNamesForNotesRequest)\n      returns (FieldNamesForNotesResponse);\n  rpc NoteFieldsCheck(Note) returns (NoteFieldsCheckResponse);\n  rpc CardsOfNote(NoteId) returns (cards.CardIds);\n  rpc GetSingleNotetypeOfNotes(notes.NoteIds) returns (notetypes.NotetypeId);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendNotesService {}\n\nmessage NoteId {\n  int64 nid = 1;\n}\n\nmessage NoteIds {\n  repeated int64 note_ids = 1;\n}\n\nmessage Note {\n  int64 id = 1;\n  string guid = 2;\n  int64 notetype_id = 3;\n  uint32 mtime_secs = 4;\n  int32 usn = 5;\n  repeated string tags = 6;\n  repeated string fields = 7;\n}\n\nmessage AddNoteRequest {\n  Note note = 1;\n  int64 deck_id = 2;\n}\n\nmessage AddNoteResponse {\n  collection.OpChangesWithCount changes = 1;\n  int64 note_id = 2;\n}\n\nmessage AddNotesRequest {\n  repeated AddNoteRequest requests = 1;\n}\n\nmessage AddNotesResponse {\n  collection.OpChanges changes = 1;\n  repeated int64 nids = 2;\n}\n\nmessage UpdateNotesRequest {\n  repeated Note notes = 1;\n  bool skip_undo_entry = 2;\n}\n\nmessage DefaultsForAddingRequest {\n  int64 home_deck_of_current_review_card = 1;\n}\n\nmessage DeckAndNotetype {\n  int64 deck_id = 1;\n  int64 notetype_id = 2;\n}\n\nmessage RemoveNotesRequest {\n  repeated int64 note_ids = 1;\n  repeated int64 card_ids = 2;\n}\n\nmessage ClozeNumbersInNoteResponse {\n  repeated uint32 numbers = 1;\n}\n\nmessage AfterNoteUpdatesRequest {\n  repeated int64 nids = 1;\n  bool mark_notes_modified = 2;\n  bool generate_cards = 3;\n}\n\nmessage FieldNamesForNotesRequest {\n  repeated int64 nids = 1;\n}\n\nmessage FieldNamesForNotesResponse {\n  repeated string fields = 1;\n}\n\nmessage NoteFieldsCheckResponse {\n  enum State {\n    NORMAL = 0;\n    EMPTY = 1;\n    DUPLICATE = 2;\n    MISSING_CLOZE = 3;\n    NOTETYPE_NOT_CLOZE = 4;\n    FIELD_NOT_CLOZE = 5;\n  }\n  State state = 1;\n}\n"
  },
  {
    "path": "proto/anki/notetypes.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.notetypes;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\n\nservice NotetypesService {\n  rpc AddNotetype(Notetype) returns (collection.OpChangesWithId);\n  rpc UpdateNotetype(Notetype) returns (collection.OpChanges);\n  rpc AddNotetypeLegacy(generic.Json) returns (collection.OpChangesWithId);\n  rpc UpdateNotetypeLegacy(UpdateNotetypeLegacyRequest)\n      returns (collection.OpChanges);\n  rpc AddOrUpdateNotetype(AddOrUpdateNotetypeRequest) returns (NotetypeId);\n  rpc GetStockNotetypeLegacy(StockNotetype) returns (generic.Json);\n  rpc GetNotetype(NotetypeId) returns (Notetype);\n  rpc GetNotetypeLegacy(NotetypeId) returns (generic.Json);\n  rpc GetNotetypeNames(generic.Empty) returns (NotetypeNames);\n  rpc GetNotetypeNamesAndCounts(generic.Empty) returns (NotetypeUseCounts);\n  rpc GetNotetypeIdByName(generic.String) returns (NotetypeId);\n  rpc RemoveNotetype(NotetypeId) returns (collection.OpChanges);\n  rpc GetAuxNotetypeConfigKey(GetAuxConfigKeyRequest) returns (generic.String);\n  rpc GetAuxTemplateConfigKey(GetAuxTemplateConfigKeyRequest)\n      returns (generic.String);\n  rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoRequest)\n      returns (ChangeNotetypeInfo);\n  rpc ChangeNotetype(ChangeNotetypeRequest) returns (collection.OpChanges);\n  rpc GetFieldNames(NotetypeId) returns (generic.StringList);\n  rpc RestoreNotetypeToStock(RestoreNotetypeToStockRequest)\n      returns (collection.OpChanges);\n  rpc GetClozeFieldOrds(NotetypeId) returns (GetClozeFieldOrdsResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendNotetypesService {}\n\nmessage NotetypeId {\n  int64 ntid = 1;\n}\n\nmessage Notetype {\n  message Config {\n    enum Kind {\n      KIND_NORMAL = 0;\n      KIND_CLOZE = 1;\n    }\n    message CardRequirement {\n      enum Kind {\n        KIND_NONE = 0;\n        KIND_ANY = 1;\n        KIND_ALL = 2;\n      }\n      uint32 card_ord = 1;\n      Kind kind = 2;\n      repeated uint32 field_ords = 3;\n    }\n\n    Kind kind = 1;\n    uint32 sort_field_idx = 2;\n    string css = 3;\n    // This is now stored separately; retrieve with DefaultsForAdding()\n    int64 target_deck_id_unused = 4;\n    string latex_pre = 5;\n    string latex_post = 6;\n    bool latex_svg = 7;\n    repeated CardRequirement reqs = 8;\n    // Only set on notetypes created with Anki 2.1.62+.\n    StockNotetype.OriginalStockKind original_stock_kind = 9;\n    // the id in the source collection for imported notetypes (Anki 23.10)\n    optional int64 original_id = 10;\n\n    bytes other = 255;\n  }\n  message Field {\n    message Config {\n      bool sticky = 1;\n      bool rtl = 2;\n      string font_name = 3;\n      uint32 font_size = 4;\n      string description = 5;\n      bool plain_text = 6;\n      bool collapsed = 7;\n      bool exclude_from_search = 8;\n      // used for merging notetypes on import (Anki 23.10)\n      optional int64 id = 9;\n      // Can be used to uniquely identify required fields.\n      optional uint32 tag = 10;\n      bool prevent_deletion = 11;\n\n      bytes other = 255;\n    }\n    generic.UInt32 ord = 1;\n    string name = 2;\n    Config config = 5;\n  }\n  message Template {\n    message Config {\n      string q_format = 1;\n      string a_format = 2;\n      string q_format_browser = 3;\n      string a_format_browser = 4;\n      int64 target_deck_id = 5;\n      string browser_font_name = 6;\n      uint32 browser_font_size = 7;\n      // used for merging notetypes on import (Anki 23.10)\n      optional int64 id = 8;\n\n      bytes other = 255;\n    }\n\n    generic.UInt32 ord = 1;\n    string name = 2;\n    int64 mtime_secs = 3;\n    sint32 usn = 4;\n    Config config = 5;\n  }\n\n  int64 id = 1;\n  string name = 2;\n  int64 mtime_secs = 3;\n  sint32 usn = 4;\n  Config config = 7;\n  repeated Field fields = 8;\n  repeated Template templates = 9;\n}\n\nmessage AddOrUpdateNotetypeRequest {\n  bytes json = 1;\n  bool preserve_usn_and_mtime = 2;\n  bool skip_checks = 3;\n}\n\nmessage UpdateNotetypeLegacyRequest {\n  bytes json = 1;\n  bool skip_checks = 2;\n}\n\nmessage StockNotetype {\n  enum Kind {\n    KIND_BASIC = 0;\n    KIND_BASIC_AND_REVERSED = 1;\n    KIND_BASIC_OPTIONAL_REVERSED = 2;\n    KIND_BASIC_TYPING = 3;\n    KIND_CLOZE = 4;\n    KIND_IMAGE_OCCLUSION = 5;\n  }\n  // This is decoupled from Kind to allow us to evolve notetypes over time\n  // (eg an older notetype might require different JS), and allow us to store\n  // a type even for notetypes that we don't add by default. Code should not\n  // assume that the entries here are always +1 from Kind.\n  enum OriginalStockKind {\n    ORIGINAL_STOCK_KIND_UNKNOWN = 0;\n    ORIGINAL_STOCK_KIND_BASIC = 1;\n    ORIGINAL_STOCK_KIND_BASIC_AND_REVERSED = 2;\n    ORIGINAL_STOCK_KIND_BASIC_OPTIONAL_REVERSED = 3;\n    ORIGINAL_STOCK_KIND_BASIC_TYPING = 4;\n    ORIGINAL_STOCK_KIND_CLOZE = 5;\n    ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION = 6;\n  }\n\n  Kind kind = 1;\n}\n\nmessage NotetypeNames {\n  repeated NotetypeNameId entries = 1;\n}\n\nmessage NotetypeUseCounts {\n  repeated NotetypeNameIdUseCount entries = 1;\n}\n\nmessage NotetypeNameId {\n  int64 id = 1;\n  string name = 2;\n}\n\nmessage NotetypeNameIdUseCount {\n  int64 id = 1;\n  string name = 2;\n  uint32 use_count = 3;\n}\n\nmessage GetAuxConfigKeyRequest {\n  int64 id = 1;\n  string key = 2;\n}\n\nmessage GetAuxTemplateConfigKeyRequest {\n  int64 notetype_id = 1;\n  uint32 card_ordinal = 2;\n  string key = 3;\n}\n\nmessage GetChangeNotetypeInfoRequest {\n  int64 old_notetype_id = 1;\n  int64 new_notetype_id = 2;\n}\n\nmessage ChangeNotetypeRequest {\n  repeated int64 note_ids = 1;\n  // -1 is used to represent null, as nullable repeated fields\n  // are unwieldy in protobuf\n  repeated int32 new_fields = 2;\n  repeated int32 new_templates = 3;\n  int64 old_notetype_id = 4;\n  int64 new_notetype_id = 5;\n  int64 current_schema = 6;\n  string old_notetype_name = 7;\n  bool is_cloze = 8;\n}\n\nmessage ChangeNotetypeInfo {\n  repeated string old_field_names = 1;\n  repeated string old_template_names = 2;\n  repeated string new_field_names = 3;\n  repeated string new_template_names = 4;\n  ChangeNotetypeRequest input = 5;\n  string old_notetype_name = 6;\n}\n\nmessage RestoreNotetypeToStockRequest {\n  NotetypeId notetype_id = 1;\n  // Older notetypes did not store their original stock kind, so we allow the UI\n  // to pass in an override to use when missing, or for tests.\n  optional StockNotetype.Kind force_kind = 2;\n}\n\nenum ImageOcclusionField {\n  IMAGE_OCCLUSION_FIELD_OCCLUSIONS = 0;\n  IMAGE_OCCLUSION_FIELD_IMAGE = 1;\n  IMAGE_OCCLUSION_FIELD_HEADER = 2;\n  IMAGE_OCCLUSION_FIELD_BACK_EXTRA = 3;\n  IMAGE_OCCLUSION_FIELD_COMMENTS = 4;\n}\n\nenum ClozeField {\n  CLOZE_FIELD_TEXT = 0;\n  CLOZE_FIELD_BACK_EXTRA = 1;\n}\n\nmessage GetClozeFieldOrdsResponse {\n  repeated uint32 ords = 1;\n}"
  },
  {
    "path": "proto/anki/scheduler.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.scheduler;\n\nimport \"anki/generic.proto\";\nimport \"anki/cards.proto\";\nimport \"anki/decks.proto\";\nimport \"anki/collection.proto\";\nimport \"anki/config.proto\";\nimport \"anki/deck_config.proto\";\n\nservice SchedulerService {\n  rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);\n  rpc AnswerCard(CardAnswer) returns (collection.OpChanges);\n  rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);\n  rpc StudiedToday(generic.Empty) returns (generic.String);\n  rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);\n  rpc UpdateStats(UpdateStatsRequest) returns (generic.Empty);\n  rpc ExtendLimits(ExtendLimitsRequest) returns (generic.Empty);\n  rpc CountsForDeckToday(decks.DeckId) returns (CountsForDeckTodayResponse);\n  rpc CongratsInfo(generic.Empty) returns (CongratsInfoResponse);\n  rpc RestoreBuriedAndSuspendedCards(cards.CardIds)\n      returns (collection.OpChanges);\n  rpc UnburyDeck(UnburyDeckRequest) returns (collection.OpChanges);\n  rpc BuryOrSuspendCards(BuryOrSuspendCardsRequest)\n      returns (collection.OpChangesWithCount);\n  rpc EmptyFilteredDeck(decks.DeckId) returns (collection.OpChanges);\n  rpc RebuildFilteredDeck(decks.DeckId) returns (collection.OpChangesWithCount);\n  rpc ScheduleCardsAsNew(ScheduleCardsAsNewRequest)\n      returns (collection.OpChanges);\n  rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest)\n      returns (ScheduleCardsAsNewDefaultsResponse);\n  rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges);\n  rpc GradeNow(GradeNowRequest) returns (collection.OpChanges);\n  rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount);\n  rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount);\n  rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates);\n  rpc DescribeNextStates(SchedulingStates) returns (generic.StringList);\n  rpc StateIsLeech(SchedulingState) returns (generic.Bool);\n  rpc UpgradeScheduler(generic.Empty) returns (generic.Empty);\n  rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges);\n  rpc CustomStudyDefaults(CustomStudyDefaultsRequest)\n      returns (CustomStudyDefaultsResponse);\n  rpc RepositionDefaults(generic.Empty) returns (RepositionDefaultsResponse);\n  rpc ComputeFsrsParams(ComputeFsrsParamsRequest)\n      returns (ComputeFsrsParamsResponse);\n  rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest)\n      returns (GetOptimalRetentionParametersResponse);\n  rpc ComputeOptimalRetention(SimulateFsrsReviewRequest)\n      returns (ComputeOptimalRetentionResponse);\n  rpc SimulateFsrsReview(SimulateFsrsReviewRequest)\n      returns (SimulateFsrsReviewResponse);\n  rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest)\n      returns (SimulateFsrsWorkloadResponse);\n  rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse);\n  rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest)\n      returns (EvaluateParamsResponse);\n  rpc ComputeMemoryState(cards.CardId) returns (ComputeMemoryStateResponse);\n  // The number of days the calculated interval was fuzzed by on the previous\n  // review (if any). Utilized by the FSRS add-on.\n  rpc FuzzDelta(FuzzDeltaRequest) returns (FuzzDeltaResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendSchedulerService {\n  rpc ComputeFsrsParamsFromItems(ComputeFsrsParamsFromItemsRequest)\n      returns (ComputeFsrsParamsResponse);\n  // Generates parameters used for FSRS's scheduler benchmarks.\n  rpc FsrsBenchmark(FsrsBenchmarkRequest) returns (FsrsBenchmarkResponse);\n  // Used for exporting revlogs for algorithm research.\n  rpc ExportDataset(ExportDatasetRequest) returns (generic.Empty);\n}\n\nmessage SchedulingState {\n  message New {\n    uint32 position = 1;\n  }\n  message Learning {\n    uint32 remaining_steps = 1;\n    uint32 scheduled_secs = 2;\n    uint32 elapsed_secs = 3;\n    optional cards.FsrsMemoryState memory_state = 6;\n  }\n  message Review {\n    uint32 scheduled_days = 1;\n    uint32 elapsed_days = 2;\n    float ease_factor = 3;\n    uint32 lapses = 4;\n    bool leeched = 5;\n    optional cards.FsrsMemoryState memory_state = 6;\n  }\n  message Relearning {\n    Review review = 1;\n    Learning learning = 2;\n  }\n  message Normal {\n    oneof kind {\n      New new = 1;\n      Learning learning = 2;\n      Review review = 3;\n      Relearning relearning = 4;\n    }\n  }\n  message Preview {\n    uint32 scheduled_secs = 1;\n    bool finished = 2;\n  }\n  message ReschedulingFilter {\n    Normal original_state = 1;\n  }\n  message Filtered {\n    oneof kind {\n      Preview preview = 1;\n      ReschedulingFilter rescheduling = 2;\n    }\n  }\n\n  oneof kind {\n    Normal normal = 1;\n    Filtered filtered = 2;\n  }\n  // The backend does not populate this field in GetQueuedCards; the front-end\n  // is expected to populate it based on the provided Card. If it's not set when\n  // answering a card, the existing custom data will not be updated.\n  optional string custom_data = 3;\n}\n\nmessage QueuedCards {\n  enum Queue {\n    NEW = 0;\n    LEARNING = 1;\n    REVIEW = 2;\n  }\n  message QueuedCard {\n    cards.Card card = 1;\n    Queue queue = 2;\n    SchedulingStates states = 3;\n    SchedulingContext context = 4;\n  }\n\n  repeated QueuedCard cards = 1;\n  uint32 new_count = 2;\n  uint32 learning_count = 3;\n  uint32 review_count = 4;\n}\n\nmessage GetQueuedCardsRequest {\n  uint32 fetch_limit = 1;\n  bool intraday_learning_only = 2;\n}\n\nmessage SchedTimingTodayResponse {\n  uint32 days_elapsed = 1;\n  int64 next_day_at = 2;\n}\n\nmessage StudiedTodayMessageRequest {\n  uint32 cards = 1;\n  double seconds = 2;\n}\n\nmessage UpdateStatsRequest {\n  int64 deck_id = 1;\n  int32 new_delta = 2;\n  int32 review_delta = 4;\n  int32 millisecond_delta = 5;\n}\n\nmessage ExtendLimitsRequest {\n  int64 deck_id = 1;\n  int32 new_delta = 2;\n  int32 review_delta = 3;\n}\n\nmessage CountsForDeckTodayResponse {\n  int32 new = 1;\n  int32 review = 2;\n}\n\nmessage CongratsInfoResponse {\n  uint32 learn_remaining = 1;\n  uint32 secs_until_next_learn = 2;\n  bool review_remaining = 3;\n  bool new_remaining = 4;\n  bool have_sched_buried = 5;\n  bool have_user_buried = 6;\n  bool is_filtered_deck = 7;\n  bool bridge_commands_supported = 8;\n  string deck_description = 9;\n}\n\nmessage UnburyDeckRequest {\n  enum Mode {\n    ALL = 0;\n    SCHED_ONLY = 1;\n    USER_ONLY = 2;\n  }\n  int64 deck_id = 1;\n  Mode mode = 2;\n}\n\nmessage BuryOrSuspendCardsRequest {\n  enum Mode {\n    SUSPEND = 0;\n    BURY_SCHED = 1;\n    BURY_USER = 2;\n  }\n  repeated int64 card_ids = 1;\n  repeated int64 note_ids = 2;\n  Mode mode = 3;\n}\n\nmessage ScheduleCardsAsNewRequest {\n  enum Context {\n    BROWSER = 0;\n    REVIEWER = 1;\n  }\n  repeated int64 card_ids = 1;\n  bool log = 2;\n  bool restore_position = 3;\n  bool reset_counts = 4;\n  optional Context context = 5;\n}\n\nmessage ScheduleCardsAsNewDefaultsRequest {\n  ScheduleCardsAsNewRequest.Context context = 1;\n}\n\nmessage ScheduleCardsAsNewDefaultsResponse {\n  bool restore_position = 1;\n  bool reset_counts = 2;\n}\n\nmessage SetDueDateRequest {\n  repeated int64 card_ids = 1;\n  string days = 2;\n  config.OptionalStringConfigKey config_key = 3;\n}\n\nmessage GradeNowRequest {\n  repeated int64 card_ids = 1;\n  CardAnswer.Rating rating = 2;\n}\n\nmessage SortCardsRequest {\n  repeated int64 card_ids = 1;\n  uint32 starting_from = 2;\n  uint32 step_size = 3;\n  bool randomize = 4;\n  bool shift_existing = 5;\n}\n\nmessage SortDeckRequest {\n  int64 deck_id = 1;\n  bool randomize = 2;\n}\n\nmessage SchedulingStates {\n  SchedulingState current = 1;\n  SchedulingState again = 2;\n  SchedulingState hard = 3;\n  SchedulingState good = 4;\n  SchedulingState easy = 5;\n}\n\nmessage CardAnswer {\n  enum Rating {\n    AGAIN = 0;\n    HARD = 1;\n    GOOD = 2;\n    EASY = 3;\n  }\n\n  int64 card_id = 1;\n  SchedulingState current_state = 2;\n  SchedulingState new_state = 3;\n  Rating rating = 4;\n  int64 answered_at_millis = 5;\n  uint32 milliseconds_taken = 6;\n}\n\nmessage CustomStudyRequest {\n  message Cram {\n    enum CramKind {\n      // due cards in due order\n      CRAM_KIND_DUE = 0;\n      // new cards in added order\n      CRAM_KIND_NEW = 1;\n      // review cards in random order\n      CRAM_KIND_REVIEW = 2;\n      // all cards in random order; no rescheduling\n      CRAM_KIND_ALL = 3;\n    }\n    CramKind kind = 1;\n    // the maximum number of cards\n    uint32 card_limit = 2;\n    // cards must match one of these, if unempty\n    repeated string tags_to_include = 3;\n    // cards must not match any of these\n    repeated string tags_to_exclude = 4;\n  }\n  int64 deck_id = 1;\n  oneof value {\n    // increase new limit by x\n    int32 new_limit_delta = 2;\n    // increase review limit by x\n    int32 review_limit_delta = 3;\n    // repeat cards forgotten in the last x days\n    uint32 forgot_days = 4;\n    // review cards due in the next x days\n    uint32 review_ahead_days = 5;\n    // preview new cards added in the last x days\n    uint32 preview_days = 6;\n    Cram cram = 7;\n  }\n}\n\nmessage SchedulingContext {\n  string deck_name = 1;\n  uint64 seed = 2;\n}\n\nmessage CustomStudyDefaultsRequest {\n  int64 deck_id = 1;\n}\n\nmessage CustomStudyDefaultsResponse {\n  message Tag {\n    string name = 1;\n    bool include = 2;\n    bool exclude = 3;\n  }\n\n  repeated Tag tags = 1;\n  uint32 extend_new = 2;\n  uint32 extend_review = 3;\n  uint32 available_new = 4;\n  uint32 available_review = 5;\n  // in v3, counts for children are provided separately\n  uint32 available_new_in_children = 6;\n  uint32 available_review_in_children = 7;\n}\n\nmessage RepositionDefaultsResponse {\n  bool random = 1;\n  bool shift = 2;\n}\n\nmessage ComputeFsrsParamsRequest {\n  /// The search used to gather cards for training\n  string search = 1;\n  repeated float current_params = 2;\n  int64 ignore_revlogs_before_ms = 3;\n  uint32 num_of_relearning_steps = 4;\n  bool health_check = 5;\n}\n\nmessage ComputeFsrsParamsResponse {\n  repeated float params = 1;\n  uint32 fsrs_items = 2;\n  optional bool health_check_passed = 3;\n}\n\nmessage ComputeFsrsParamsFromItemsRequest {\n  repeated FsrsItem items = 1;\n}\n\nmessage FsrsBenchmarkRequest {\n  repeated FsrsItem train_set = 1;\n}\n\nmessage FsrsBenchmarkResponse {\n  repeated float params = 1;\n}\n\nmessage ExportDatasetRequest {\n  uint32 min_entries = 1;\n  string target_path = 2;\n}\n\nmessage FsrsItem {\n  repeated FsrsReview reviews = 1;\n}\n\nmessage FsrsReview {\n  uint32 rating = 1;\n  uint32 delta_t = 2;\n}\n\nmessage SimulateFsrsReviewRequest {\n  repeated float params = 1;\n  float desired_retention = 2;\n  uint32 deck_size = 3;\n  uint32 days_to_simulate = 4;\n  uint32 new_limit = 5;\n  uint32 review_limit = 6;\n  uint32 max_interval = 7;\n  string search = 8;\n  bool new_cards_ignore_review_limit = 9;\n  repeated float easy_days_percentages = 10;\n  deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;\n  optional uint32 suspend_after_lapse_count = 12;\n  float historical_retention = 13;\n  uint32 learning_step_count = 14;\n  uint32 relearning_step_count = 15;\n}\n\nmessage SimulateFsrsReviewResponse {\n  repeated float accumulated_knowledge_acquisition = 1;\n  repeated uint32 daily_review_count = 2;\n  repeated uint32 daily_new_count = 3;\n  repeated float daily_time_cost = 4;\n}\n\nmessage SimulateFsrsWorkloadResponse {\n  map<uint32, float> cost = 1;\n  map<uint32, float> memorized = 2;\n  map<uint32, uint32> review_count = 3;\n}\n\nmessage ComputeOptimalRetentionResponse {\n  float optimal_retention = 1;\n}\n\nmessage GetOptimalRetentionParametersRequest {\n  string search = 1;\n}\n\nmessage GetOptimalRetentionParametersResponse {\n  uint32 deck_size = 1;\n  uint32 learn_span = 2;\n  float max_cost_perday = 3;\n  float max_ivl = 4;\n  repeated float first_rating_prob = 5;\n  repeated float review_rating_prob = 6;\n  float loss_aversion = 7;\n  uint32 learn_limit = 8;\n  uint32 review_limit = 9;\n  repeated float learning_step_transitions = 10;\n  repeated float relearning_step_transitions = 11;\n  repeated float state_rating_costs = 12;\n  uint32 learning_step_count = 13;\n  uint32 relearning_step_count = 14;\n}\n\nmessage EvaluateParamsRequest {\n  string search = 1;\n  int64 ignore_revlogs_before_ms = 2;\n  uint32 num_of_relearning_steps = 3;\n}\n\nmessage EvaluateParamsLegacyRequest {\n  repeated float params = 1;\n  string search = 2;\n  int64 ignore_revlogs_before_ms = 3;\n}\n\nmessage EvaluateParamsResponse {\n  float log_loss = 1;\n  float rmse_bins = 2;\n}\n\nmessage ComputeMemoryStateResponse {\n  optional cards.FsrsMemoryState state = 1;\n  float desired_retention = 2;\n  float decay = 3;\n}\n\nmessage FuzzDeltaRequest {\n  int64 card_id = 1;\n  uint32 interval = 2;\n}\n\nmessage FuzzDeltaResponse {\n  sint32 delta_days = 1;\n}\n"
  },
  {
    "path": "proto/anki/search.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.search;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\n\nservice SearchService {\n  rpc BuildSearchString(SearchNode) returns (generic.String);\n  rpc SearchCards(SearchRequest) returns (SearchResponse);\n  rpc SearchNotes(SearchRequest) returns (SearchResponse);\n  rpc JoinSearchNodes(JoinSearchNodesRequest) returns (generic.String);\n  rpc ReplaceSearchNode(ReplaceSearchNodeRequest) returns (generic.String);\n  rpc FindAndReplace(FindAndReplaceRequest)\n      returns (collection.OpChangesWithCount);\n  rpc AllBrowserColumns(generic.Empty) returns (BrowserColumns);\n  rpc BrowserRowForId(generic.Int64) returns (BrowserRow);\n  rpc SetActiveBrowserColumns(generic.StringList) returns (generic.Empty);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendSearchService {}\n\nmessage SearchNode {\n  message Dupe {\n    int64 notetype_id = 1;\n    string first_field = 2;\n  }\n  enum Flag {\n    FLAG_NONE = 0;\n    FLAG_ANY = 1;\n    FLAG_RED = 2;\n    FLAG_ORANGE = 3;\n    FLAG_GREEN = 4;\n    FLAG_BLUE = 5;\n    FLAG_PINK = 6;\n    FLAG_TURQUOISE = 7;\n    FLAG_PURPLE = 8;\n  }\n  enum Rating {\n    RATING_ANY = 0;\n    RATING_AGAIN = 1;\n    RATING_HARD = 2;\n    RATING_GOOD = 3;\n    RATING_EASY = 4;\n    RATING_BY_RESCHEDULE = 5;\n  }\n  message Rated {\n    uint32 days = 1;\n    Rating rating = 2;\n  }\n  enum CardState {\n    CARD_STATE_NEW = 0;\n    CARD_STATE_LEARN = 1;\n    CARD_STATE_REVIEW = 2;\n    CARD_STATE_DUE = 3;\n    CARD_STATE_SUSPENDED = 4;\n    CARD_STATE_BURIED = 5;\n  }\n  message IdList {\n    repeated int64 ids = 1;\n  }\n  message Group {\n    enum Joiner {\n      AND = 0;\n      OR = 1;\n    }\n    repeated SearchNode nodes = 1;\n    Joiner joiner = 2;\n  }\n  enum FieldSearchMode {\n    FIELD_SEARCH_MODE_NORMAL = 0;\n    FIELD_SEARCH_MODE_REGEX = 1;\n    FIELD_SEARCH_MODE_NOCOMBINING = 2;\n  }\n  message Field {\n    string field_name = 1;\n    string text = 2;\n    FieldSearchMode mode = 3;\n  }\n\n  oneof filter {\n    Group group = 1;\n    SearchNode negated = 2;\n    string parsable_text = 3;\n    uint32 template = 4;\n    int64 nid = 5;\n    Dupe dupe = 6;\n    string field_name = 7;\n    Rated rated = 8;\n    uint32 added_in_days = 9;\n    int32 due_in_days = 10;\n    Flag flag = 11;\n    CardState card_state = 12;\n    IdList nids = 13;\n    uint32 edited_in_days = 14;\n    string deck = 15;\n    int32 due_on_day = 16;\n    string tag = 17;\n    string note = 18;\n    uint32 introduced_in_days = 19;\n    Field field = 20;\n    string literal_text = 21;\n  }\n}\n\nmessage SearchRequest {\n  string search = 1;\n  SortOrder order = 2;\n}\n\nmessage SearchResponse {\n  repeated int64 ids = 1;\n}\n\nmessage SortOrder {\n  message Builtin {\n    string column = 1;\n    bool reverse = 2;\n  }\n  oneof value {\n    generic.Empty none = 1;\n    string custom = 2;\n    Builtin builtin = 3;\n  }\n}\n\nmessage JoinSearchNodesRequest {\n  SearchNode.Group.Joiner joiner = 1;\n  SearchNode existing_node = 2;\n  SearchNode additional_node = 3;\n}\n\nmessage ReplaceSearchNodeRequest {\n  SearchNode existing_node = 1;\n  SearchNode replacement_node = 2;\n}\n\nmessage FindAndReplaceRequest {\n  repeated int64 nids = 1;\n  string search = 2;\n  string replacement = 3;\n  bool regex = 4;\n  bool match_case = 5;\n  string field_name = 6;\n}\n\nmessage BrowserColumns {\n  enum Sorting {\n    SORTING_NONE = 0;\n    SORTING_ASCENDING = 1;\n    SORTING_DESCENDING = 2;\n  }\n  enum Alignment {\n    ALIGNMENT_START = 0;\n    ALIGNMENT_CENTER = 1;\n  }\n  message Column {\n    string key = 1;\n    string cards_mode_label = 2;\n    string notes_mode_label = 3;\n    // The default sort order\n    Sorting sorting_cards = 4;\n    Sorting sorting_notes = 9;\n    bool uses_cell_font = 5;\n    Alignment alignment = 6;\n    string cards_mode_tooltip = 7;\n    string notes_mode_tooltip = 8;\n  }\n  repeated Column columns = 1;\n}\n\nmessage BrowserRow {\n  message Cell {\n    enum TextElideMode {\n      ElideLeft = 0;\n      ElideRight = 1;\n      ElideMiddle = 2;\n      ElideNone = 3;\n    }\n\n    string text = 1;\n    bool is_rtl = 2;\n    TextElideMode elide_mode = 3;\n  }\n  enum Color {\n    COLOR_DEFAULT = 0;\n    COLOR_MARKED = 1;\n    COLOR_SUSPENDED = 2;\n    COLOR_FLAG_RED = 3;\n    COLOR_FLAG_ORANGE = 4;\n    COLOR_FLAG_GREEN = 5;\n    COLOR_FLAG_BLUE = 6;\n    COLOR_FLAG_PINK = 7;\n    COLOR_FLAG_TURQUOISE = 8;\n    COLOR_FLAG_PURPLE = 9;\n    COLOR_BURIED = 10;\n  }\n  repeated Cell cells = 1;\n  Color color = 2;\n  string font_name = 3;\n  uint32 font_size = 4;\n}\n"
  },
  {
    "path": "proto/anki/stats.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.stats;\n\nimport \"anki/generic.proto\";\nimport \"anki/cards.proto\";\n\nservice StatsService {\n  rpc CardStats(cards.CardId) returns (CardStatsResponse);\n  rpc GetReviewLogs(cards.CardId) returns (ReviewLogs);\n  rpc Graphs(GraphsRequest) returns (GraphsResponse);\n  rpc GetGraphPreferences(generic.Empty) returns (GraphPreferences);\n  rpc SetGraphPreferences(GraphPreferences) returns (generic.Empty);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendStatsService {}\n\nmessage ReviewLogs {\n  repeated CardStatsResponse.StatsRevlogEntry entries = 1;\n}\n\nmessage CardStatsResponse {\n  message StatsRevlogEntry {\n    int64 time = 1;\n    RevlogEntry.ReviewKind review_kind = 2;\n    uint32 button_chosen = 3;\n    // seconds\n    uint32 interval = 4;\n    // per mill\n    uint32 ease = 5;\n    float taken_secs = 6;\n    optional cards.FsrsMemoryState memory_state = 7;\n    // seconds\n    uint32 last_interval = 8;\n  }\n  repeated StatsRevlogEntry revlog = 1;\n  int64 card_id = 2;\n  int64 note_id = 3;\n  string deck = 4;\n  // Unix timestamps\n  int64 added = 5;\n  optional int64 first_review = 6;\n  optional int64 latest_review = 7;\n  optional int64 due_date = 8;\n  optional int32 due_position = 9;\n  // days\n  uint32 interval = 10;\n  // per mill\n  uint32 ease = 11;\n  uint32 reviews = 12;\n  uint32 lapses = 13;\n  float average_secs = 14;\n  float total_secs = 15;\n  string card_type = 16;\n  string notetype = 17;\n  optional cards.FsrsMemoryState memory_state = 18;\n  // not set if due date/state not available\n  optional float fsrs_retrievability = 19;\n  string custom_data = 20;\n  string preset = 21;\n  optional string original_deck = 22;\n  optional float desired_retention = 23;\n  repeated float fsrs_params = 24;\n}\n\nmessage GraphsRequest {\n  string search = 1;\n  uint32 days = 2;\n}\n\nmessage GraphsResponse {\n  message Added {\n    map<int32, uint32> added = 1;\n  }\n  message Intervals {\n    map<uint32, uint32> intervals = 1;\n  }\n  message Eases {\n    map<uint32, uint32> eases = 1;\n    float average = 2;\n  }\n  message Retrievability {\n    map<uint32, uint32> retrievability = 1;\n    float average = 2;\n    float sum_by_card = 3;\n    float sum_by_note = 4;\n  }\n  message FutureDue {\n    map<int32, uint32> future_due = 1;\n    bool have_backlog = 2;\n    uint32 daily_load = 3;\n  }\n  message Today {\n    uint32 answer_count = 1;\n    uint32 answer_millis = 2;\n    uint32 correct_count = 3;\n    uint32 mature_correct = 4;\n    uint32 mature_count = 5;\n    uint32 learn_count = 6;\n    uint32 review_count = 7;\n    uint32 relearn_count = 8;\n    uint32 early_review_count = 9;\n  }\n  // each bucket is a 24 element vec\n  message Hours {\n    message Hour {\n      uint32 total = 1;\n      uint32 correct = 2;\n    }\n    repeated Hour one_month = 1;\n    repeated Hour three_months = 2;\n    repeated Hour one_year = 3;\n    repeated Hour all_time = 4;\n  }\n  message ReviewCountsAndTimes {\n    message Reviews {\n      uint32 learn = 1;\n      uint32 relearn = 2;\n      uint32 young = 3;\n      uint32 mature = 4;\n      uint32 filtered = 5;\n    }\n    map<int32, Reviews> count = 1;\n    map<int32, Reviews> time = 2;\n  }\n  // 4 element vecs for buttons 1-4\n  message Buttons {\n    message ButtonCounts {\n      repeated uint32 learning = 1;\n      repeated uint32 young = 2;\n      repeated uint32 mature = 3;\n    }\n    ButtonCounts one_month = 1;\n    ButtonCounts three_months = 2;\n    ButtonCounts one_year = 3;\n    ButtonCounts all_time = 4;\n  }\n  message CardCounts {\n    message Counts {\n      uint32 newCards = 1;\n      uint32 learn = 2;\n      uint32 relearn = 3;\n      uint32 young = 4;\n      uint32 mature = 5;\n      uint32 suspended = 6;\n      uint32 buried = 7;\n    }\n    // Buried/suspended cards are included in counts; suspended/buried counts\n    // are 0.\n    Counts including_inactive = 1;\n    // Buried/suspended cards are counted separately.\n    Counts excluding_inactive = 2;\n  }\n  message TrueRetentionStats {\n    message TrueRetention {\n      uint32 young_passed = 1;\n      uint32 young_failed = 2;\n      uint32 mature_passed = 3;\n      uint32 mature_failed = 4;\n    }\n\n    TrueRetention today = 1;\n    TrueRetention yesterday = 2;\n    TrueRetention week = 3;\n    TrueRetention month = 4;\n    TrueRetention year = 5;\n    TrueRetention all_time = 6;\n  }\n\n  Buttons buttons = 1;\n  CardCounts card_counts = 2;\n  Hours hours = 3;\n  Today today = 4;\n  Eases eases = 5;\n  Eases difficulty = 11;\n  Intervals intervals = 6;\n  FutureDue future_due = 7;\n  Added added = 8;\n  ReviewCountsAndTimes reviews = 9;\n  uint32 rollover_hour = 10;\n  Retrievability retrievability = 12;\n  bool fsrs = 13;\n  Intervals stability = 14;\n  TrueRetentionStats true_retention = 15;\n}\n\nmessage GraphPreferences {\n  enum Weekday {\n    SUNDAY = 0;\n    MONDAY = 1;\n    FRIDAY = 5;\n    SATURDAY = 6;\n  }\n  Weekday calendar_first_day_of_week = 1;\n  bool card_counts_separate_inactive = 2;\n  bool browser_links_supported = 3;\n  bool future_due_show_backlog = 4;\n}\n\nmessage RevlogEntry {\n  enum ReviewKind {\n    LEARNING = 0;\n    REVIEW = 1;\n    RELEARNING = 2;\n    FILTERED = 3;\n    MANUAL = 4;\n    RESCHEDULED = 5;\n  }\n  int64 id = 1;\n  int64 cid = 2;\n  int32 usn = 3;\n  uint32 button_chosen = 4;\n  int32 interval = 5;\n  int32 last_interval = 6;\n  uint32 ease_factor = 7;\n  uint32 taken_millis = 8;\n  ReviewKind review_kind = 9;\n}\n\nmessage CardEntry {\n  int64 id = 1;\n  int64 note_id = 2;\n  int64 deck_id = 3;\n}\n\nmessage DeckEntry {\n  int64 id = 1;\n  int64 parent_id = 2;\n  int64 preset_id = 3;\n}\n\nmessage Dataset {\n  repeated RevlogEntry revlogs = 1;\n  repeated CardEntry cards = 2;\n  repeated DeckEntry decks = 3;\n  int64 next_day_at = 4;\n}\n"
  },
  {
    "path": "proto/anki/sync.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.sync;\n\nimport \"anki/generic.proto\";\n\n// Syncing methods are only available with a Backend handle.\nservice SyncService {}\n\nservice BackendSyncService {\n  rpc SyncMedia(SyncAuth) returns (generic.Empty);\n  rpc AbortMediaSync(generic.Empty) returns (generic.Empty);\n  // Can be used by the frontend to detect an active sync. If the sync aborted\n  // with an error, the next call to this method will return the error.\n  rpc MediaSyncStatus(generic.Empty) returns (MediaSyncStatusResponse);\n  rpc SyncLogin(SyncLoginRequest) returns (SyncAuth);\n  rpc SyncStatus(SyncAuth) returns (SyncStatusResponse);\n  rpc SyncCollection(SyncCollectionRequest) returns (SyncCollectionResponse);\n  rpc FullUploadOrDownload(FullUploadOrDownloadRequest) returns (generic.Empty);\n  rpc AbortSync(generic.Empty) returns (generic.Empty);\n  rpc SetCustomCertificate(generic.String) returns (generic.Bool);\n}\n\nmessage SyncAuth {\n  string hkey = 1;\n  optional string endpoint = 2;\n  optional uint32 io_timeout_secs = 3;\n}\n\nmessage SyncLoginRequest {\n  string username = 1;\n  string password = 2;\n  optional string endpoint = 3;\n}\n\nmessage SyncStatusResponse {\n  enum Required {\n    NO_CHANGES = 0;\n    NORMAL_SYNC = 1;\n    FULL_SYNC = 2;\n  }\n  Required required = 1;\n  optional string new_endpoint = 4;\n}\n\nmessage SyncCollectionRequest {\n  SyncAuth auth = 1;\n  bool sync_media = 2;\n}\n\nmessage SyncCollectionResponse {\n  enum ChangesRequired {\n    NO_CHANGES = 0;\n    NORMAL_SYNC = 1;\n    FULL_SYNC = 2;\n    // local collection has no cards; upload not an option\n    FULL_DOWNLOAD = 3;\n    // remote collection has no cards; download not an option\n    FULL_UPLOAD = 4;\n  }\n\n  uint32 host_number = 1;\n  string server_message = 2;\n  ChangesRequired required = 3;\n  optional string new_endpoint = 4;\n  int32 server_media_usn = 5;\n}\n\nmessage MediaSyncStatusResponse {\n  bool active = 1;\n  MediaSyncProgress progress = 2;\n}\n\nmessage MediaSyncProgress {\n  string checked = 1;\n  string added = 2;\n  string removed = 3;\n}\n\nmessage FullUploadOrDownloadRequest {\n  SyncAuth auth = 1;\n  bool upload = 2;\n  // if not provided, media syncing will be skipped\n  optional int32 server_usn = 3;\n}\n"
  },
  {
    "path": "proto/anki/tags.proto",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage anki.tags;\n\nimport \"anki/generic.proto\";\nimport \"anki/collection.proto\";\n\nservice TagsService {\n  rpc ClearUnusedTags(generic.Empty) returns (collection.OpChangesWithCount);\n  rpc AllTags(generic.Empty) returns (generic.StringList);\n  rpc RemoveTags(generic.String) returns (collection.OpChangesWithCount);\n  rpc SetTagCollapsed(SetTagCollapsedRequest) returns (collection.OpChanges);\n  rpc TagTree(generic.Empty) returns (TagTreeNode);\n  rpc ReparentTags(ReparentTagsRequest) returns (collection.OpChangesWithCount);\n  rpc RenameTags(RenameTagsRequest) returns (collection.OpChangesWithCount);\n  rpc AddNoteTags(NoteIdsAndTagsRequest)\n      returns (collection.OpChangesWithCount);\n  rpc RemoveNoteTags(NoteIdsAndTagsRequest)\n      returns (collection.OpChangesWithCount);\n  rpc FindAndReplaceTag(FindAndReplaceTagRequest)\n      returns (collection.OpChangesWithCount);\n  rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse);\n}\n\n// Implicitly includes any of the above methods that are not listed in the\n// backend service.\nservice BackendTagsService {}\n\nmessage SetTagCollapsedRequest {\n  string name = 1;\n  bool collapsed = 2;\n}\n\nmessage TagTreeNode {\n  string name = 1;\n  repeated TagTreeNode children = 2;\n  uint32 level = 3;\n  bool collapsed = 4;\n}\n\nmessage ReparentTagsRequest {\n  repeated string tags = 1;\n  string new_parent = 2;\n}\n\nmessage RenameTagsRequest {\n  string current_prefix = 1;\n  string new_prefix = 2;\n}\n\nmessage NoteIdsAndTagsRequest {\n  repeated int64 note_ids = 1;\n  string tags = 2;\n}\n\nmessage FindAndReplaceTagRequest {\n  repeated int64 note_ids = 1;\n  string search = 2;\n  string replacement = 3;\n  bool regex = 4;\n  bool match_case = 5;\n}\n\nmessage CompleteTagRequest {\n  // a partial tag, optionally delimited with ::\n  string input = 1;\n  uint32 match_limit = 2;\n}\n\nmessage CompleteTagResponse {\n  repeated string tags = 1;\n}\n"
  },
  {
    "path": "pylib/.gitignore",
    "content": "*.mo\n*.pyc\n*\\#\n*~\n.*.swp\n.build\n.coverage\n.DS_Store\n.mypy_cache\n.pytype\n__pycache__\nanki.egg-info\nbuild\ndist\n"
  },
  {
    "path": "pylib/README.md",
    "content": "Anki's Python library code is in anki/.\n\nThe Rust/Python extension module is in rsbridge/; it references the library defined in ../rslib.\n"
  },
  {
    "path": "pylib/anki/_backend.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport sys\nimport time\nimport traceback\nfrom collections.abc import Iterable, Sequence\nfrom threading import current_thread, main_thread\nfrom typing import TYPE_CHECKING, Any\nfrom weakref import ref\n\nfrom markdown import markdown\n\nimport anki.buildinfo\nfrom anki import _rsbridge, backend_pb2, i18n_pb2\nfrom anki._backend_generated import RustBackendGenerated\nfrom anki._fluent import GeneratedTranslations\nfrom anki.dbproxy import Row as DBRow\nfrom anki.dbproxy import ValueForDB\nfrom anki.utils import from_json_bytes, to_json_bytes\n\nif TYPE_CHECKING:\n    from anki.collection import FsrsItem\n\nfrom .errors import (\n    BackendError,\n    BackendIOError,\n    CardTypeError,\n    CustomStudyError,\n    DBError,\n    ExistsError,\n    FilteredDeckError,\n    Interrupted,\n    InvalidInput,\n    NetworkError,\n    NotFoundError,\n    SchedulerUpgradeRequired,\n    SearchError,\n    SyncError,\n    SyncErrorKind,\n    TemplateError,\n    UndoEmpty,\n)\n\n# the following comment is required to suppress a warning that only shows up\n# when there are other pylint failures\nif _rsbridge.buildhash() != anki.buildinfo.buildhash:\n    raise Exception(\n        f\"\"\"rsbridge and anki build hashes do not match:\n{_rsbridge.buildhash()} vs {anki.buildinfo.buildhash}\"\"\"\n    )\n\n\nclass RustBackend(RustBackendGenerated):\n    \"\"\"\n    Python bindings for Anki's Rust libraries.\n\n    Please do not access methods on the backend directly - they may be changed\n    or removed at any time. Instead, please use the methods on the collection\n    instead. Eg, don't use col._backend.all_deck_config(), instead use\n    col.decks.all_config()\n\n    If you need to access a backend method that is not currently accessible\n    via the collection, please send through a pull request that adds a\n    public method.\n    \"\"\"\n\n    @staticmethod\n    def initialize_logging(path: str | None = None) -> None:\n        _rsbridge.initialize_logging(path)\n\n    def __init__(\n        self,\n        langs: list[str] | None = None,\n        server: bool = False,\n    ) -> None:\n        # pick up global defaults if not provided\n        import anki.lang\n\n        if langs is None:\n            langs = [anki.lang.current_lang]\n\n        init_msg = backend_pb2.BackendInit(\n            preferred_langs=langs,\n            server=server,\n        )\n        self._backend = _rsbridge.open_backend(init_msg.SerializeToString())\n\n    @staticmethod\n    def syncserver() -> None:\n        _rsbridge.syncserver()\n\n    def db_query(\n        self, sql: str, args: Sequence[ValueForDB], first_row_only: bool\n    ) -> list[DBRow]:\n        return self._db_command(\n            dict(kind=\"query\", sql=sql, args=args, first_row_only=first_row_only)\n        )\n\n    def db_execute_many(self, sql: str, args: list[list[ValueForDB]]) -> list[DBRow]:\n        return self._db_command(dict(kind=\"executemany\", sql=sql, args=args))\n\n    def db_begin(self) -> None:\n        return self._db_command(dict(kind=\"begin\"))\n\n    def db_commit(self) -> None:\n        return self._db_command(dict(kind=\"commit\"))\n\n    def db_rollback(self) -> None:\n        return self._db_command(dict(kind=\"rollback\"))\n\n    def _db_command(self, input: dict[str, Any]) -> Any:\n        bytes_input = to_json_bytes(input)\n        try:\n            return from_json_bytes(self._backend.db_command(bytes_input))\n        except Exception as error:\n            err_bytes = bytes(error.args[0])\n        err = backend_pb2.BackendError()\n        err.ParseFromString(err_bytes)\n        raise backend_exception_to_pylib(err)\n\n    def translate(\n        self, module_index: int, message_index: int, **kwargs: str | int | float\n    ) -> str:\n        args = {\n            k: (\n                i18n_pb2.TranslateArgValue(str=v)\n                if isinstance(v, str)\n                else i18n_pb2.TranslateArgValue(number=v)\n            )\n            for k, v in kwargs.items()\n        }\n\n        return self.translate_string(\n            module_index=module_index, message_index=message_index, args=args\n        )\n\n    def format_time_span(\n        self,\n        seconds: Any,\n        context: Any = 2,\n    ) -> str:\n        traceback.print_stack(file=sys.stdout)\n        print(\n            \"please use col.format_timespan() instead of col.backend.format_time_span()\"\n        )\n        return self.format_timespan(seconds=seconds, context=context)\n\n    def compute_params_from_items(self, items: Iterable[FsrsItem]) -> Sequence[float]:\n        return self.compute_fsrs_params_from_items(items).params\n\n    def benchmark(self, train_set: Iterable[FsrsItem]) -> Sequence[float]:\n        return self.fsrs_benchmark(train_set=train_set)\n\n    def _run_command(self, service: int, method: int, input: bytes) -> bytes:\n        start = time.time()\n        try:\n            return self._backend.command(service, method, input)\n        except Exception as error:\n            error_bytes = bytes(error.args[0])\n        finally:\n            elapsed = time.time() - start\n            if current_thread() is main_thread() and elapsed > 0.2:\n                print(f\"blocked main thread for {int(elapsed * 1000)}ms:\")\n                print(\"\".join(traceback.format_stack()))\n\n        err = backend_pb2.BackendError()\n        err.ParseFromString(error_bytes)\n        raise backend_exception_to_pylib(err)\n\n\nclass Translations(GeneratedTranslations):\n    def __init__(self, backend: ref[RustBackend] | None):\n        self.backend = backend\n\n    def __call__(self, key: tuple[int, int], **kwargs: Any) -> str:\n        \"Mimic the old col.tr / TR interface\"\n        if \"pytest\" not in sys.modules:\n            traceback.print_stack(file=sys.stdout)\n            print(\"please use tr.message_name() instead of tr(TR.MESSAGE_NAME)\")\n\n        (module, message) = key\n        return self.backend().translate(\n            module_index=module, message_index=message, **kwargs\n        )\n\n    def _translate(\n        self, module: int, message: int, args: dict[str, str | int | float]\n    ) -> str:\n        return self.backend().translate(\n            module_index=module, message_index=message, **args\n        )\n\n\ndef backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception:\n    kind = backend_pb2.BackendError\n    val = err.kind\n    help_page = err.help_page if err.HasField(\"help_page\") else None\n    context = err.context if err.context else None\n    backtrace = err.backtrace if err.backtrace else None\n\n    if val == kind.INTERRUPTED:\n        return Interrupted(err.message, help_page, context, backtrace)\n\n    elif val == kind.NETWORK_ERROR:\n        return NetworkError(err.message, help_page, context, backtrace)\n\n    elif val == kind.SYNC_AUTH_ERROR:\n        return SyncError(err.message, help_page, context, backtrace, SyncErrorKind.AUTH)\n\n    elif val == kind.SYNC_OTHER_ERROR:\n        return SyncError(\n            err.message, help_page, context, backtrace, SyncErrorKind.OTHER\n        )\n\n    elif val == kind.IO_ERROR:\n        return BackendIOError(err.message, help_page, context, backtrace)\n\n    elif val == kind.DB_ERROR:\n        return DBError(err.message, help_page, context, backtrace)\n\n    elif val == kind.CARD_TYPE_ERROR:\n        return CardTypeError(err.message, help_page, context, backtrace)\n\n    elif val == kind.TEMPLATE_PARSE:\n        return TemplateError(err.message, help_page, context, backtrace)\n\n    elif val == kind.INVALID_INPUT:\n        return InvalidInput(err.message, help_page, context, backtrace)\n\n    elif val == kind.JSON_ERROR:\n        return BackendError(err.message, help_page, context, backtrace)\n\n    elif val == kind.NOT_FOUND_ERROR:\n        return NotFoundError(err.message, help_page, context, backtrace)\n\n    elif val == kind.EXISTS:\n        return ExistsError(err.message, help_page, context, backtrace)\n\n    elif val == kind.FILTERED_DECK_ERROR:\n        return FilteredDeckError(err.message, help_page, context, backtrace)\n\n    elif val == kind.PROTO_ERROR:\n        return BackendError(err.message, help_page, context, backtrace)\n\n    elif val == kind.SEARCH_ERROR:\n        return SearchError(err.message, help_page, context, backtrace)\n\n    elif val == kind.UNDO_EMPTY:\n        return UndoEmpty(err.message, help_page, context, backtrace)\n\n    elif val == kind.CUSTOM_STUDY_ERROR:\n        return CustomStudyError(err.message, help_page, context, backtrace)\n\n    elif val == kind.SCHEDULER_UPGRADE_REQUIRED:\n        return SchedulerUpgradeRequired(err.message, help_page, context, backtrace)\n\n    else:\n        # sadly we can't do exhaustiveness checking on protobuf enums\n        # assert_exhaustive(val)\n        return BackendError(err.message, help_page, context, backtrace)\n"
  },
  {
    "path": "pylib/anki/_legacy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport functools\nimport os\nimport pathlib\nimport sys\nimport traceback\nfrom collections.abc import Callable\nfrom typing import TYPE_CHECKING, Any, Union\n\nfrom anki._vendor import stringcase  # type: ignore\n\nsys.modules[\"stringcase\"] = stringcase\n\nVariableTarget = tuple[Any, str]\nDeprecatedAliasTarget = Union[Callable, VariableTarget]\n\n\ndef _target_to_string(target: DeprecatedAliasTarget | None) -> str:\n    if target is None:\n        return \"\"\n    if name := getattr(target, \"__name__\", None):\n        return name\n    return target[1]  # type: ignore\n\n\ndef partial_path(full_path: str, components: int) -> str:\n    path = pathlib.Path(full_path)\n    return os.path.join(*path.parts[-components:])\n\n\ndef print_deprecation_warning(msg: str, frame: int = 1) -> None:\n    # skip one frame to get to caller\n    # then by default, skip one more frame as caller themself usually wants to\n    # print their own caller\n    path, linenum, _, _ = traceback.extract_stack(limit=frame + 2)[0]\n    path = partial_path(path, components=3)\n    print(f\"{path}:{linenum}:{msg}\")\n\n\ndef _print_warning(old: str, doc: str, frame: int = 1) -> None:\n    return print_deprecation_warning(f\"{old} is deprecated: {doc}\", frame=frame + 1)\n\n\ndef _print_replacement_warning(old: str, new: str, frame: int = 1) -> None:\n    doc = f\"please use '{new}'\" if new else \"please implement your own\"\n    _print_warning(old, doc, frame=frame + 1)\n\n\ndef _get_remapped_and_replacement(\n    mixin: DeprecatedNamesMixin | DeprecatedNamesMixinForModule, name: str\n) -> tuple[str, str | None]:\n    if some_tuple := mixin._deprecated_attributes.get(name):\n        return some_tuple\n\n    remapped = mixin._deprecated_aliases.get(name) or stringcase.snakecase(name)\n    if remapped == name:\n        raise AttributeError\n    return (remapped, remapped)\n\n\nclass DeprecatedNamesMixin:\n    \"Expose instance methods/vars as camelCase for legacy callers.\"\n\n    # deprecated name -> new name\n    _deprecated_aliases: dict[str, str] = {}\n    # deprecated name -> [new internal name, new name shown to user]\n    _deprecated_attributes: dict[str, tuple[str, str | None]] = {}\n\n    # TYPE_CHECKING check is required for https://github.com/python/mypy/issues/13319\n    if not TYPE_CHECKING:\n\n        def __getattr__(self, name: str) -> Any:\n            try:\n                remapped, replacement = _get_remapped_and_replacement(self, name)\n                out = getattr(self, remapped)\n            except AttributeError:\n                raise AttributeError(\n                    f\"'{self.__class__.__name__}' object has no attribute '{name}'\"\n                ) from None\n\n            _print_replacement_warning(name, replacement)\n            return out\n\n    @classmethod\n    def register_deprecated_aliases(cls, **kwargs: DeprecatedAliasTarget) -> None:\n        \"\"\"Manually add aliases that are not a simple transform.\n\n        Either pass in a method, or a tuple of (variable, \"variable\"). The\n        latter is required because we want to ensure the provided arguments\n        are valid symbols, and we can't get a variable's name easily.\n        \"\"\"\n        cls._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()}\n\n    @classmethod\n    def register_deprecated_attributes(\n        cls,\n        **kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None],\n    ) -> None:\n        \"\"\"Manually add deprecated attributes without exact substitutes.\n\n        Pass a tuple of (alias, replacement), where alias is the attribute's new\n        name (by convention: snakecase, prepended with '_legacy_'), and\n        replacement is any callable to be used instead in new code or None.\n        Also note the docstring of `register_deprecated_aliases`.\n\n        E.g. given `def oldFunc(args): return new_func(additionalLogic(args))`,\n        rename `oldFunc` to `_legacy_old_func` and call\n        `register_deprecated_attributes(oldFunc=(_legacy_old_func, new_func))`.\n        \"\"\"\n        cls._deprecated_attributes = {\n            k: (_target_to_string(v[0]), _target_to_string(v[1]))\n            for k, v in kwargs.items()\n        }\n\n\nclass DeprecatedNamesMixinForModule:\n    \"\"\"Provides the functionality of DeprecatedNamesMixin for modules.\n\n    It can be invoked like this:\n    ```\n        _deprecated_names = DeprecatedNamesMixinForModule(globals())\n        _deprecated_names.register_deprecated_aliases(...\n        _deprecated_names.register_deprecated_attributes(...\n\n        if not TYPE_CHECKING:\n            def __getattr__(name: str) -> Any:\n                return _deprecated_names.__getattr__(name)\n    ```\n    See DeprecatedNamesMixin for more documentation.\n    \"\"\"\n\n    def __init__(self, module_globals: dict[str, Any]) -> None:\n        self.module_globals = module_globals\n        self._deprecated_aliases: dict[str, str] = {}\n        self._deprecated_attributes: dict[str, tuple[str, str | None]] = {}\n\n    if not TYPE_CHECKING:\n\n        def __getattr__(self, name: str) -> Any:\n            try:\n                remapped, replacement = _get_remapped_and_replacement(self, name)\n                out = self.module_globals[remapped]\n            except (AttributeError, KeyError):\n                raise AttributeError(\n                    f\"Module '{self.module_globals['__name__']}' has no attribute '{name}'\"\n                ) from None\n\n            # skip an additional frame as we are called from the module `__getattr__`\n            _print_replacement_warning(name, replacement, frame=2)\n            return out\n\n    def register_deprecated_aliases(self, **kwargs: DeprecatedAliasTarget) -> None:\n        self._deprecated_aliases = {k: _target_to_string(v) for k, v in kwargs.items()}\n\n    def register_deprecated_attributes(\n        self,\n        **kwargs: tuple[DeprecatedAliasTarget, DeprecatedAliasTarget | None],\n    ) -> None:\n        self._deprecated_attributes = {\n            k: (_target_to_string(v[0]), _target_to_string(v[1]))\n            for k, v in kwargs.items()\n        }\n\n\ndef deprecated(replaced_by: Callable | None = None, info: str = \"\") -> Callable:\n    \"\"\"Print a deprecation warning, telling users to use `replaced_by`, or show `doc`.\"\"\"\n\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def decorated_func(*args: Any, **kwargs: Any) -> Any:\n            if info:\n                _print_warning(f\"{func.__name__}()\", info)\n            else:\n                _print_replacement_warning(func.__name__, replaced_by.__name__)\n\n            return func(*args, **kwargs)\n\n        return decorated_func\n\n    return decorator\n\n\ndef deprecated_keywords(**replaced_keys: str) -> Callable:\n    \"\"\"Pass `oldKey=\"new_key\"` to map the former to the latter, if passed to the\n    decorated function as a key word, and print a deprecation warning.\n    \"\"\"\n\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def decorated_func(*args: Any, **kwargs: Any) -> Any:\n            updated_kwargs = {}\n            for key, val in kwargs.items():\n                if replacement := replaced_keys.get(key):\n                    _print_replacement_warning(key, replacement)\n                updated_kwargs[replacement or key] = val\n\n            return func(*args, **updated_kwargs)\n\n        return decorated_func\n\n    return decorator\n"
  },
  {
    "path": "pylib/anki/_rsbridge.pyi",
    "content": "from typing import Union\n\nclass Backend:\n    @classmethod\n    def command(cls, service: int, method: int, data: bytes) -> bytes: ...\n    def db_command(self, data: bytes) -> bytes: ...\n\ndef buildhash() -> str: ...\ndef open_backend(data: bytes) -> Backend: ...\ndef initialize_logging(log_file: Union[str, None]) -> Backend: ...\ndef syncserver() -> None: ...\n"
  },
  {
    "path": "pylib/anki/_vendor/stringcase.py",
    "content": "# stringcase 1.2.0 with python warning fix applied\n# MIT: https://github.com/okunishinishi/python-stringcase\n\n\n\"\"\"\nString convert functions\n\"\"\"\n\nimport re\n\n\ndef camelcase(string):\n    \"\"\"Convert string into camel case.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Camel case string.\n\n    \"\"\"\n\n    string = re.sub(r\"\\w[\\s\\W]+\\w\", \"\", str(string))\n    if not string:\n        return string\n    return lowercase(string[0]) + re.sub(\n        r\"[\\-_\\.\\s]([a-z])\", lambda matched: uppercase(matched.group(1)), string[1:]\n    )\n\n\ndef capitalcase(string):\n    \"\"\"Convert string into capital case.\n    First letters will be uppercase.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Capital case string.\n\n    \"\"\"\n\n    string = str(string)\n    if not string:\n        return string\n    return uppercase(string[0]) + string[1:]\n\n\ndef constcase(string):\n    \"\"\"Convert string into upper snake case.\n    Join punctuation with underscore and convert letters into uppercase.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Const cased string.\n\n    \"\"\"\n\n    return uppercase(snakecase(string))\n\n\ndef lowercase(string):\n    \"\"\"Convert string into lower case.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Lowercase case string.\n\n    \"\"\"\n\n    return str(string).lower()\n\n\ndef pascalcase(string):\n    \"\"\"Convert string into pascal case.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Pascal case string.\n\n    \"\"\"\n\n    return capitalcase(camelcase(string))\n\n\ndef pathcase(string):\n    \"\"\"Convert string into path case.\n    Join punctuation with slash.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Path cased string.\n\n    \"\"\"\n    string = snakecase(string)\n    if not string:\n        return string\n    return re.sub(r\"_\", \"/\", string)\n\n\ndef backslashcase(string):\n    \"\"\"Convert string into spinal case.\n    Join punctuation with backslash.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Spinal cased string.\n\n    \"\"\"\n    str1 = re.sub(r\"_\", r\"\\\\\", snakecase(string))\n\n    return str1\n    # return re.sub(r\"\\\\n\", \"\", str1))  # TODO: make regex for \\t ...\n\n\ndef sentencecase(string):\n    \"\"\"Convert string into sentence case.\n    First letter capped and each punctuations are joined with space.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Sentence cased string.\n\n    \"\"\"\n    joiner = \" \"\n    string = re.sub(r\"[\\-_\\.\\s]\", joiner, str(string))\n    if not string:\n        return string\n    return capitalcase(\n        trimcase(\n            re.sub(\n                r\"[A-Z]\", lambda matched: joiner + lowercase(matched.group(0)), string\n            )\n        )\n    )\n\n\ndef snakecase(string):\n    \"\"\"Convert string into snake case.\n    Join punctuation with underscore\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Snake cased string.\n\n    \"\"\"\n\n    string = re.sub(r\"[\\-\\.\\s]\", \"_\", str(string))\n    if not string:\n        return string\n    return lowercase(string[0]) + re.sub(\n        r\"[A-Z]\", lambda matched: \"_\" + lowercase(matched.group(0)), string[1:]\n    )\n\n\ndef spinalcase(string):\n    \"\"\"Convert string into spinal case.\n    Join punctuation with hyphen.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Spinal cased string.\n\n    \"\"\"\n\n    return re.sub(r\"_\", \"-\", snakecase(string))\n\n\ndef dotcase(string):\n    \"\"\"Convert string into dot case.\n    Join punctuation with dot.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Dot cased string.\n\n    \"\"\"\n\n    return re.sub(r\"_\", \".\", snakecase(string))\n\n\ndef titlecase(string):\n    \"\"\"Convert string into sentence case.\n    First letter capped while each punctuations is capitalsed\n    and joined with space.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Title cased string.\n\n    \"\"\"\n\n    return \" \".join([capitalcase(word) for word in snakecase(string).split(\"_\")])\n\n\ndef trimcase(string):\n    \"\"\"Convert string into trimmed string.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Trimmed case string\n    \"\"\"\n\n    return str(string).strip()\n\n\ndef uppercase(string):\n    \"\"\"Convert string into upper case.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: Uppercase case string.\n\n    \"\"\"\n\n    return str(string).upper()\n\n\ndef alphanumcase(string):\n    \"\"\"Cuts all non-alphanumeric symbols,\n    i.e. cuts all expect except 0-9, a-z and A-Z.\n\n    Args:\n        string: String to convert.\n\n    Returns:\n        string: String with cut non-alphanumeric symbols.\n\n    \"\"\"\n    # return filter(str.isalnum, str(string))\n    return re.sub(r\"\\W+\", \"\", string)\n"
  },
  {
    "path": "pylib/anki/browser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nclass BrowserConfig:\n    ACTIVE_CARD_COLUMNS_KEY = \"activeCols\"\n    ACTIVE_NOTE_COLUMNS_KEY = \"activeNoteCols\"\n    CARDS_SORT_COLUMN_KEY = \"sortType\"\n    NOTES_SORT_COLUMN_KEY = \"noteSortType\"\n    CARDS_SORT_BACKWARDS_KEY = \"sortBackwards\"\n    NOTES_SORT_BACKWARDS_KEY = \"browserNoteSortBackwards\"\n\n    @staticmethod\n    def active_columns_key(is_notes_mode: bool) -> str:\n        if is_notes_mode:\n            return BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY\n        return BrowserConfig.ACTIVE_CARD_COLUMNS_KEY\n\n    @staticmethod\n    def sort_column_key(is_notes_mode: bool) -> str:\n        if is_notes_mode:\n            return BrowserConfig.NOTES_SORT_COLUMN_KEY\n        return BrowserConfig.CARDS_SORT_COLUMN_KEY\n\n    @staticmethod\n    def sort_backwards_key(is_notes_mode: bool) -> str:\n        if is_notes_mode:\n            return BrowserConfig.NOTES_SORT_BACKWARDS_KEY\n        return BrowserConfig.CARDS_SORT_BACKWARDS_KEY\n\n\nclass BrowserDefaults:\n    CARD_COLUMNS = [\"noteFld\", \"template\", \"cardDue\", \"deck\"]\n    NOTE_COLUMNS = [\"noteFld\", \"note\", \"template\", \"noteTags\"]\n"
  },
  {
    "path": "pylib/anki/cards.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport pprint\nimport time\nfrom typing import NewType\n\nimport anki\nimport anki.collection\nimport anki.decks\nimport anki.notes\nimport anki.template\nfrom anki import cards_pb2, hooks\nfrom anki._legacy import DeprecatedNamesMixin, deprecated\nfrom anki.consts import *\nfrom anki.models import NotetypeDict, TemplateDict\nfrom anki.notes import Note\nfrom anki.sound import AVTag\n\n# Cards\n##########################################################################\n\n# Type: 0=new, 1=learning, 2=due\n# Queue: same as above, and:\n#        -1=suspended, -2=user buried, -3=sched buried\n# Due is used differently for different queues.\n# - new queue: position\n# - rev queue: integer day\n# - lrn queue: integer timestamp\n\n# types\nCardId = NewType(\"CardId\", int)\nBackendCard = cards_pb2.Card\nFSRSMemoryState = cards_pb2.FsrsMemoryState\n\n\nclass Card(DeprecatedNamesMixin):\n    _note: Note | None\n    lastIvl: int\n    ord: int\n    nid: anki.notes.NoteId\n    id: CardId\n    did: anki.decks.DeckId\n    odid: anki.decks.DeckId\n    queue: CardQueue\n    type: CardType\n    memory_state: FSRSMemoryState | None\n    desired_retention: float | None\n    decay: float | None\n    last_review_time: int | None\n\n    def __init__(\n        self,\n        col: anki.collection.Collection,\n        id: CardId | None = None,\n        backend_card: BackendCard | None = None,\n    ) -> None:\n        self.col = col.weakref()\n        self.timer_started: float | None = None\n        self._render_output: anki.template.TemplateRenderOutput | None = None\n        if id:\n            # existing card\n            self.id = id\n            self.load()\n        elif backend_card:\n            self._load_from_backend_card(backend_card)\n        else:\n            # new card with defaults\n            self._load_from_backend_card(cards_pb2.Card())\n\n    def load(self) -> None:\n        card = self.col._backend.get_card(self.id)\n        assert card\n        self._load_from_backend_card(card)\n\n    def _load_from_backend_card(self, card: cards_pb2.Card) -> None:\n        self._render_output = None\n        self._note = None\n        self.id = CardId(card.id)\n        self.nid = anki.notes.NoteId(card.note_id)\n        self.did = anki.decks.DeckId(card.deck_id)\n        self.ord = card.template_idx\n        self.mod = card.mtime_secs\n        self.usn = card.usn\n        self.type = CardType(card.ctype)\n        self.queue = CardQueue(card.queue)\n        self.due = card.due\n        self.ivl = card.interval\n        self.factor = card.ease_factor\n        self.reps = card.reps\n        self.lapses = card.lapses\n        self.left = card.remaining_steps\n        self.odue = card.original_due\n        self.odid = anki.decks.DeckId(card.original_deck_id)\n        self.flags = card.flags\n        self.original_position = (\n            card.original_position if card.HasField(\"original_position\") else None\n        )\n        self.custom_data = card.custom_data\n        self.memory_state = card.memory_state if card.HasField(\"memory_state\") else None\n        self.desired_retention = (\n            card.desired_retention if card.HasField(\"desired_retention\") else None\n        )\n        self.decay = card.decay if card.HasField(\"decay\") else None\n        self.last_review_time = (\n            card.last_review_time_secs\n            if card.HasField(\"last_review_time_secs\")\n            else None\n        )\n\n    def _to_backend_card(self) -> cards_pb2.Card:\n        # mtime & usn are set by backend\n        return cards_pb2.Card(\n            id=self.id,\n            note_id=self.nid,\n            deck_id=self.did,\n            template_idx=self.ord,\n            ctype=self.type,\n            queue=self.queue,\n            due=self.due,\n            interval=self.ivl,\n            ease_factor=self.factor,\n            reps=self.reps,\n            lapses=self.lapses,\n            remaining_steps=self.left,\n            original_due=self.odue,\n            original_deck_id=self.odid,\n            flags=self.flags,\n            original_position=self.original_position,\n            custom_data=self.custom_data,\n            memory_state=self.memory_state,\n            desired_retention=self.desired_retention,\n            decay=self.decay,\n            last_review_time_secs=self.last_review_time,\n        )\n\n    @deprecated(info=\"please use col.update_card()\")\n    def flush(self) -> None:\n        hooks.card_will_flush(self)\n        if self.id != 0:\n            self.col._backend.update_cards(\n                cards=[self._to_backend_card()], skip_undo_entry=True\n            )\n        else:\n            raise Exception(\"card.flush() expects an existing card\")\n\n    def question(self, reload: bool = False, browser: bool = False) -> str:\n        return self.render_output(reload, browser).question_and_style()\n\n    def answer(self) -> str:\n        return self.render_output().answer_and_style()\n\n    def question_av_tags(self) -> list[AVTag]:\n        return self.render_output().question_av_tags\n\n    def answer_av_tags(self) -> list[AVTag]:\n        return self.render_output().answer_av_tags\n\n    def render_output(\n        self, reload: bool = False, browser: bool = False\n    ) -> anki.template.TemplateRenderOutput:\n        if not self._render_output or reload:\n            self._render_output = (\n                anki.template.TemplateRenderContext.from_existing_card(\n                    self, browser\n                ).render()\n            )\n        return self._render_output\n\n    def set_render_output(self, output: anki.template.TemplateRenderOutput) -> None:\n        self._render_output = output\n\n    def note(self, reload: bool = False) -> Note:\n        if not self._note or reload:\n            self._note = self.col.get_note(self.nid)\n        return self._note\n\n    def note_type(self) -> NotetypeDict:\n        return self.col.models.get(self.note().mid)\n\n    def template(self) -> TemplateDict:\n        notetype = self.note_type()\n        templates = notetype[\"tmpls\"]\n        if notetype[\"type\"] == MODEL_STD:\n            return templates[self.ord]\n        else:\n            return templates[0]\n\n    def start_timer(self) -> None:\n        self.timer_started = time.time()\n\n    def current_deck_id(self) -> anki.decks.DeckId:\n        return anki.decks.DeckId(self.odid or self.did)\n\n    def time_limit(self) -> int:\n        \"Time limit for answering in milliseconds.\"\n        conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id())\n        return conf[\"maxTaken\"] * 1000\n\n    def should_show_timer(self) -> bool:\n        conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id())\n        return conf[\"timer\"]\n\n    def replay_question_audio_on_answer_side(self) -> bool:\n        conf = self.col.decks.config_dict_for_deck_id(self.current_deck_id())\n        return conf.get(\"replayq\", True)\n\n    def autoplay(self) -> bool:\n        return self.col.decks.config_dict_for_deck_id(self.current_deck_id())[\n            \"autoplay\"\n        ]\n\n    def time_taken(self, capped: bool = True) -> int:\n        \"\"\"Time taken since card timer started, in integer MS.\n        If `capped` is true, returned time is limited to deck preset setting.\"\"\"\n        total = int((time.time() - self.timer_started) * 1000)\n        if capped:\n            total = min(total, self.time_limit())\n        return total\n\n    def description(self) -> str:\n        dict_copy = dict(self.__dict__)\n        # remove non-useful elements\n        del dict_copy[\"_note\"]\n        del dict_copy[\"_render_output\"]\n        del dict_copy[\"col\"]\n        del dict_copy[\"timer_started\"]\n        return f\"{super().__repr__()} {pprint.pformat(dict_copy, width=300)}\"\n\n    def user_flag(self) -> int:\n        return self.flags & 0b111\n\n    def set_user_flag(self, flag: int) -> None:\n        print(\"use col.set_user_flag_for_cards() instead\")\n        if not 0 <= flag <= 7:\n            raise Exception(\"invalid flag\")\n        self.flags = (self.flags & ~0b111) | flag\n\n    @deprecated(info=\"use card.render_output() directly\")\n    def css(self) -> str:\n        return f\"<style>{self.render_output().css}</style>\"\n\n    @deprecated(info=\"handled by template rendering\")\n    def is_empty(self) -> bool:\n        return False\n\n\nCard.register_deprecated_aliases(\n    flushSched=Card.flush,\n    q=Card.question,\n    a=Card.answer,\n    model=Card.note_type,\n)\n"
  },
  {
    "path": "pylib/anki/collection.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Generator, Iterable, Sequence\nfrom typing import Any, Literal, Union, cast\n\nfrom anki import (\n    ankiweb_pb2,\n    card_rendering_pb2,\n    collection_pb2,\n    config_pb2,\n    generic_pb2,\n    image_occlusion_pb2,\n    import_export_pb2,\n    links_pb2,\n    notes_pb2,\n    scheduler_pb2,\n    search_pb2,\n    stats_pb2,\n    sync_pb2,\n)\nfrom anki._legacy import DeprecatedNamesMixin, deprecated\nfrom anki.sync_pb2 import SyncLoginRequest\n\n# protobuf we publicly export - listed first to avoid circular imports\nHelpPage = links_pb2.HelpPageLinkRequest.HelpPage\nSearchNode = search_pb2.SearchNode\nProgress = collection_pb2.Progress\nEmptyCardsReport = card_rendering_pb2.EmptyCardsReport\nGraphPreferences = stats_pb2.GraphPreferences\nCardStats = stats_pb2.CardStatsResponse\nPreferences = config_pb2.Preferences\nUndoStatus = collection_pb2.UndoStatus\nOpChanges = collection_pb2.OpChanges\nOpChangesOnly = collection_pb2.OpChangesOnly\nOpChangesWithCount = collection_pb2.OpChangesWithCount\nOpChangesWithId = collection_pb2.OpChangesWithId\nOpChangesAfterUndo = collection_pb2.OpChangesAfterUndo\nBrowserRow = search_pb2.BrowserRow\nBrowserColumns = search_pb2.BrowserColumns\nStripHtmlMode = card_rendering_pb2.StripHtmlRequest\nImportLogWithChanges = import_export_pb2.ImportResponse\nImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest\nImportAnkiPackageOptions = import_export_pb2.ImportAnkiPackageOptions\nExportAnkiPackageOptions = import_export_pb2.ExportAnkiPackageOptions\nImportCsvRequest = import_export_pb2.ImportCsvRequest\nCsvMetadata = import_export_pb2.CsvMetadata\nDupeResolution = CsvMetadata.DupeResolution\nDelimiter = import_export_pb2.CsvMetadata.Delimiter\nTtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice\nGetImageForOcclusionResponse = image_occlusion_pb2.GetImageForOcclusionResponse\nAddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest\nGetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse\nAddonInfo = ankiweb_pb2.AddonInfo\nCheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse\nMediaSyncStatus = sync_pb2.MediaSyncStatusResponse\nFsrsItem = scheduler_pb2.FsrsItem\nFsrsReview = scheduler_pb2.FsrsReview\n\nimport os\nimport sys\nimport time\nimport traceback\nimport weakref\nfrom dataclasses import dataclass\n\nimport anki.latex\nfrom anki import hooks\nfrom anki._backend import RustBackend, Translations\nfrom anki.browser import BrowserConfig, BrowserDefaults\nfrom anki.cards import Card, CardId\nfrom anki.config import Config, ConfigManager\nfrom anki.consts import *\nfrom anki.dbproxy import DBProxy\nfrom anki.decks import DeckId, DeckManager\nfrom anki.errors import AbortSchemaModification, DBError\nfrom anki.lang import FormatTimeSpan\nfrom anki.media import MediaManager, media_paths_from_col_path\nfrom anki.models import ModelManager, NotetypeDict, NotetypeId\nfrom anki.notes import Note, NoteId\nfrom anki.scheduler.dummy import DummyScheduler\nfrom anki.scheduler.v3 import Scheduler as V3Scheduler\nfrom anki.sync import SyncAuth, SyncOutput, SyncStatus\nfrom anki.tags import TagManager\nfrom anki.utils import (\n    from_json_bytes,\n    ids2str,\n    int_time,\n    split_fields,\n    strip_html_media,\n    to_json_bytes,\n)\n\nanki.latex.setup_hook()\n\n\nSearchJoiner = Literal[\"AND\", \"OR\"]\n\n\n@dataclass\nclass DeckIdLimit:\n    deck_id: DeckId\n\n\n@dataclass\nclass NoteIdsLimit:\n    note_ids: Sequence[NoteId]\n\n\n@dataclass\nclass CardIdsLimit:\n    card_ids: Sequence[CardId]\n\n\nExportLimit = Union[DeckIdLimit, NoteIdsLimit, CardIdsLimit, None]\n\n\n@dataclass\nclass ComputedMemoryState:\n    desired_retention: float\n    stability: float | None = None\n    difficulty: float | None = None\n    decay: float | None = None\n\n\n@dataclass\nclass AddNoteRequest:\n    note: Note\n    deck_id: DeckId\n\n\nclass Collection(DeprecatedNamesMixin):\n    sched: V3Scheduler | DummyScheduler\n\n    @staticmethod\n    def initialize_backend_logging() -> None:\n        \"\"\"Enable terminal logging. Must be called only once.\"\"\"\n        RustBackend.initialize_logging(None)\n\n    def __init__(\n        self,\n        path: str,\n        backend: RustBackend | None = None,\n        server: bool = False,\n    ) -> None:\n        self._backend = backend or RustBackend(server=server)\n        self.db: DBProxy | None = None\n        self.server = server\n        self.path = os.path.abspath(path)\n        self.reopen()\n\n        self.tr = Translations(weakref.ref(self._backend))\n        self.media = MediaManager(self, server)\n        self.models = ModelManager(self)\n        self.decks = DeckManager(self)\n        self.tags = TagManager(self)\n        self.conf = ConfigManager(self)\n        self._load_scheduler()\n        self._startReps = 0\n\n    def name(self) -> Any:\n        return os.path.splitext(os.path.basename(self.path))[0]\n\n    def weakref(self) -> Collection:\n        \"Shortcut to create a weak reference that doesn't break code completion.\"\n        return weakref.proxy(self)\n\n    @property\n    def backend(self) -> RustBackend:\n        traceback.print_stack(file=sys.stdout)\n        print()\n        print(\n            \"Accessing the backend directly will break in the future. Please use the public methods on Collection instead.\"\n        )\n        return self._backend\n\n    # I18n/messages\n    ##########################################################################\n\n    def format_timespan(\n        self,\n        seconds: float,\n        context: FormatTimeSpan.Context.V = FormatTimeSpan.INTERVALS,\n    ) -> str:\n        return self._backend.format_timespan(seconds=seconds, context=context)\n\n    # Progress\n    ##########################################################################\n\n    def latest_progress(self) -> Progress:\n        return self._backend.latest_progress()\n\n    # Scheduler\n    ##########################################################################\n\n    _supported_scheduler_versions = (1, 2)\n\n    def sched_ver(self) -> Literal[1, 2]:\n        \"\"\"For backwards compatibility, the v3 scheduler currently returns 2.\n        Use the separate v3_scheduler() method to check if it is active.\"\"\"\n        # for backwards compatibility, v3 is represented as 2\n        ver = self.conf.get(\"schedVer\", 1)\n        if ver in self._supported_scheduler_versions:\n            return ver\n        else:\n            raise Exception(\"Unsupported scheduler version\")\n\n    def _load_scheduler(self) -> None:\n        ver = self.sched_ver()\n        if ver == 1:\n            self.sched = DummyScheduler(self)\n        elif ver == 2:\n            if self.v3_scheduler():\n                self.sched = V3Scheduler(self)\n                # enable new timezone if not already enabled\n                if self.conf.get(\"creationOffset\") is None:\n                    prefs = self._backend.get_preferences()\n                    prefs.scheduling.new_timezone = True\n                    self._backend.set_preferences(prefs)\n            else:\n                self.sched = DummyScheduler(self)\n\n    def upgrade_to_v2_scheduler(self) -> None:\n        self._backend.upgrade_scheduler()\n        self._load_scheduler()\n\n    def v3_scheduler(self) -> bool:\n        return self.sched_ver() == 2 and self.get_config_bool(Config.Bool.SCHED_2021)\n\n    def set_v3_scheduler(self, enabled: bool) -> None:\n        if self.v3_scheduler() != enabled:\n            if enabled and self.sched_ver() != 2:\n                raise Exception(\"must upgrade to v2 scheduler first\")\n            self.set_config_bool(Config.Bool.SCHED_2021, enabled)\n            self._load_scheduler()\n\n    # DB-related\n    ##########################################################################\n\n    # legacy properties; these will likely go away in the future\n\n    @property\n    def crt(self) -> int:\n        return self.db.scalar(\"select crt from col\")\n\n    @crt.setter\n    def crt(self, crt: int) -> None:\n        self.db.execute(\"update col set crt = ?\", crt)\n\n    @property\n    def mod(self) -> int:\n        return self.db.scalar(\"select mod from col\")\n\n    @deprecated(info=\"saving is automatic\")\n    def save(self, **args: Any) -> None:\n        pass\n\n    @deprecated(info=\"saving is automatic\")\n    def autosave(self) -> None:\n        pass\n\n    def close(\n        self,\n        downgrade: bool = False,\n    ) -> None:\n        \"Disconnect from DB.\"\n        if self.db:\n            self._clear_caches()\n            self._backend.close_collection(\n                downgrade_to_schema11=downgrade,\n            )\n            self.db = None\n\n    def close_for_full_sync(self) -> None:\n        # save and cleanup, but backend will take care of collection close\n        if self.db:\n            self._clear_caches()\n            self.db = None\n\n    def _clear_caches(self) -> None:\n        self.models._clear_cache()\n\n    def reopen(self, after_full_sync: bool = False) -> None:\n        if self.db:\n            raise Exception(\"reopen() called with open db\")\n\n        (media_dir, media_db) = media_paths_from_col_path(self.path)\n\n        # connect\n        if not after_full_sync:\n            self._backend.open_collection(\n                collection_path=self.path,\n                media_folder_path=media_dir,\n                media_db_path=media_db,\n            )\n        self.db = DBProxy(weakref.proxy(self._backend))\n        if after_full_sync:\n            self._load_scheduler()\n\n    def set_schema_modified(self) -> None:\n        self.db.execute(\"update col set scm=?\", int_time(1000))\n\n    def mod_schema(self, check: bool) -> None:\n        \"Mark schema modified. GUI catches this and will ask user if required.\"\n        if not self.schema_changed():\n            if check and not hooks.schema_will_change(proceed=True):\n                raise AbortSchemaModification()\n        self.set_schema_modified()\n\n    def schema_changed(self) -> bool:\n        \"True if schema changed since last sync.\"\n        return self.db.scalar(\"select scm > ls from col\")\n\n    def usn(self) -> int:\n        if self.server:\n            return self.db.scalar(\"select usn from col\")\n        else:\n            return -1\n\n    # Import/export\n    ##########################################################################\n\n    def create_backup(\n        self,\n        *,\n        backup_folder: str,\n        force: bool,\n        wait_for_completion: bool,\n    ) -> bool:\n        \"\"\"Create a backup if enough time has elapsed, and rotate old backups.\n\n        If `force` is true, the user's configured backup interval is ignored.\n        Returns true if backup created. This may be false in the force=True case,\n        if no changes have been made to the collection.\n\n        Throws on failure of current backup, or the previous backup if it was not\n        awaited.\n        \"\"\"\n        # ensure any pending transaction from legacy code/add-ons has been committed\n        created = self._backend.create_backup(\n            backup_folder=backup_folder,\n            force=force,\n            wait_for_completion=wait_for_completion,\n        )\n        return created\n\n    def await_backup_completion(self) -> None:\n        \"Throws if backup creation failed.\"\n        self._backend.await_backup_completion()\n\n    def export_collection_package(\n        self, out_path: str, include_media: bool, legacy: bool\n    ) -> None:\n        self.close_for_full_sync()\n        self._backend.export_collection_package(\n            out_path=out_path, include_media=include_media, legacy=legacy\n        )\n\n    def import_anki_package(\n        self, request: ImportAnkiPackageRequest\n    ) -> ImportLogWithChanges:\n        log = self._backend.import_anki_package_raw(request.SerializeToString())\n        return ImportLogWithChanges.FromString(log)\n\n    def export_anki_package(\n        self, *, out_path: str, options: ExportAnkiPackageOptions, limit: ExportLimit\n    ) -> int:\n        return self._backend.export_anki_package(\n            out_path=out_path,\n            options=options,\n            limit=pb_export_limit(limit),\n        )\n\n    def get_csv_metadata(self, path: str, delimiter: Delimiter.V | None) -> CsvMetadata:\n        request = import_export_pb2.CsvMetadataRequest(path=path, delimiter=delimiter)\n        return self._backend.get_csv_metadata(request)\n\n    def import_csv(self, request: ImportCsvRequest) -> ImportLogWithChanges:\n        log = self._backend.import_csv_raw(request.SerializeToString())\n        return ImportLogWithChanges.FromString(log)\n\n    def export_note_csv(\n        self,\n        *,\n        out_path: str,\n        limit: ExportLimit,\n        with_html: bool,\n        with_tags: bool,\n        with_deck: bool,\n        with_notetype: bool,\n        with_guid: bool,\n    ) -> int:\n        return self._backend.export_note_csv(\n            out_path=out_path,\n            with_html=with_html,\n            with_tags=with_tags,\n            with_deck=with_deck,\n            with_notetype=with_notetype,\n            with_guid=with_guid,\n            limit=pb_export_limit(limit),\n        )\n\n    def export_card_csv(\n        self,\n        *,\n        out_path: str,\n        limit: ExportLimit,\n        with_html: bool,\n    ) -> int:\n        return self._backend.export_card_csv(\n            out_path=out_path,\n            with_html=with_html,\n            limit=pb_export_limit(limit),\n        )\n\n    def import_json_file(self, path: str) -> ImportLogWithChanges:\n        return self._backend.import_json_file(path)\n\n    def import_json_string(self, json: str) -> ImportLogWithChanges:\n        return self._backend.import_json_string(json)\n\n    def export_dataset_for_research(\n        self, target_path: str, min_entries: int = 0\n    ) -> None:\n        self._backend.export_dataset(min_entries=min_entries, target_path=target_path)\n\n    # Image Occlusion\n    ##########################################################################\n\n    def get_image_for_occlusion(self, path: str | None) -> GetImageForOcclusionResponse:\n        return self._backend.get_image_for_occlusion(path=path)\n\n    def add_image_occlusion_notetype(self) -> None:\n        \"Add notetype if missing.\"\n        self._backend.add_image_occlusion_notetype()\n\n    def add_image_occlusion_note(\n        self,\n        notetype_id: int,\n        image_path: str,\n        occlusions: str,\n        header: str,\n        back_extra: str,\n        tags: list[str],\n    ) -> OpChanges:\n        return self._backend.add_image_occlusion_note(\n            notetype_id=notetype_id,\n            image_path=image_path,\n            occlusions=occlusions,\n            header=header,\n            back_extra=back_extra,\n            tags=tags,\n        )\n\n    def get_image_occlusion_note(\n        self, note_id: int | None\n    ) -> GetImageOcclusionNoteResponse:\n        return self._backend.get_image_occlusion_note(note_id=note_id)\n\n    def update_image_occlusion_note(\n        self,\n        note_id: int | None,\n        occlusions: str | None,\n        header: str | None,\n        back_extra: str | None,\n        tags: list[str] | None,\n    ) -> OpChanges:\n        return self._backend.update_image_occlusion_note(\n            note_id=note_id,\n            occlusions=occlusions,\n            header=header,\n            back_extra=back_extra,\n            tags=tags,\n        )\n\n    # Object helpers\n    ##########################################################################\n\n    def get_card(self, id: CardId | None) -> Card:\n        return Card(self, id)\n\n    def update_cards(\n        self, cards: Sequence[Card], skip_undo_entry: bool = False\n    ) -> OpChanges:\n        \"\"\"Save card changes to database.\"\"\"\n        return self._backend.update_cards(\n            cards=[c._to_backend_card() for c in cards], skip_undo_entry=skip_undo_entry\n        )\n\n    def update_card(self, card: Card, skip_undo_entry: bool = False) -> OpChanges:\n        \"\"\"Save card changes to database.\"\"\"\n        return self.update_cards([card], skip_undo_entry=skip_undo_entry)\n\n    def get_note(self, id: NoteId) -> Note:\n        return Note(self, id=id)\n\n    def update_notes(\n        self, notes: Sequence[Note], skip_undo_entry: bool = False\n    ) -> OpChanges:\n        \"\"\"Save note changes to database.\"\"\"\n        return self._backend.update_notes(\n            notes=[n._to_backend_note() for n in notes], skip_undo_entry=skip_undo_entry\n        )\n\n    def update_note(self, note: Note, skip_undo_entry: bool = False) -> OpChanges:\n        \"\"\"Save note changes to database.\"\"\"\n        return self.update_notes([note], skip_undo_entry=skip_undo_entry)\n\n    # Utils\n    ##########################################################################\n\n    def nextID(self, type: str, inc: bool = True) -> Any:\n        type = f\"next{type.capitalize()}\"\n        id = self.conf.get(type, 1)\n        if inc:\n            self.conf[type] = id + 1\n        return id\n\n    @deprecated(info=\"no longer required\")\n    def reset(self) -> None:\n        pass\n\n    # Notes\n    ##########################################################################\n\n    def new_note(self, notetype: NotetypeDict) -> Note:\n        return Note(self, notetype)\n\n    def add_note(self, note: Note, deck_id: DeckId) -> OpChangesWithCount:\n        hooks.note_will_be_added(self, note, deck_id)\n        out = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id)\n        note.id = NoteId(out.note_id)\n        return out.changes\n\n    def add_notes(self, requests: Iterable[AddNoteRequest]) -> OpChanges:\n        for request in requests:\n            hooks.note_will_be_added(self, request.note, request.deck_id)\n        out = self._backend.add_notes(\n            requests=[\n                notes_pb2.AddNoteRequest(\n                    note=request.note._to_backend_note(), deck_id=request.deck_id\n                )\n                for request in requests\n            ]\n        )\n        for idx, request in enumerate(requests):\n            request.note.id = NoteId(out.nids[idx])\n\n        return out.changes\n\n    def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount:\n        hooks.notes_will_be_deleted(self, note_ids)\n        return self._backend.remove_notes(note_ids=note_ids, card_ids=[])\n\n    def remove_notes_by_card(self, card_ids: list[CardId]) -> None:\n        if hooks.notes_will_be_deleted.count():\n            nids = self.db.list(\n                f\"select nid from cards where id in {ids2str(card_ids)}\"\n            )\n            hooks.notes_will_be_deleted(self, nids)\n        self._backend.remove_notes(note_ids=[], card_ids=card_ids)\n\n    def card_ids_of_note(self, note_id: NoteId) -> Sequence[CardId]:\n        return [CardId(id) for id in self._backend.cards_of_note(note_id)]\n\n    def defaults_for_adding(\n        self, *, current_review_card: Card | None\n    ) -> anki.notes.DefaultsForAdding:\n        \"\"\"Get starting deck and notetype for add screen.\n        An option in the preferences controls whether this will be based on the current deck\n        or current notetype.\n        \"\"\"\n        if card := current_review_card:\n            home_deck = card.current_deck_id()\n        else:\n            home_deck = DeckId(0)\n\n        return self._backend.defaults_for_adding(\n            home_deck_of_current_review_card=home_deck,\n        )\n\n    def default_deck_for_notetype(self, notetype_id: NotetypeId) -> DeckId | None:\n        \"\"\"If 'change deck depending on notetype' is enabled in the preferences,\n        return the last deck used with the provided notetype, if any..\"\"\"\n        if self.get_config_bool(Config.Bool.ADDING_DEFAULTS_TO_CURRENT_DECK):\n            return None\n\n        return (\n            DeckId(\n                self._backend.default_deck_for_notetype(\n                    ntid=notetype_id,\n                )\n            )\n            or None\n        )\n\n    def note_count(self) -> int:\n        return self.db.scalar(\"select count() from notes\")\n\n    # Cards\n    ##########################################################################\n\n    def is_empty(self) -> bool:\n        return not self.db.scalar(\"select 1 from cards limit 1\")\n\n    def card_count(self) -> Any:\n        return self.db.scalar(\"select count() from cards\")\n\n    def remove_cards_and_orphaned_notes(\n        self, card_ids: Sequence[CardId]\n    ) -> OpChangesWithCount:\n        \"You probably want .remove_notes_by_card() instead.\"\n        return self._backend.remove_cards(card_ids=card_ids)\n\n    def set_deck(self, card_ids: Sequence[CardId], deck_id: int) -> OpChangesWithCount:\n        return self._backend.set_deck(card_ids=card_ids, deck_id=deck_id)\n\n    def get_empty_cards(self) -> EmptyCardsReport:\n        return self._backend.get_empty_cards()\n\n    # Card generation & field checksums/sort fields\n    ##########################################################################\n\n    def after_note_updates(\n        self, nids: list[NoteId], mark_modified: bool, generate_cards: bool = True\n    ) -> None:\n        \"If notes modified directly in database, call this afterwards.\"\n        self._backend.after_note_updates(\n            nids=nids, generate_cards=generate_cards, mark_notes_modified=mark_modified\n        )\n\n    # Finding cards\n    ##########################################################################\n\n    def find_cards(\n        self,\n        query: str,\n        order: bool | str | BrowserColumns.Column = False,\n        reverse: bool = False,\n    ) -> Sequence[CardId]:\n        \"\"\"Return card ids matching the provided search.\n\n        To programmatically construct a search string, see .build_search_string().\n\n        If order=True, use the sort order stored in the collection config\n        If order=False, do no ordering\n\n        If order is a string, that text is added after 'order by' in the sql statement.\n        You must add ' asc' or ' desc' to the order, as Anki will replace asc with\n        desc and vice versa when reverse is set in the collection config, eg\n        order=\"c.ivl asc, c.due desc\".\n\n        If order is a BrowserColumns.Column that supports sorting, sort using that\n        column. All available columns are available through col.all_browser_columns()\n        or browser.table._model.columns and support sorting cards unless column.sorting_cards\n        is set to BrowserColumns.SORTING_NONE, .SORTING_NOTES_ASCENDING, or\n        .SORTING_NOTES_DESCENDING.\n\n        The reverse argument only applies when a BrowserColumns.Column is provided;\n        otherwise the collection config defines whether reverse is set or not.\n        \"\"\"\n        mode = self._build_sort_mode(order, reverse, False)\n        return cast(\n            Sequence[CardId], self._backend.search_cards(search=query, order=mode)\n        )\n\n    def find_notes(\n        self,\n        query: str,\n        order: bool | str | BrowserColumns.Column = False,\n        reverse: bool = False,\n    ) -> Sequence[NoteId]:\n        \"\"\"Return note ids matching the provided search.\n\n        To programmatically construct a search string, see .build_search_string().\n        The order parameter is documented in .find_cards().\n        \"\"\"\n        mode = self._build_sort_mode(order, reverse, True)\n        return cast(\n            Sequence[NoteId], self._backend.search_notes(search=query, order=mode)\n        )\n\n    def _build_sort_mode(\n        self,\n        order: bool | str | BrowserColumns.Column,\n        reverse: bool,\n        finding_notes: bool,\n    ) -> search_pb2.SortOrder:\n        if isinstance(order, str):\n            return search_pb2.SortOrder(custom=order)\n        if isinstance(order, bool):\n            if order is False:\n                return search_pb2.SortOrder(none=generic_pb2.Empty())\n            # order=True: set args to sort column and reverse from config\n            sort_key = BrowserConfig.sort_column_key(finding_notes)\n            order = self.get_browser_column(self.get_config(sort_key))\n            reverse_key = BrowserConfig.sort_backwards_key(finding_notes)\n            reverse = self.get_config(reverse_key)\n        if (\n            isinstance(order, BrowserColumns.Column)\n            and (order.sorting_notes if finding_notes else order.sorting_cards)\n            is not BrowserColumns.SORTING_NONE\n        ):\n            return search_pb2.SortOrder(\n                builtin=search_pb2.SortOrder.Builtin(column=order.key, reverse=reverse)\n            )\n\n        # eg, user is ordering on an add-on field with the add-on not installed\n        print(f\"{order} is not a valid sort order.\")\n        return search_pb2.SortOrder(none=generic_pb2.Empty())\n\n    def find_and_replace(\n        self,\n        *,\n        note_ids: Sequence[NoteId],\n        search: str,\n        replacement: str,\n        regex: bool = False,\n        field_name: str | None = None,\n        match_case: bool = False,\n    ) -> OpChangesWithCount:\n        \"Find and replace fields in a note. Returns changed note count.\"\n        return self._backend.find_and_replace(\n            nids=note_ids,\n            search=search,\n            replacement=replacement,\n            regex=regex,\n            match_case=match_case,\n            field_name=field_name or \"\",\n        )\n\n    def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]:\n        return self._backend.field_names_for_notes(nids)\n\n    # returns array of (\"dupestr\", [nids])\n    def find_dupes(self, field_name: str, search: str = \"\") -> list[tuple[str, list]]:\n        nids = self.find_notes(\n            self.build_search_string(search, SearchNode(field_name=field_name))\n        )\n        # go through notes\n        vals: dict[str, list[int]] = {}\n        dupes = []\n        fields: dict[int, int] = {}\n\n        def ord_for_mid(mid: NotetypeId) -> int:\n            if mid not in fields:\n                model = self.models.get(mid)\n                for idx, field in enumerate(model[\"flds\"]):\n                    if field[\"name\"].lower() == field_name.lower():\n                        fields[mid] = idx\n                        break\n            return fields[mid]\n\n        for nid, mid, flds in self.db.all(\n            f\"select id, mid, flds from notes where id in {ids2str(nids)}\"\n        ):\n            flds = split_fields(flds)\n            ord = ord_for_mid(mid)\n            if ord is None:\n                continue\n            val = flds[ord]\n            val = strip_html_media(val)\n            # empty does not count as duplicate\n            if not val:\n                continue\n            vals.setdefault(val, []).append(nid)\n            if len(vals[val]) == 2:\n                dupes.append((val, vals[val]))\n        return dupes\n\n    # Search Strings\n    ##########################################################################\n\n    def build_search_string(\n        self,\n        *nodes: str | SearchNode,\n        joiner: SearchJoiner = \"AND\",\n    ) -> str:\n        \"\"\"Join one or more searches, and return a normalized search string.\n\n        To negate, wrap in a negated search term:\n\n            term = SearchNode(negated=col.group_searches(...))\n\n        Invalid searches will throw an exception.\n        \"\"\"\n        term = self.group_searches(*nodes, joiner=joiner)\n        return self._backend.build_search_string(term)\n\n    def group_searches(\n        self,\n        *nodes: str | SearchNode,\n        joiner: SearchJoiner = \"AND\",\n    ) -> SearchNode:\n        \"\"\"Join provided search nodes and strings into a single SearchNode.\n        If a single SearchNode is provided, it is returned as-is.\n        At least one node must be provided.\n        \"\"\"\n        assert nodes\n\n        # convert raw text to SearchNodes\n        search_nodes = [\n            node if isinstance(node, SearchNode) else SearchNode(parsable_text=node)\n            for node in nodes\n        ]\n\n        # if there's more than one, wrap them in a group\n        if len(search_nodes) > 1:\n            return SearchNode(\n                group=SearchNode.Group(\n                    nodes=search_nodes, joiner=self._pb_search_separator(joiner)\n                )\n            )\n        else:\n            return search_nodes[0]\n\n    def join_searches(\n        self,\n        existing_node: SearchNode,\n        additional_node: SearchNode,\n        operator: Literal[\"AND\", \"OR\"],\n    ) -> str:\n        \"\"\"\n        AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets.\n        Used by the Browse screen to avoid adding extra brackets when joining.\n        If you're building a search query yourself, you probably don't need this.\n        \"\"\"\n        search_string = self._backend.join_search_nodes(\n            joiner=self._pb_search_separator(operator),\n            existing_node=existing_node,\n            additional_node=additional_node,\n        )\n\n        return search_string\n\n    def replace_in_search_node(\n        self, existing_node: SearchNode, replacement_node: SearchNode\n    ) -> str:\n        \"\"\"If nodes of the same type as `replacement_node` are found in existing_node, replace them.\n\n        You can use this to replace any \"deck\" clauses in a search with a different deck for example.\n        \"\"\"\n        return self._backend.replace_search_node(\n            existing_node=existing_node, replacement_node=replacement_node\n        )\n\n    def _pb_search_separator(self, operator: SearchJoiner) -> SearchNode.Group.Joiner.V:\n        if operator == \"AND\":\n            return SearchNode.Group.Joiner.AND\n        else:\n            return SearchNode.Group.Joiner.OR\n\n    # Browser Table\n    ##########################################################################\n\n    def all_browser_columns(self) -> Sequence[BrowserColumns.Column]:\n        return self._backend.all_browser_columns()\n\n    def get_browser_column(self, key: str) -> BrowserColumns.Column | None:\n        for column in self._backend.all_browser_columns():\n            if column.key == key:\n                return column\n        return None\n\n    def browser_row_for_id(\n        self, id_: int\n    ) -> tuple[\n        Generator[tuple[str, bool, BrowserRow.Cell.TextElideMode.V], None, None],\n        BrowserRow.Color.V,\n        str,\n        int,\n    ]:\n        row = self._backend.browser_row_for_id(id_)\n        return (\n            ((cell.text, cell.is_rtl, cell.elide_mode) for cell in row.cells),\n            row.color,\n            row.font_name,\n            row.font_size,\n        )\n\n    def load_browser_card_columns(self) -> list[str]:\n        \"\"\"Return the stored card column names and ensure the backend columns are set and in sync.\"\"\"\n        columns = self.get_config(\n            BrowserConfig.ACTIVE_CARD_COLUMNS_KEY, BrowserDefaults.CARD_COLUMNS\n        )\n        self._backend.set_active_browser_columns(columns)\n        return columns\n\n    def set_browser_card_columns(self, columns: list[str]) -> None:\n        self.set_config(BrowserConfig.ACTIVE_CARD_COLUMNS_KEY, columns)\n        self._backend.set_active_browser_columns(columns)\n\n    def load_browser_note_columns(self) -> list[str]:\n        \"\"\"Return the stored note column names and ensure the backend columns are set and in sync.\"\"\"\n        columns = self.get_config(\n            BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY, BrowserDefaults.NOTE_COLUMNS\n        )\n        self._backend.set_active_browser_columns(columns)\n        return columns\n\n    def set_browser_note_columns(self, columns: list[str]) -> None:\n        self.set_config(BrowserConfig.ACTIVE_NOTE_COLUMNS_KEY, columns)\n        self._backend.set_active_browser_columns(columns)\n\n    # Config\n    ##########################################################################\n\n    def get_config(self, key: str, default: Any | None = None) -> Any:\n        try:\n            return self.conf.get_immutable(key)\n        except KeyError:\n            return default\n\n    def set_config(self, key: str, val: Any, *, undoable: bool = False) -> OpChanges:\n        \"\"\"Set a single config variable to any JSON-serializable value. The config\n        is currently sent on every sync, so please don't store more than a few\n        kilobytes in it.\n\n        By default, no undo entry will be created, but the existing undo history\n        will be preserved. Set `undoable=True` to allow the change to be undone;\n        see undo code for how you can merge multiple undo entries.\"\"\"\n        return self._backend.set_config_json(\n            key=key, value_json=to_json_bytes(val), undoable=undoable\n        )\n\n    def remove_config(self, key: str) -> OpChanges:\n        return self.conf.remove(key)\n\n    def all_config(self) -> dict[str, Any]:\n        \"This is a debugging aid. Prefer .get_config() when you know the key you need.\"\n        return from_json_bytes(self._backend.get_all_config())\n\n    def get_config_bool(self, key: Config.Bool.V) -> bool:\n        return self._backend.get_config_bool(key)\n\n    def set_config_bool(\n        self, key: Config.Bool.V, value: bool, *, undoable: bool = False\n    ) -> OpChanges:\n        return self._backend.set_config_bool(key=key, value=value, undoable=undoable)\n\n    def get_config_string(self, key: Config.String.V) -> str:\n        return self._backend.get_config_string(key)\n\n    def set_config_string(\n        self, key: Config.String.V, value: str, undoable: bool = False\n    ) -> OpChanges:\n        return self._backend.set_config_string(key=key, value=value, undoable=undoable)\n\n    def get_aux_notetype_config(\n        self, id: NotetypeId, key: str, default: Any | None = None\n    ) -> Any:\n        key = self._backend.get_aux_notetype_config_key(id=id, key=key)\n        return self.get_config(key, default=default)\n\n    def set_aux_notetype_config(\n        self, id: NotetypeId, key: str, value: Any, *, undoable: bool = False\n    ) -> OpChanges:\n        key = self._backend.get_aux_notetype_config_key(id=id, key=key)\n        return self.set_config(key, value, undoable=undoable)\n\n    def get_aux_template_config(\n        self, id: NotetypeId, card_ordinal: int, key: str, default: Any | None = None\n    ) -> Any:\n        key = self._backend.get_aux_template_config_key(\n            notetype_id=id, card_ordinal=card_ordinal, key=key\n        )\n        return self.get_config(key, default=default)\n\n    def set_aux_template_config(\n        self,\n        id: NotetypeId,\n        card_ordinal: int,\n        key: str,\n        value: Any,\n        *,\n        undoable: bool = False,\n    ) -> OpChanges:\n        key = self._backend.get_aux_template_config_key(\n            notetype_id=id, card_ordinal=card_ordinal, key=key\n        )\n        return self.set_config(key, value, undoable=undoable)\n\n    def _get_load_balancer_enabled(self) -> bool:\n        return self.get_config_bool(Config.Bool.LOAD_BALANCER_ENABLED)\n\n    def _set_load_balancer_enabled(self, value: bool) -> None:\n        self._backend.set_load_balancer_enabled(value)\n\n    load_balancer_enabled = property(\n        fget=_get_load_balancer_enabled, fset=_set_load_balancer_enabled\n    )\n\n    def _get_enable_fsrs_short_term_with_steps(self) -> bool:\n        return self.get_config_bool(Config.Bool.FSRS_SHORT_TERM_WITH_STEPS_ENABLED)\n\n    def _set_enable_fsrs_short_term_with_steps(self, value: bool) -> None:\n        self.set_config_bool(Config.Bool.FSRS_SHORT_TERM_WITH_STEPS_ENABLED, value)\n\n    fsrs_short_term_with_steps_enabled = property(\n        fget=_get_enable_fsrs_short_term_with_steps,\n        fset=_set_enable_fsrs_short_term_with_steps,\n    )\n    # Stats\n    ##########################################################################\n\n    def stats(self) -> anki.stats.CollectionStats:\n        from anki.stats import CollectionStats\n\n        return CollectionStats(self)\n\n    def card_stats_data(self, card_id: CardId) -> stats_pb2.CardStatsResponse:\n        \"\"\"Returns the data required to show card stats.\n\n        If you wish to display the stats in a HTML table like Anki does,\n        you can use the .js file directly - see this add-on for an example:\n        https://ankiweb.net/shared/info/2179254157\n        \"\"\"\n        return self._backend.card_stats(card_id)\n\n    def get_review_logs(\n        self, card_id: CardId\n    ) -> Sequence[stats_pb2.CardStatsResponse.StatsRevlogEntry]:\n        return self._backend.get_review_logs(card_id)\n\n    def studied_today(self) -> str:\n        return self._backend.studied_today()\n\n    # Undo\n    ##########################################################################\n\n    def undo_status(self) -> UndoStatus:\n        \"Return the undo status.\"\n        return self._check_backend_undo_status() or UndoStatus()\n\n    def add_custom_undo_entry(self, name: str) -> int:\n        \"\"\"Add an empty undo entry with the given name.\n        The return value can be used to merge subsequent changes\n        with `merge_undo_entries()`.\n\n        You should only use this with your own custom actions - when\n        extending default Anki behaviour, you should merge into an\n        existing undo entry instead, so the existing undo name is\n        preserved, and changes are processed correctly.\n        \"\"\"\n        return self._backend.add_custom_undo_entry(name)\n\n    def merge_undo_entries(self, target: int) -> OpChanges:\n        \"\"\"Combine multiple undoable operations into one.\n\n        After a standard Anki action, you can use col.undo_status().last_step\n        to retrieve the target to merge into. When defining your own custom\n        actions, you can use `add_custom_undo_entry()` to define a custom\n        undo name.\n        \"\"\"\n        return self._backend.merge_undo_entries(target)\n\n    def undo(self) -> OpChangesAfterUndo:\n        \"\"\"Returns result of backend undo operation, or throws UndoEmpty.\"\"\"\n        out = self._backend.undo()\n        if out.changes.notetype:\n            self.models._clear_cache()\n        return out\n\n    def redo(self) -> OpChangesAfterUndo:\n        \"\"\"Returns result of backend redo operation, or throws UndoEmpty.\"\"\"\n        out = self._backend.redo()\n        if out.changes.notetype:\n            self.models._clear_cache()\n        return out\n\n    def op_made_changes(self, changes: OpChanges) -> bool:\n        for field in changes.DESCRIPTOR.fields:\n            if field.name != \"kind\":\n                if getattr(changes, field.name, False):\n                    return True\n        return False\n\n    def _check_backend_undo_status(self) -> UndoStatus | None:\n        \"\"\"Return undo status if undo available on backend.\n        If backend has undo available, clear the Python undo state.\"\"\"\n        status = self._backend.get_undo_status()\n        if status.undo or status.redo:\n            return status\n        else:\n            return None\n\n    # DB maintenance\n    ##########################################################################\n\n    def fix_integrity(self) -> tuple[str, bool]:\n        \"\"\"Fix possible problems and rebuild caches.\n\n        Returns tuple of (error: str, ok: bool). 'ok' will be true if no\n        problems were found.\n        \"\"\"\n        try:\n            problems = list(self._backend.check_database())\n            ok = not problems\n            problems.append(self.tr.database_check_rebuilt())\n        except DBError as err:\n            problems = [str(err)]\n            ok = False\n        return (\"\\n\".join(problems), ok)\n\n    def optimize(self) -> None:\n        self.db.execute(\"vacuum\")\n        self.db.execute(\"analyze\")\n\n    ##########################################################################\n\n    def set_user_flag_for_cards(\n        self, flag: int, cids: Sequence[CardId]\n    ) -> OpChangesWithCount:\n        return self._backend.set_flag(card_ids=cids, flag=flag)\n\n    def set_wants_abort(self) -> None:\n        self._backend.set_wants_abort()\n\n    def i18n_resources(self, modules: Sequence[str]) -> bytes:\n        return self._backend.i18n_resources(modules=modules)\n\n    def abort_media_sync(self) -> None:\n        self._backend.abort_media_sync()\n\n    def abort_sync(self) -> None:\n        self._backend.abort_sync()\n\n    def full_upload_or_download(\n        self, *, auth: SyncAuth | None, server_usn: int | None, upload: bool\n    ) -> None:\n        self._backend.full_upload_or_download(\n            sync_pb2.FullUploadOrDownloadRequest(\n                auth=auth, server_usn=server_usn, upload=upload\n            )\n        )\n\n    def sync_login(\n        self, username: str, password: str, endpoint: str | None\n    ) -> SyncAuth:\n        return self._backend.sync_login(\n            SyncLoginRequest(username=username, password=password, endpoint=endpoint)\n        )\n\n    def sync_collection(self, auth: SyncAuth, sync_media: bool) -> SyncOutput:\n        return self._backend.sync_collection(auth=auth, sync_media=sync_media)\n\n    def sync_media(self, auth: SyncAuth) -> None:\n        self._backend.sync_media(auth)\n\n    def sync_status(self, auth: SyncAuth) -> SyncStatus:\n        return self._backend.sync_status(auth)\n\n    def media_sync_status(self) -> MediaSyncStatus:\n        \"This will throw if the sync failed with an error.\"\n        return self._backend.media_sync_status()\n\n    def ankihub_login(self, id: str, password: str) -> str:\n        return self._backend.ankihub_login(id=id, password=password)\n\n    def ankihub_logout(self, token: str) -> None:\n        self._backend.ankihub_logout(token=token)\n\n    def get_preferences(self) -> Preferences:\n        return self._backend.get_preferences()\n\n    def set_preferences(self, prefs: Preferences) -> OpChanges:\n        return self._backend.set_preferences(prefs)\n\n    def render_markdown(self, text: str, sanitize: bool = True) -> str:\n        \"Not intended for public consumption at this time.\"\n        return self._backend.render_markdown(markdown=text, sanitize=sanitize)\n\n    def compare_answer(\n        self, expected: str, provided: str, combining: bool = True\n    ) -> str:\n        return self._backend.compare_answer(\n            expected=expected, provided=provided, combining=combining\n        )\n\n    def extract_cloze_for_typing(self, text: str, ordinal: int) -> str:\n        return self._backend.extract_cloze_for_typing(text=text, ordinal=ordinal)\n\n    def compute_memory_state(self, card_id: CardId) -> ComputedMemoryState:\n        resp = self._backend.compute_memory_state(card_id)\n        if resp.HasField(\"state\"):\n            return ComputedMemoryState(\n                desired_retention=resp.desired_retention,\n                stability=resp.state.stability,\n                difficulty=resp.state.difficulty,\n                decay=resp.decay,\n            )\n        else:\n            return ComputedMemoryState(\n                desired_retention=resp.desired_retention,\n                decay=resp.decay,\n            )\n\n    def fuzz_delta(self, card_id: CardId, interval: int) -> int:\n        \"The delta days of fuzz applied if reviewing the card in v3.\"\n        return self._backend.fuzz_delta(card_id=card_id, interval=interval)\n\n    # Timeboxing\n    ##########################################################################\n    # fixme: there doesn't seem to be a good reason why this code is in main.py\n    # instead of covered in reviewer, and the reps tracking is covered by both\n    # the scheduler and reviewer.py. in the future, we should probably move\n    # reps tracking to reviewer.py, and remove the startTimebox() calls from\n    # other locations like overview.py. We just need to make sure not to reset\n    # the count on things like edits, which we probably could do by checking\n    # the previous state in moveToState.\n\n    def startTimebox(self) -> None:\n        self._startTime = time.time()\n        self._startReps = self.sched.reps\n\n    def timeboxReached(self) -> Literal[False] | tuple[Any, int]:\n        \"Return (elapsedTime, reps) if timebox reached, or False.\"\n        if not self.conf[\"timeLim\"]:\n            # timeboxing disabled\n            return False\n        elapsed = time.time() - self._startTime\n        if elapsed > self.conf[\"timeLim\"]:\n            return (self.conf[\"timeLim\"], self.sched.reps - self._startReps)\n        return False\n\n    # Legacy\n    ##########################################################################\n\n    @deprecated(info=\"no longer used\")\n    def log(self, *args: Any, **kwargs: Any) -> None:\n        print(args, kwargs)\n\n    @deprecated(replaced_by=undo_status)\n    def undo_name(self) -> str | None:\n        \"Undo menu item name, or None if undo unavailable.\"\n        status = self.undo_status()\n        return status.undo or None\n\n    # @deprecated(replaced_by=new_note)\n    def newNote(self, forDeck: bool = True) -> Note:\n        \"Return a new note with the current model.\"\n        return Note(self, self.models.current(forDeck))\n\n    # @deprecated(replaced_by=add_note)\n    def addNote(self, note: Note) -> int:\n        self.add_note(note, note.note_type()[\"did\"])\n        return len(note.cards())\n\n    @deprecated(replaced_by=remove_notes)\n    def remNotes(self, ids: Sequence[NoteId]) -> None:\n        self.remove_notes(ids)\n\n    @deprecated(replaced_by=remove_notes)\n    def _remNotes(self, ids: list[NoteId]) -> None:\n        pass\n\n    @deprecated(replaced_by=card_stats_data)\n    def card_stats(self, card_id: CardId, include_revlog: bool) -> str:\n        from anki.stats import _legacy_card_stats\n\n        return _legacy_card_stats(self, card_id, include_revlog)\n\n    @deprecated(replaced_by=card_stats_data)\n    def cardStats(self, card: Card) -> str:\n        from anki.stats import _legacy_card_stats\n\n        return _legacy_card_stats(self, card.id, False)\n\n    @deprecated(replaced_by=after_note_updates)\n    def updateFieldCache(self, nids: list[NoteId]) -> None:\n        self.after_note_updates(nids, mark_modified=False, generate_cards=False)\n\n    @deprecated(replaced_by=after_note_updates)\n    def genCards(self, nids: list[NoteId]) -> list[int]:\n        self.after_note_updates(nids, mark_modified=False, generate_cards=True)\n        # previously returned empty cards, no longer does\n        return []\n\n    @deprecated(info=\"no longer used\")\n    def emptyCids(self) -> list[CardId]:\n        return []\n\n    @deprecated(info=\"handled by backend\")\n    def _logRem(self, ids: list[int | NoteId], type: int) -> None:\n        self.db.executemany(\n            \"insert into graves values (%d, ?, %d)\" % (self.usn(), type),\n            ([x] for x in ids),\n        )\n\n    @deprecated(info=\"no longer required\")\n    def setMod(self) -> None:\n        pass\n\n    @deprecated(info=\"no longer required\")\n    def flush(self) -> None:\n        pass\n\n\nCollection.register_deprecated_aliases(\n    findReplace=Collection.find_and_replace,\n    remCards=Collection.remove_cards_and_orphaned_notes,\n)\n\n\n# legacy name\n_Collection = Collection\n\n\ndef pb_export_limit(limit: ExportLimit) -> import_export_pb2.ExportLimit:\n    message = import_export_pb2.ExportLimit()\n    if isinstance(limit, DeckIdLimit):\n        message.deck_id = limit.deck_id\n    elif isinstance(limit, NoteIdsLimit):\n        message.note_ids.note_ids.extend(limit.note_ids)\n    elif isinstance(limit, CardIdsLimit):\n        message.card_ids.cids.extend(limit.card_ids)\n    else:\n        message.whole_collection.SetInParent()\n    return message\n"
  },
  {
    "path": "pylib/anki/config.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nConfig handling\n\n- To set a config value, use col.set_config(key, val).\n- To get a config value, use col.get_config(key, default=None). In\nthe case of lists and dictionaries, any changes you make to the returned\nvalue will not be saved unless you call set_config().\n- To remove a config value, use col.remove_config(key).\n\nFor legacy reasons, the config is also exposed as a dict interface\nas col.conf.  To support old code that was mutating inner values,\nusing col.conf[\"key\"] needs to wrap lists and dicts when returning them.\nAs this is less efficient, please use the col.*_config() API in new code.\nThe legacy set also does not support the new undo handling.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport copy\nimport weakref\nfrom typing import Any\nfrom weakref import ref\n\nimport anki\nimport anki.collection\nfrom anki import config_pb2\nfrom anki.collection import OpChanges\nfrom anki.errors import NotFoundError\nfrom anki.utils import from_json_bytes, to_json_bytes\n\nConfig = config_pb2.ConfigKey\n\n\nclass ConfigManager:\n    def __init__(self, col: anki.collection.Collection):\n        self.col = col.weakref()\n\n    def get_immutable(self, key: str) -> Any:\n        try:\n            return from_json_bytes(self.col._backend.get_config_json(key))\n        except NotFoundError as exc:\n            raise KeyError from exc\n\n    def set(self, key: str, val: Any) -> None:\n        self.col._backend.set_config_json_no_undo(\n            key=key,\n            value_json=to_json_bytes(val),\n            # this argument is ignored\n            undoable=True,\n        )\n\n    def remove(self, key: str) -> OpChanges:\n        return self.col._backend.remove_config(key)\n\n    # Legacy dict interface\n    #########################\n\n    def __getitem__(self, key: str) -> Any:\n        val = self.get_immutable(key)\n        if isinstance(val, list):\n            print(\n                f\"conf key {key} should be fetched with col.get_config(), and saved with col.set_config()\"\n            )\n            return WrappedList(weakref.ref(self), key, val)\n        elif isinstance(val, dict):\n            print(\n                f\"conf key {key} should be fetched with col.get_config(), and saved with col.set_config()\"\n            )\n            return WrappedDict(weakref.ref(self), key, val)\n        else:\n            return val\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.set(key, value)\n\n    def get(self, key: str, default: Any | None = None) -> Any:\n        try:\n            return self[key]\n        except KeyError:\n            return default\n\n    def setdefault(self, key: str, default: Any) -> Any:\n        if key not in self:\n            self[key] = default\n        return self[key]\n\n    def __contains__(self, key: str) -> bool:\n        try:\n            self.get_immutable(key)\n            return True\n        except KeyError:\n            return False\n\n    def __delitem__(self, key: str) -> None:\n        self.remove(key)\n\n\n# Tracking changes to mutable objects\n#########################################\n# Because we previously allowed mutation of the conf\n# structure directly, to allow col.conf[\"foo\"][\"bar\"] = xx\n# to continue to function, we apply changes as the object\n# is dropped.\n\n\nclass WrappedList(list):\n    def __init__(self, conf: ref[ConfigManager], key: str, val: Any) -> None:\n        self.key = key\n        self.conf = conf\n        self.orig = copy.deepcopy(val)\n        super().__init__(val)\n\n    def __del__(self) -> None:\n        cur = list(self)\n        conf = self.conf()\n        if conf and self.orig != cur:\n            conf[self.key] = cur\n\n\nclass WrappedDict(dict):\n    def __init__(self, conf: ref[ConfigManager], key: str, val: Any) -> None:\n        self.key = key\n        self.conf = conf\n        self.orig = copy.deepcopy(val)\n        super().__init__(val)\n\n    def __del__(self) -> None:\n        cur = dict(self)\n        conf = self.conf()\n        if conf and self.orig != cur:\n            conf[self.key] = cur\n"
  },
  {
    "path": "pylib/anki/consts.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport sys\nfrom typing import TYPE_CHECKING, Any, NewType\n\nfrom anki._legacy import DeprecatedNamesMixinForModule\n\n# whether new cards should be mixed with reviews, or shown first or last\nNEW_CARDS_DISTRIBUTE = 0\nNEW_CARDS_LAST = 1\nNEW_CARDS_FIRST = 2\n\n# new card insertion order\nNEW_CARDS_RANDOM = 0\nNEW_CARDS_DUE = 1\n\n# Queue types\nCardQueue = NewType(\"CardQueue\", int)\nQUEUE_TYPE_MANUALLY_BURIED = CardQueue(-3)\nQUEUE_TYPE_SIBLING_BURIED = CardQueue(-2)\nQUEUE_TYPE_SUSPENDED = CardQueue(-1)\nQUEUE_TYPE_NEW = CardQueue(0)\nQUEUE_TYPE_LRN = CardQueue(1)\nQUEUE_TYPE_REV = CardQueue(2)\nQUEUE_TYPE_DAY_LEARN_RELEARN = CardQueue(3)\nQUEUE_TYPE_PREVIEW = CardQueue(4)\n\n# Card types\nCardType = NewType(\"CardType\", int)\nCARD_TYPE_NEW = CardType(0)\nCARD_TYPE_LRN = CardType(1)\nCARD_TYPE_REV = CardType(2)\nCARD_TYPE_RELEARNING = CardType(3)\n\n# removal types\nREM_CARD = 0\nREM_NOTE = 1\nREM_DECK = 2\n\n# count display\nCOUNT_ANSWERED = 0\nCOUNT_REMAINING = 1\n\n# media log\nMEDIA_ADD = 0\nMEDIA_REM = 1\n\n# Kind of decks\nDECK_STD = 0\nDECK_DYN = 1\n\n# dynamic deck order\nDYN_OLDEST = 0\nDYN_RANDOM = 1\nDYN_SMALLINT = 2\nDYN_BIGINT = 3\nDYN_LAPSES = 4\nDYN_ADDED = 5\nDYN_DUE = 6\nDYN_REVADDED = 7\nDYN_DUEPRIORITY = 8\n\nDYN_MAX_SIZE = 99999\n\n# model types\nMODEL_STD = 0\nMODEL_CLOZE = 1\n\nSTARTING_FACTOR = 2500\nSTARTING_FACTOR_FRACTION = STARTING_FACTOR / 1000\n\nHELP_SITE = \"https://docs.ankiweb.net/\"\n\n# Leech actions\nLEECH_SUSPEND = 0\nLEECH_TAGONLY = 1\n\n# Buttons\nBUTTON_ONE = 1\nBUTTON_TWO = 2\nBUTTON_THREE = 3\nBUTTON_FOUR = 4\n\n# Revlog types\nREVLOG_LRN = 0\nREVLOG_REV = 1\nREVLOG_RELRN = 2\nREVLOG_CRAM = 3\nREVLOG_RESCHED = 4\n\n# Labels\n##########################################################################\n\nimport anki.collection\n\n\ndef _tr(col: anki.collection.Collection | None) -> Any:\n    if col:\n        return col.tr\n    else:\n        print(\"routine in consts.py should be passed col\")\n        import traceback\n\n        traceback.print_stack(file=sys.stdout)\n        from anki.lang import tr_legacyglobal\n\n        return tr_legacyglobal\n\n\ndef new_card_order_labels(col: anki.collection.Collection | None) -> dict[int, Any]:\n    tr = _tr(col)\n    return {\n        0: tr.scheduling_show_new_cards_in_random_order(),\n        1: tr.scheduling_show_new_cards_in_order_added(),\n    }\n\n\n_deprecated_names = DeprecatedNamesMixinForModule(globals())\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name: str) -> Any:\n        return _deprecated_names.__getattr__(name)\n"
  },
  {
    "path": "pylib/anki/db.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nA convenience wrapper over pysqlite.\n\nAnki's Collection class now uses dbproxy.py instead of this class,\nbut this class is still used by aqt's profile manager, and a number\nof add-ons rely on it.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport pprint\nimport time\nfrom sqlite3 import Cursor\nfrom sqlite3 import dbapi2 as sqlite\nfrom typing import Any\n\nfrom anki._legacy import DeprecatedNamesMixin\n\nDBError = sqlite.Error\n\n\nclass DB(DeprecatedNamesMixin):\n    def __init__(self, path: str, timeout: int = 0) -> None:\n        self._db = sqlite.connect(path, timeout=timeout)\n        self._db.text_factory = self._text_factory\n        self._path = path\n        self.echo = os.environ.get(\"DBECHO\")\n        self.mod = False\n\n    def __repr__(self) -> str:\n        dict_ = dict(self.__dict__)\n        del dict_[\"_db\"]\n        return f\"{super().__repr__()} {pprint.pformat(dict_, width=300)}\"\n\n    def execute(self, sql: str, *a: Any, **ka: Any) -> Cursor:\n        canonized = sql.strip().lower()\n        # mark modified?\n        for stmt in \"insert\", \"update\", \"delete\":\n            if canonized.startswith(stmt):\n                self.mod = True\n        start_time = time.time()\n        if ka:\n            # execute(\"...where id = :id\", id=5)\n            res = self._db.execute(sql, ka)\n        else:\n            # execute(\"...where id = ?\", 5)\n            res = self._db.execute(sql, a)\n        if self.echo:\n            # print a, ka\n            print(sql, f\"{(time.time() - start_time) * 1000:0.3f}ms\")\n            if self.echo == \"2\":\n                print(a, ka)\n        return res\n\n    def executemany(self, sql: str, iterable: Any) -> None:\n        self.mod = True\n        start_time = time.time()\n        self._db.executemany(sql, iterable)\n        if self.echo:\n            print(sql, f\"{(time.time() - start_time) * 1000:0.3f}ms\")\n            if self.echo == \"2\":\n                print(iterable)\n\n    def commit(self) -> None:\n        start_time = time.time()\n        self._db.commit()\n        if self.echo:\n            print(f\"commit {(time.time() - start_time) * 1000:0.3f}ms\")\n\n    def executescript(self, sql: str) -> None:\n        self.mod = True\n        if self.echo:\n            print(sql)\n        self._db.executescript(sql)\n\n    def rollback(self) -> None:\n        self._db.rollback()\n\n    def scalar(self, *a: Any, **kw: Any) -> Any:\n        res = self.execute(*a, **kw).fetchone()\n        if res:\n            return res[0]\n        return None\n\n    def all(self, *a: Any, **kw: Any) -> list:\n        return self.execute(*a, **kw).fetchall()\n\n    def first(self, *a: Any, **kw: Any) -> Any:\n        cursor = self.execute(*a, **kw)\n        res = cursor.fetchone()\n        cursor.close()\n        return res\n\n    def list(self, *a: Any, **kw: Any) -> list:\n        return [x[0] for x in self.execute(*a, **kw)]\n\n    def close(self) -> None:\n        self._db.text_factory = None\n        self._db.close()\n\n    def set_progress_handler(self, *args: Any) -> None:\n        self._db.set_progress_handler(*args)\n\n    def __enter__(self) -> \"DB\":\n        self._db.execute(\"begin\")\n        return self\n\n    def __exit__(self, *args: Any) -> None:\n        self._db.close()\n\n    def total_changes(self) -> Any:\n        return self._db.total_changes\n\n    def interrupt(self) -> None:\n        self._db.interrupt()\n\n    def set_autocommit(self, autocommit: bool) -> None:\n        if autocommit:\n            self._db.isolation_level = None\n        else:\n            self._db.isolation_level = \"\"\n\n    # strip out invalid utf-8 when reading from db\n    def _text_factory(self, data: bytes) -> str:\n        return str(data, errors=\"ignore\")\n\n    def cursor(self, factory: type[Cursor] = Cursor) -> Cursor:\n        return self._db.cursor(factory)\n"
  },
  {
    "path": "pylib/anki/dbproxy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport re\nfrom collections.abc import Callable, Iterable, Sequence\nfrom re import Match\nfrom typing import TYPE_CHECKING, Any, Union\n\nif TYPE_CHECKING:\n    import anki._backend\n    from anki.collection import Collection\n\n# DBValue is actually Union[str, int, float, None], but if defined\n# that way, every call site needs to do a type check prior to using\n# the return values.\nValueFromDB = Any\nRow = Sequence[ValueFromDB]\n\nValueForDB = Union[str, int, float, None]\n\n\nclass DBProxy:\n    # Lifecycle\n    ###############\n\n    def __init__(self, backend: anki._backend.RustBackend) -> None:\n        self._backend = backend\n\n    # Transactions\n    ###############\n\n    def transact(self, op: Callable[[], None]) -> None:\n        \"\"\"Run the provided operation inside a transaction.\n\n        Please note that all backend methods automatically wrap changes in a transaction,\n        so there is no need to use this when calling methods like update_cards(), unless\n        you are making other changes at the same time and want to ensure they are applied\n        completely or not at all.\n\n        If the operation throws an exception, the changes will be automatically rolled\n        back.\n        \"\"\"\n\n        try:\n            self._backend.db_begin()\n            op()\n            self._backend.db_commit()\n        except BaseException as e:\n            self._backend.db_rollback()\n            raise e\n\n    # Querying\n    ################\n\n    def _query(\n        self,\n        sql: str,\n        *args: ValueForDB,\n        first_row_only: bool = False,\n        **kwargs: ValueForDB,\n    ) -> list[Row]:\n        sql, args2 = emulate_named_args(sql, args, kwargs)\n        # fetch rows\n        return self._backend.db_query(sql, args2, first_row_only)\n\n    # Query shortcuts\n    ###################\n\n    def all(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> list[Row]:\n        return self._query(sql, *args, first_row_only=False, **kwargs)\n\n    def list(\n        self, sql: str, *args: ValueForDB, **kwargs: ValueForDB\n    ) -> list[ValueFromDB]:\n        return [x[0] for x in self._query(sql, *args, first_row_only=False, **kwargs)]\n\n    def first(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> Row | None:\n        rows = self._query(sql, *args, first_row_only=True, **kwargs)\n        if rows:\n            return rows[0]\n        else:\n            return None\n\n    def scalar(self, sql: str, *args: ValueForDB, **kwargs: ValueForDB) -> ValueFromDB:\n        rows = self._query(sql, *args, first_row_only=True, **kwargs)\n        if rows:\n            return rows[0][0]\n        else:\n            return None\n\n    # execute used to return a pysqlite cursor, but now is synonymous\n    # with .all()\n    execute = all\n\n    # Updates\n    ################\n\n    def executemany(self, sql: str, args: Iterable[Sequence[ValueForDB]]) -> None:\n        if isinstance(args, list):\n            list_args = args\n        else:\n            list_args = list(args)\n        self._backend.db_execute_many(sql, list_args)\n\n\n# convert kwargs to list format\ndef emulate_named_args(\n    sql: str, args: tuple, kwargs: dict[str, Any]\n) -> tuple[str, Sequence[ValueForDB]]:\n    # nothing to do?\n    if not kwargs:\n        return sql, args\n    print(\"named arguments in queries will go away in the future:\", sql)\n    # map args to numbers\n    arg_num = {}\n    args2 = list(args)\n    for key, val in kwargs.items():\n        args2.append(val)\n        number = len(args2)\n        arg_num[key] = number\n\n    # update refs\n    def repl(match: Match) -> str:\n        arg = match.group(1)\n        return f\"?{arg_num[arg]}\"\n\n    sql = re.sub(\":([a-zA-Z_0-9]+)\", repl, sql)\n    return sql, args2\n"
  },
  {
    "path": "pylib/anki/decks.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport copy\nfrom collections.abc import Iterable, Sequence\nfrom typing import TYPE_CHECKING, Any, NewType\n\nif TYPE_CHECKING:\n    import anki\n\nimport anki.cards\nimport anki.collection\nfrom anki import deck_config_pb2, decks_pb2\nfrom anki._legacy import DeprecatedNamesMixin, deprecated, print_deprecation_warning\nfrom anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId\nfrom anki.consts import *\nfrom anki.errors import NotFoundError\nfrom anki.utils import from_json_bytes, ids2str, int_time, to_json_bytes\n\n# public exports\nDeckTreeNode = decks_pb2.DeckTreeNode\nDeckNameId = decks_pb2.DeckNameId\nFilteredDeckConfig = decks_pb2.Deck.Filtered\nDeckCollapseScope = decks_pb2.SetDeckCollapsedRequest.Scope\nDeckConfigsForUpdate = deck_config_pb2.DeckConfigsForUpdate\nUpdateDeckConfigs = deck_config_pb2.UpdateDeckConfigsRequest\nDeck = decks_pb2.Deck\n\n# type aliases until we can move away from dicts\nDeckDict = dict[str, Any]\nDeckConfigDict = dict[str, Any]\n\nDeckId = NewType(\"DeckId\", int)\nDeckConfigId = NewType(\"DeckConfigId\", int)\n\nDEFAULT_DECK_ID = DeckId(1)\nDEFAULT_DECK_CONF_ID = DeckConfigId(1)\n\n\nclass DecksDictProxy:\n    def __init__(self, col: anki.collection.Collection):\n        self._col = col.weakref()\n\n    def _warn(self) -> None:\n        print_deprecation_warning(\n            \"add-on should use methods on col.decks, not col.decks.decks dict\"\n        )\n\n    def __getitem__(self, item: Any) -> Any:\n        self._warn()\n        return self._col.decks.get(DeckId(int(item)))\n\n    def __setitem__(self, key: Any, val: Any) -> None:\n        self._warn()\n        self._col.decks.save(val)\n\n    def __len__(self) -> int:\n        self._warn()\n        return len(self._col.decks.all_names_and_ids())\n\n    def keys(self) -> Any:\n        self._warn()\n        return [str(nt.id) for nt in self._col.decks.all_names_and_ids()]\n\n    def values(self) -> Any:\n        self._warn()\n        return self._col.decks.all()\n\n    def items(self) -> Any:\n        self._warn()\n        return [(str(nt[\"id\"]), nt) for nt in self._col.decks.all()]\n\n    def __contains__(self, item: Any) -> bool:\n        self._warn()\n        return self._col.decks.have(item)\n\n\nclass DeckManager(DeprecatedNamesMixin):\n    # Registry save/load\n    #############################################################\n\n    def __init__(self, col: anki.collection.Collection) -> None:\n        self.col = col.weakref()\n        self.decks = DecksDictProxy(col)\n\n    def save(self, deck_or_config: DeckDict | DeckConfigDict | None = None) -> None:\n        \"Can be called with either a deck or a deck configuration.\"\n        if not deck_or_config:\n            print(\"col.decks.save() should be passed the changed deck\")\n            return\n\n        # deck conf?\n        if \"maxTaken\" in deck_or_config:\n            self.update_config(deck_or_config)\n            return\n        else:\n            self.update(deck_or_config, preserve_usn=False)\n\n    # Deck save/load\n    #############################################################\n\n    def add_normal_deck_with_name(self, name: str) -> OpChangesWithId:\n        \"If deck exists, return existing id.\"\n        if id := self.col.decks.id_for_name(name):\n            return OpChangesWithId(id=id)\n        else:\n            deck = self.col.decks.new_deck()\n            deck.name = name\n            return self.add_deck(deck)\n\n    def add_deck_legacy(self, deck: DeckDict) -> OpChangesWithId:\n        \"Add a deck created with new_deck_legacy(). Must have id of 0.\"\n        if not deck[\"id\"] == 0:\n            raise Exception(\"id should be 0\")\n        return self.col._backend.add_deck_legacy(to_json_bytes(deck))\n\n    def id(\n        self,\n        name: str,\n        create: bool = True,\n        type: DeckConfigId = DeckConfigId(0),\n    ) -> DeckId | None:\n        \"Add a deck with NAME. Reuse deck if already exists. Return id as int.\"\n        id = self.id_for_name(name)\n        if id:\n            return id\n        elif not create:\n            return None\n\n        deck = self.new_deck_legacy(bool(type))\n        deck[\"name\"] = name\n        out = self.add_deck_legacy(deck)\n        return DeckId(out.id)\n\n    def remove(self, dids: Sequence[DeckId]) -> OpChangesWithCount:\n        return self.col._backend.remove_decks(dids)\n\n    def all_names_and_ids(\n        self, skip_empty_default: bool = False, include_filtered: bool = True\n    ) -> Sequence[DeckNameId]:\n        \"A sorted sequence of deck names and IDs.\"\n        return self.col._backend.get_deck_names(\n            skip_empty_default=skip_empty_default, include_filtered=include_filtered\n        )\n\n    def id_for_name(self, name: str) -> DeckId | None:\n        try:\n            return DeckId(self.col._backend.get_deck_id_by_name(name))\n        except NotFoundError:\n            return None\n\n    def get_legacy(self, did: DeckId) -> DeckDict | None:\n        try:\n            return from_json_bytes(self.col._backend.get_deck_legacy(did))\n        except NotFoundError:\n            return None\n\n    def have(self, id: DeckId) -> bool:\n        return bool(self.get_legacy(id))\n\n    def get_all_legacy(self) -> list[DeckDict]:\n        return list(from_json_bytes(self.col._backend.get_all_decks_legacy()).values())\n\n    def new_deck(self) -> Deck:\n        \"Return a new normal deck. It must be added with .add_deck() after a name assigned.\"\n        return self.col._backend.new_deck()\n\n    def add_deck(self, deck: Deck) -> OpChangesWithId:\n        return self.col._backend.add_deck(message=deck)\n\n    def new_deck_legacy(self, filtered: bool) -> DeckDict:\n        deck = from_json_bytes(self.col._backend.new_deck_legacy(filtered))\n        if deck[\"dyn\"]:\n            # Filtered decks are now created via a scheduler method, but old unit\n            # tests still use this method. Set the default values to what the tests\n            # expect: one empty search term, and ordering by oldest first.\n            del deck[\"terms\"][1]\n            deck[\"terms\"][0][0] = \"\"\n            deck[\"terms\"][0][2] = 0\n\n        return deck\n\n    def deck_tree(self) -> DeckTreeNode:\n        return self.col._backend.deck_tree(now=0)\n\n    @classmethod\n    def find_deck_in_tree(\n        cls, node: DeckTreeNode, deck_id: DeckId\n    ) -> DeckTreeNode | None:\n        if node.deck_id == deck_id:\n            return node\n        for child in node.children:\n            match = cls.find_deck_in_tree(child, deck_id)\n            if match:\n                return match\n        return None\n\n    def all(self) -> list[DeckDict]:\n        \"All decks. Expensive; prefer all_names_and_ids()\"\n        return self.get_all_legacy()\n\n    def set_collapsed(\n        self, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V\n    ) -> OpChanges:\n        return self.col._backend.set_deck_collapsed(\n            deck_id=deck_id, collapsed=collapsed, scope=scope\n        )\n\n    def collapse(self, did: DeckId) -> None:\n        deck = self.get(did)\n        deck[\"collapsed\"] = not deck[\"collapsed\"]\n        self.save(deck)\n\n    def collapse_browser(self, did: DeckId) -> None:\n        deck = self.get(did)\n        collapsed = deck.get(\"browserCollapsed\", False)\n        deck[\"browserCollapsed\"] = not collapsed\n        self.save(deck)\n\n    def count(self) -> int:\n        return len(self.all_names_and_ids())\n\n    def card_count(\n        self, dids: DeckId | Iterable[DeckId], include_subdecks: bool\n    ) -> Any:\n        if isinstance(dids, int):\n            dids = {dids}\n        else:\n            dids = set(dids)\n        if include_subdecks:\n            dids.update([child[1] for did in dids for child in self.children(did)])\n        str_ids = ids2str(dids)\n        count = self.col.db.scalar(\n            f\"select count() from cards where did in {str_ids} or odid in {str_ids}\"\n        )\n        return count\n\n    def get(self, did: DeckId | str, default: bool = True) -> DeckDict | None:\n        if not did:\n            if default:\n                return self.get_legacy(DEFAULT_DECK_ID)\n            else:\n                return None\n        id = DeckId(int(did))\n        deck = self.get_legacy(id)\n        if deck:\n            return deck\n        elif default:\n            return self.get_legacy(DEFAULT_DECK_ID)\n        else:\n            return None\n\n    def by_name(self, name: str) -> DeckDict | None:\n        \"\"\"Get deck with NAME, ignoring case.\"\"\"\n        id = self.id_for_name(name)\n        if id:\n            return self.get_legacy(id)\n        return None\n\n    def update(self, deck: DeckDict, preserve_usn: bool = True) -> None:\n        \"Add or update an existing deck. Used for syncing and merging.\"\n        deck[\"id\"] = self.col._backend.add_or_update_deck_legacy(\n            deck=to_json_bytes(deck), preserve_usn_and_mtime=preserve_usn\n        )\n\n    def update_dict(self, deck: DeckDict) -> OpChanges:\n        return self.col._backend.update_deck_legacy(json=to_json_bytes(deck))\n\n    def rename(self, deck: DeckDict | DeckId, new_name: str) -> OpChanges:\n        \"Rename deck prefix to NAME if not exists. Updates children.\"\n        if isinstance(deck, int):\n            deck_id = deck\n        else:\n            deck_id = deck[\"id\"]\n        return self.col._backend.rename_deck(deck_id=deck_id, new_name=new_name)\n\n    # Drag/drop\n    #############################################################\n\n    def reparent(\n        self, deck_ids: Sequence[DeckId], new_parent: DeckId\n    ) -> OpChangesWithCount:\n        \"\"\"Rename one or more source decks that were dropped on `new_parent`.\n        If new_parent is 0, decks will be placed at the top level.\"\"\"\n        return self.col._backend.reparent_decks(\n            deck_ids=deck_ids, new_parent=new_parent\n        )\n\n    # Deck configurations\n    #############################################################\n\n    def get_deck_configs_for_update(self, deck_id: DeckId) -> DeckConfigsForUpdate:\n        return self.col._backend.get_deck_configs_for_update(deck_id)\n\n    def update_deck_configs(self, input: UpdateDeckConfigs) -> OpChanges:\n        op_bytes = self.col._backend.update_deck_configs_raw(input.SerializeToString())\n        return OpChanges.FromString(op_bytes)\n\n    def all_config(self) -> list[DeckConfigDict]:\n        \"A list of all deck config.\"\n        return list(from_json_bytes(self.col._backend.all_deck_config_legacy()))\n\n    def config_dict_for_deck_id(self, did: DeckId) -> DeckConfigDict:\n        deck = self.get(did, default=False)\n        assert deck\n        if \"conf\" in deck:\n            dcid = DeckConfigId(int(deck[\"conf\"]))  # may be a string\n            conf = self.get_config(dcid)\n            if not conf:\n                # fall back on default\n                conf = self.get_config(DEFAULT_DECK_CONF_ID)\n            conf[\"dyn\"] = False\n            return conf\n        # dynamic decks have embedded conf\n        return deck\n\n    def get_config(self, conf_id: DeckConfigId) -> DeckConfigDict | None:\n        try:\n            return from_json_bytes(self.col._backend.get_deck_config_legacy(conf_id))\n        except NotFoundError:\n            return None\n\n    def update_config(self, conf: DeckConfigDict, preserve_usn: bool = False) -> None:\n        \"preserve_usn is ignored\"\n        conf[\"id\"] = self.col._backend.add_or_update_deck_config_legacy(\n            json=to_json_bytes(conf)\n        )\n\n    def add_config(\n        self, name: str, clone_from: DeckConfigDict | None = None\n    ) -> DeckConfigDict:\n        if clone_from is not None:\n            conf = copy.deepcopy(clone_from)\n            conf[\"id\"] = 0\n        else:\n            conf = from_json_bytes(self.col._backend.new_deck_config_legacy())\n        conf[\"name\"] = name\n        self.update_config(conf)\n        return conf\n\n    def add_config_returning_id(\n        self, name: str, clone_from: DeckConfigDict | None = None\n    ) -> DeckConfigId:\n        return self.add_config(name, clone_from)[\"id\"]\n\n    def remove_config(self, id: DeckConfigId) -> None:\n        \"Remove a configuration and update all decks using it.\"\n        self.col.mod_schema(check=True)\n        for deck in self.all():\n            # ignore cram decks\n            if \"conf\" not in deck:\n                continue\n            if str(deck[\"conf\"]) == str(id):\n                deck[\"conf\"] = 1\n                self.save(deck)\n        self.col._backend.remove_deck_config(id)\n\n    def set_config_id_for_deck_dict(self, deck: DeckDict, id: DeckConfigId) -> None:\n        deck[\"conf\"] = id\n        self.save(deck)\n\n    def decks_using_config(self, conf: DeckConfigDict) -> list[DeckId]:\n        dids = []\n        for deck in self.all():\n            if \"conf\" in deck and deck[\"conf\"] == conf[\"id\"]:\n                dids.append(deck[\"id\"])\n        return dids\n\n    def restore_to_default(self, conf: DeckConfigDict) -> None:\n        old_order = conf[\"new\"][\"order\"]\n        new = from_json_bytes(self.col._backend.new_deck_config_legacy())\n        new[\"id\"] = conf[\"id\"]\n        new[\"name\"] = conf[\"name\"]\n        self.update_config(new)\n        # if it was previously randomized, re-sort\n        if not old_order:\n            self.col.sched.resort_conf(new)\n\n    # Deck utils\n    #############################################################\n\n    def name(self, did: DeckId, default: bool = False) -> str:\n        deck = self.get(did, default=default)\n        if deck:\n            return deck[\"name\"]\n        return self.col.tr.decks_no_deck()\n\n    def name_if_exists(self, did: DeckId) -> str | None:\n        deck = self.get(did, default=False)\n        if deck:\n            return deck[\"name\"]\n        return None\n\n    def cids(self, did: DeckId, children: bool = False) -> list[anki.cards.CardId]:\n        if not children:\n            return self.col.db.list(\"select id from cards where did=?\", did)\n        dids = [did]\n        for name, id in self.children(did):\n            dids.append(id)\n        return self.col.db.list(f\"select id from cards where did in {ids2str(dids)}\")\n\n    def for_card_ids(self, cids: list[anki.cards.CardId]) -> list[DeckId]:\n        return self.col.db.list(f\"select did from cards where id in {ids2str(cids)}\")\n\n    # Deck selection\n    #############################################################\n\n    def set_current(self, deck: DeckId) -> OpChanges:\n        return self.col._backend.set_current_deck(deck)\n\n    def get_current_id(self) -> DeckId:\n        \"The currently selected deck ID.\"\n        return DeckId(self.col._backend.get_current_deck().id)\n\n    def current(self) -> DeckDict:\n        return self.get(self.selected())\n\n    def active(self) -> list[DeckId]:\n        # some add-ons assume this will always be non-empty\n        return self.col.sched.active_decks or [DeckId(1)]\n\n    def select(self, did: DeckId) -> None:\n        # make sure arg is an int; legacy callers may be passing in a string\n        did = DeckId(did)\n        self.set_current(did)\n\n    selected = get_current_id\n\n    # Parents/children\n    #############################################################\n\n    @staticmethod\n    def path(name: str) -> list[str]:\n        return name.split(\"::\")\n\n    @classmethod\n    def basename(cls, name: str) -> str:\n        return cls.path(name)[-1]\n\n    @classmethod\n    def immediate_parent_path(cls, name: str) -> list[str]:\n        return cls.path(name)[:-1]\n\n    @classmethod\n    def immediate_parent(cls, name: str) -> str | None:\n        parent_path = cls.immediate_parent_path(name)\n        if parent_path:\n            return \"::\".join(parent_path)\n        return None\n\n    @classmethod\n    def key(cls, deck: DeckDict) -> list[str]:\n        return cls.path(deck[\"name\"])\n\n    def deck_and_child_name_ids(self, deck_id: DeckId) -> Iterable[tuple[str, DeckId]]:\n        \"\"\"The deck of did and all its children, as (name, id).\"\"\"\n        return (\n            (entry.name, DeckId(entry.id))\n            for entry in self.col._backend.get_deck_and_child_names(deck_id)\n        )\n\n    def children(self, did: DeckId) -> list[tuple[str, DeckId]]:\n        \"All children of did, as (name, id).\"\n        return [\n            name_id\n            for name_id in self.deck_and_child_name_ids(did)\n            if name_id[1] != did\n        ]\n\n    def child_ids(self, parent_name: str) -> Iterable[DeckId]:\n        if not (parent_id := self.id_for_name(parent_name)):\n            return []\n        return (name_id[1] for name_id in self.children(parent_id))\n\n    def deck_and_child_ids(self, deck_id: DeckId) -> list[DeckId]:\n        return [\n            DeckId(entry.id)\n            for entry in self.col._backend.get_deck_and_child_names(deck_id)\n        ]\n\n    def parents(\n        self, did: DeckId, name_map: dict[str, DeckDict] | None = None\n    ) -> list[DeckDict]:\n        \"All parents of did.\"\n        # get parent and grandparent names\n        parents_names: list[str] = []\n        for part in self.immediate_parent_path(self.get(did)[\"name\"]):\n            if not parents_names:\n                parents_names.append(part)\n            else:\n                parents_names.append(f\"{parents_names[-1]}::{part}\")\n        parents: list[DeckDict] = []\n        # convert to objects\n        for parent_name in parents_names:\n            if name_map:\n                deck = name_map[parent_name]\n            else:\n                deck = self.get(self.id(parent_name))\n            parents.append(deck)\n        return parents\n\n    def parents_by_name(self, name: str) -> list[DeckDict]:\n        \"All existing parents of name\"\n        if \"::\" not in name:\n            return []\n        names = self.immediate_parent_path(name)\n        head = []\n        parents: list[DeckDict] = []\n\n        while names:\n            head.append(names.pop(0))\n            deck = self.by_name(\"::\".join(head))\n            if deck:\n                parents.append(deck)\n\n        return parents\n\n    # Filtered decks\n    ##########################################################################\n\n    def new_filtered(self, name: str) -> DeckId:\n        \"For new code, prefer col.sched.get_or_create_filtered_deck().\"\n        did = self.id(name, type=DEFAULT_DECK_CONF_ID)\n        self.select(did)\n        return did\n\n    def is_filtered(self, did: DeckId | str) -> bool:\n        return bool(self.get(did)[\"dyn\"])\n\n    # Legacy\n    #############\n\n    @deprecated(info=\"no longer required\")\n    def flush(self) -> None:\n        pass\n\n    @deprecated(replaced_by=remove)\n    def rem(\n        self,\n        did: DeckId,\n        **legacy_args: bool,\n    ) -> None:\n        \"Remove the deck. If cardsToo, delete any cards inside.\"\n        if isinstance(did, str):\n            did = int(did)\n        self.remove([did])\n\n    @deprecated(replaced_by=all_names_and_ids)\n    def name_map(self) -> dict[str, DeckDict]:\n        return {d[\"name\"]: d for d in self.all()}\n\n    @deprecated(info=\"use col.set_deck() instead\")\n    def set_deck(self, cids: list[anki.cards.CardId], did: DeckId) -> None:\n        self.col.set_deck(card_ids=cids, deck_id=did)\n        self.col.db.execute(\n            f\"update cards set did=?,usn=?,mod=? where id in {ids2str(cids)}\",\n            did,\n            self.col.usn(),\n            int_time(),\n        )\n\n    @deprecated(replaced_by=all_names_and_ids)\n    def all_ids(self) -> list[str]:\n        return [str(x.id) for x in self.all_names_and_ids()]\n\n    @deprecated(replaced_by=all_names_and_ids)\n    def all_names(self, dyn: bool = True, force_default: bool = True) -> list[str]:\n        return [\n            x.name\n            for x in self.all_names_and_ids(\n                skip_empty_default=not force_default, include_filtered=dyn\n            )\n        ]\n\n\nDeckManager.register_deprecated_aliases(\n    confForDid=DeckManager.config_dict_for_deck_id,\n    setConf=DeckManager.set_config_id_for_deck_dict,\n    didsForConf=DeckManager.decks_using_config,\n    allConf=DeckManager.all_config,\n    getConf=DeckManager.get_config,\n    updateConf=DeckManager.update_config,\n    remConf=DeckManager.remove_config,\n    confId=DeckManager.add_config_returning_id,\n    newDyn=DeckManager.new_filtered,\n    isDyn=DeckManager.is_filtered,\n    nameOrNone=DeckManager.name_if_exists,\n)\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name):\n        if name == \"defaultDeck\":\n            print_deprecation_warning(\n                \"defaultDeck is deprecated; call decks.id() without it\"\n            )\n            return 0\n        elif name == \"defaultDynamicDeck\":\n            print_deprecation_warning(\n                \"defaultDynamicDeck is replaced with new_filtered()\"\n            )\n            return 1\n        else:\n            raise AttributeError(f\"module {__name__} has no attribute {name}\")\n"
  },
  {
    "path": "pylib/anki/errors.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom enum import Enum\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    import anki.collection\n\n\nclass AnkiException(Exception):\n    \"\"\"\n    General Anki exception that all custom exceptions raised by Anki should\n    inherit from. Allows add-ons to easily identify Anki-native exceptions.\n\n    When inheriting from a Python built-in exception other than `Exception`,\n    please supply `AnkiException` as an additional inheritance:\n\n    ```\n    class MyNewAnkiException(ValueError, AnkiException):\n        pass\n    ```\n    \"\"\"\n\n\nclass BackendError(AnkiException):\n    \"An error originating from Anki's backend.\"\n\n    def __init__(\n        self,\n        message: str,\n        help_page: anki.collection.HelpPage.V | None,\n        context: str | None,\n        backtrace: str | None,\n    ) -> None:\n        super().__init__()\n        self._message = message\n        self.help_page = help_page\n        self.context = context\n        self.backtrace = backtrace\n\n    def __str__(self) -> str:\n        return self._message\n\n\nclass Interrupted(BackendError):\n    pass\n\n\nclass NetworkError(BackendError):\n    pass\n\n\nclass SyncErrorKind(Enum):\n    AUTH = 1\n    OTHER = 2\n\n\nclass SyncError(BackendError):\n    def __init__(\n        self,\n        message: str,\n        help_page: anki.collection.HelpPage.V | None,\n        context: str | None,\n        backtrace: str | None,\n        kind: SyncErrorKind,\n    ):\n        self.kind = kind\n        super().__init__(message, help_page, context, backtrace)\n\n\nclass BackendIOError(BackendError):\n    pass\n\n\nclass CustomStudyError(BackendError):\n    pass\n\n\nclass DBError(BackendError):\n    pass\n\n\nclass CardTypeError(BackendError):\n    pass\n\n\nclass TemplateError(BackendError):\n    pass\n\n\nclass NotFoundError(BackendError):\n    pass\n\n\nclass DeletedError(BackendError):\n    pass\n\n\nclass ExistsError(BackendError):\n    pass\n\n\nclass UndoEmpty(BackendError):\n    pass\n\n\nclass FilteredDeckError(BackendError):\n    pass\n\n\nclass InvalidInput(BackendError):\n    pass\n\n\nclass SearchError(BackendError):\n    pass\n\n\nclass SchedulerUpgradeRequired(BackendError):\n    pass\n\n\nclass AbortSchemaModification(AnkiException):\n    pass\n\n\n# legacy\nDeckRenameError = FilteredDeckError\nAnkiError = AbortSchemaModification\n"
  },
  {
    "path": "pylib/anki/exporting.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport shutil\nimport threading\nimport time\nimport unicodedata\nimport zipfile\nfrom collections.abc import Sequence\nfrom io import BufferedWriter\nfrom typing import Any\nfrom zipfile import ZipFile\n\nfrom anki import hooks\nfrom anki.cards import CardId\nfrom anki.collection import Collection\nfrom anki.decks import DeckId\nfrom anki.utils import ids2str, namedtmp, split_fields, strip_html\n\n\nclass Exporter:\n    includeHTML: bool | None = None\n    ext: str | None = None\n    includeTags: bool | None = None\n    includeSched: bool | None = None\n    includeMedia: bool | None = None\n\n    def __init__(\n        self,\n        col: Collection,\n        did: DeckId | None = None,\n        cids: list[CardId] | None = None,\n    ) -> None:\n        self.col = col.weakref()\n        self.did = did\n        self.cids = cids\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return \"\"\n\n    def doExport(self, path) -> None:\n        raise Exception(\"not implemented\")\n\n    def exportInto(self, path: str) -> None:\n        self._escapeCount = 0\n        file = open(path, \"wb\")\n        self.doExport(file)\n        file.close()\n\n    def processText(self, text: str) -> str:\n        if self.includeHTML is False:\n            text = self.stripHTML(text)\n\n        text = self.escapeText(text)\n\n        return text\n\n    def escapeText(self, text: str) -> str:\n        \"Escape newlines, tabs, CSS and quotechar.\"\n        # fixme: we should probably quote fields with newlines\n        # instead of converting them to spaces\n        text = text.replace(\"\\n\", \" \")\n        text = text.replace(\"\\r\", \"\")\n        text = text.replace(\"\\t\", \" \" * 8)\n        text = re.sub(\"(?i)<style>.*?</style>\", \"\", text)\n        text = re.sub(r\"\\[\\[type:[^]]+\\]\\]\", \"\", text)\n        if '\"' in text or \"'\" in text:\n            text = '\"' + text.replace('\"', '\"\"') + '\"'\n        return text\n\n    def stripHTML(self, text: str) -> str:\n        # very basic conversion to text\n        s = text\n        s = re.sub(r\"(?i)<(br ?/?|div|p)>\", \" \", s)\n        s = re.sub(r\"\\[sound:[^]]+\\]\", \"\", s)\n        s = strip_html(s)\n        s = re.sub(r\"[ \\n\\t]+\", \" \", s)\n        s = s.strip()\n        return s\n\n    def cardIds(self) -> Any:\n        if self.cids is not None:\n            cids = self.cids\n        elif not self.did:\n            cids = self.col.db.list(\"select id from cards\")\n        else:\n            cids = self.col.decks.cids(self.did, children=True)\n        self.count = len(cids)\n        return cids\n\n\n# Cards as TSV\n######################################################################\n\n\nclass TextCardExporter(Exporter):\n    ext = \".txt\"\n    includeHTML = True\n\n    def __init__(self, col) -> None:\n        Exporter.__init__(self, col)\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return col.tr.exporting_cards_in_plain_text()\n\n    def doExport(self, file) -> None:\n        ids = sorted(self.cardIds())\n        strids = ids2str(ids)\n\n        def esc(s):\n            # strip off the repeated question in answer if exists\n            s = re.sub(\"(?si)^.*<hr id=answer>\\n*\", \"\", s)\n            return self.processText(s)\n\n        out = \"\"\n        for cid in ids:\n            c = self.col.get_card(cid)\n            out += esc(c.question())\n            out += \"\\t\" + esc(c.answer()) + \"\\n\"\n        file.write(out.encode(\"utf-8\"))\n\n\n# Notes as TSV\n######################################################################\n\n\nclass TextNoteExporter(Exporter):\n    ext = \".txt\"\n    includeTags = True\n    includeHTML = True\n\n    def __init__(self, col: Collection) -> None:\n        Exporter.__init__(self, col)\n        self.includeID = False\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return col.tr.exporting_notes_in_plain_text()\n\n    def doExport(self, file: BufferedWriter) -> None:\n        cardIds = self.cardIds()\n        data = []\n        for id, flds, tags in self.col.db.execute(\n            \"\"\"\nselect guid, flds, tags from notes\nwhere id in\n(select nid from cards\nwhere cards.id in %s)\"\"\"\n            % ids2str(cardIds)\n        ):\n            row = []\n            # note id\n            if self.includeID:\n                row.append(str(id))\n            # fields\n            row.extend([self.processText(f) for f in split_fields(flds)])\n            # tags\n            if self.includeTags:\n                row.append(tags.strip())\n            data.append(\"\\t\".join(row))\n        self.count = len(data)\n        out = \"\\n\".join(data)\n        file.write(out.encode(\"utf-8\"))\n\n\n# Anki decks\n######################################################################\n# media files are stored in self.mediaFiles, but not exported.\n\n\nclass AnkiExporter(Exporter):\n    ext = \".anki2\"\n    includeSched: bool | None = False\n    includeMedia = True\n\n    def __init__(self, col: Collection) -> None:\n        Exporter.__init__(self, col)\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return col.tr.exporting_anki_20_deck()\n\n    def deckIds(self) -> list[DeckId]:\n        if self.cids:\n            return self.col.decks.for_card_ids(self.cids)\n        elif self.did:\n            return self.src.decks.deck_and_child_ids(self.did)\n        else:\n            return []\n\n    def exportInto(self, path: str) -> None:\n        # create a new collection at the target\n        try:\n            os.unlink(path)\n        except OSError:\n            pass\n        self.dst = Collection(path)\n        self.src = self.col\n        # find cards\n        cids = self.cardIds()\n        # copy cards, noting used nids\n        nids = {}\n        data: list[Sequence] = []\n        for row in self.src.db.execute(\n            \"select * from cards where id in \" + ids2str(cids)\n        ):\n            # clear flags\n            row = list(row)\n            row[-2] = 0\n            nids[row[1]] = True\n            data.append(row)\n        self.dst.db.executemany(\n            \"insert into cards values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\", data\n        )\n        # notes\n        strnids = ids2str(list(nids.keys()))\n        notedata = []\n        for row in self.src.db.all(\"select * from notes where id in \" + strnids):\n            # remove system tags if not exporting scheduling info\n            if not self.includeSched:\n                row = list(row)\n                row[5] = self.removeSystemTags(row[5])\n            notedata.append(row)\n        self.dst.db.executemany(\n            \"insert into notes values (?,?,?,?,?,?,?,?,?,?,?)\", notedata\n        )\n        # models used by the notes\n        mids = self.dst.db.list(\"select distinct mid from notes where id in \" + strnids)\n        # card history and revlog\n        if self.includeSched:\n            data = self.src.db.all(\"select * from revlog where cid in \" + ids2str(cids))\n            self.dst.db.executemany(\n                \"insert into revlog values (?,?,?,?,?,?,?,?,?)\", data\n            )\n        else:\n            # need to reset card state\n            self.dst.sched.reset_cards(cids)\n        # models - start with zero\n        self.dst.mod_schema(check=False)\n        self.dst.models.remove_all_notetypes()\n        for m in self.src.models.all():\n            if int(m[\"id\"]) in mids:\n                self.dst.models.update(m)\n        # decks\n        dids = self.deckIds()\n        dconfs = {}\n        for d in self.src.decks.all():\n            if str(d[\"id\"]) == \"1\":\n                continue\n            if dids and d[\"id\"] not in dids:\n                continue\n            if not d[\"dyn\"] and d[\"conf\"] != 1:\n                if self.includeSched:\n                    dconfs[d[\"conf\"]] = True\n            if not self.includeSched:\n                # scheduling not included, so reset deck settings to default\n                d = dict(d)\n                d[\"conf\"] = 1\n                d[\"reviewLimit\"] = d[\"newLimit\"] = None\n                d[\"reviewLimitToday\"] = d[\"newLimitToday\"] = None\n            self.dst.decks.update(d)\n        # copy used deck confs\n        for dc in self.src.decks.all_config():\n            if dc[\"id\"] in dconfs:\n                self.dst.decks.update_config(dc)\n        # find used media\n        media = {}\n        self.mediaDir = self.src.media.dir()\n        if self.includeMedia:\n            for row in notedata:\n                flds = row[6]\n                mid = row[2]\n                for file in self.src.media.files_in_str(mid, flds):\n                    # skip files in subdirs\n                    if file != os.path.basename(file):\n                        continue\n                    media[file] = True\n            if self.mediaDir:\n                for fname in os.listdir(self.mediaDir):\n                    path = os.path.join(self.mediaDir, fname)\n                    if os.path.isdir(path):\n                        continue\n                    if fname.startswith(\"_\"):\n                        # Scan all models in mids for reference to fname\n                        for m in self.src.models.all():\n                            if int(m[\"id\"]) in mids:\n                                if self._modelHasMedia(m, fname):\n                                    media[fname] = True\n                                    break\n        self.mediaFiles = list(media.keys())\n        self.dst.crt = self.src.crt\n        # todo: tags?\n        self.count = self.dst.card_count()\n        self.postExport()\n        self.dst.close(downgrade=True)\n\n    def postExport(self) -> None:\n        # overwrite to apply customizations to the deck before it's closed,\n        # such as update the deck description\n        pass\n\n    def removeSystemTags(self, tags: str) -> str:\n        return self.src.tags.rem_from_str(\"marked leech\", tags)\n\n    def _modelHasMedia(self, model, fname) -> bool:\n        # First check the styling\n        if fname in model[\"css\"]:\n            return True\n        # If no reference to fname then check the templates as well\n        for t in model[\"tmpls\"]:\n            if fname in t[\"qfmt\"] or fname in t[\"afmt\"]:\n                return True\n        return False\n\n\n# Packaged Anki decks\n######################################################################\n\n\nclass AnkiPackageExporter(AnkiExporter):\n    ext = \".apkg\"\n\n    def __init__(self, col: Collection) -> None:\n        AnkiExporter.__init__(self, col)\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return col.tr.exporting_anki_deck_package()\n\n    def exportInto(self, path: str) -> None:\n        # open a zip file\n        z = zipfile.ZipFile(\n            path, \"w\", zipfile.ZIP_DEFLATED, allowZip64=True, strict_timestamps=False\n        )\n        media = self.doExport(z, path)\n        # media map\n        z.writestr(\"media\", json.dumps(media))\n        z.close()\n\n    def doExport(self, z: ZipFile, path: str) -> dict[str, str]:  # type: ignore\n        # export into the anki2 file\n        colfile = path.replace(\".apkg\", \".anki2\")\n        AnkiExporter.exportInto(self, colfile)\n        # prevent older clients from accessing\n\n        self._addDummyCollection(z)\n        z.write(colfile, \"collection.anki21\")\n\n        # and media\n        self.prepareMedia()\n        media = self._exportMedia(z, self.mediaFiles, self.mediaDir)\n        # tidy up intermediate files\n        os.unlink(colfile)\n        p = path.replace(\".apkg\", \".media.db2\")\n        if os.path.exists(p):\n            os.unlink(p)\n        shutil.rmtree(path.replace(\".apkg\", \".media\"))\n        return media\n\n    def _exportMedia(self, z: ZipFile, files: list[str], fdir: str) -> dict[str, str]:\n        media = {}\n        for c, file in enumerate(files):\n            cStr = str(c)\n            file = hooks.media_file_filter(file)\n            mpath = os.path.join(fdir, file)\n            if os.path.isdir(mpath):\n                continue\n            if os.path.exists(mpath):\n                if re.search(r\"\\.svg$\", file, re.IGNORECASE):\n                    z.write(mpath, cStr, zipfile.ZIP_DEFLATED)\n                else:\n                    z.write(mpath, cStr, zipfile.ZIP_STORED)\n                media[cStr] = unicodedata.normalize(\"NFC\", file)\n                hooks.media_files_did_export(c)\n\n        return media\n\n    def prepareMedia(self) -> None:\n        # chance to move each file in self.mediaFiles into place before media\n        # is zipped up\n        pass\n\n    # create a dummy collection to ensure older clients don't try to read\n    # data they don't understand\n    def _addDummyCollection(self, zip) -> None:\n        path = namedtmp(\"dummy.anki2\")\n        c = Collection(path)\n        n = c.newNote()\n        n.fields[0] = \"This file requires a newer version of Anki.\"\n        c.addNote(n)\n        c.close(downgrade=True)\n\n        zip.write(path, \"collection.anki2\")\n        os.unlink(path)\n\n\n# Collection package\n######################################################################\n\n\nclass AnkiCollectionPackageExporter(AnkiPackageExporter):\n    ext = \".colpkg\"\n    verbatim = True\n    includeSched = None\n    LEGACY = True\n\n    def __init__(self, col):\n        AnkiPackageExporter.__init__(self, col)\n\n    @staticmethod\n    def key(col: Collection) -> str:\n        return col.tr.exporting_anki_collection_package()\n\n    def exportInto(self, path: str) -> None:\n        \"\"\"Export collection. Caller must re-open afterwards.\"\"\"\n\n        def exporting_media() -> bool:\n            return any(\n                hook.__name__ == \"exported_media\"\n                for hook in hooks.legacy_export_progress._hooks\n            )\n\n        def progress() -> None:\n            while exporting_media():\n                progress = self.col._backend.latest_progress()\n                if progress.HasField(\"exporting\"):\n                    hooks.legacy_export_progress(progress.exporting)\n                time.sleep(0.1)\n\n        threading.Thread(target=progress).start()\n        self.col.export_collection_package(path, self.includeMedia, self.LEGACY)\n\n\nclass AnkiCollectionPackage21bExporter(AnkiCollectionPackageExporter):\n    LEGACY = False\n\n    @staticmethod\n    def key(_col: Collection) -> str:\n        return \"Anki 2.1.50+ Collection Package\"\n\n\n# Export modules\n##########################################################################\n\n\ndef exporters(col: Collection) -> list[tuple[str, Any]]:\n    def id(obj) -> tuple[str, Exporter]:\n        if callable(obj.key):\n            key_str = obj.key(col)\n        else:\n            key_str = obj.key\n        return (f\"{key_str} (*{obj.ext})\", obj)\n\n    exps = [\n        id(AnkiCollectionPackageExporter),\n        id(AnkiCollectionPackage21bExporter),\n        id(AnkiPackageExporter),\n        id(TextNoteExporter),\n        id(TextCardExporter),\n    ]\n    hooks.exporters_list_created(exps)\n    return exps\n"
  },
  {
    "path": "pylib/anki/find.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any\n\nfrom anki.notes import NoteId\n\nif TYPE_CHECKING:\n    from anki.collection import Collection\n\n\nclass Finder:\n    def __init__(self, col: Collection | None) -> None:\n        self.col = col.weakref()\n        print(\"Finder() is deprecated, please use col.find_cards() or .find_notes()\")\n\n    def findCards(self, query: Any, order: Any) -> Any:\n        return self.col.find_cards(query, order)\n\n    def findNotes(self, query: Any) -> Any:\n        return self.col.find_notes(query)\n\n\n# Find and replace\n##########################################################################\n\n\ndef findReplace(\n    col: Collection,\n    nids: list[NoteId],\n    src: str,\n    dst: str,\n    regex: bool = False,\n    field: str | None = None,\n    fold: bool = True,\n) -> int:\n    \"Find and replace fields in a note. Returns changed note count.\"\n    print(\"use col.find_and_replace() instead of findReplace()\")\n    return col.find_and_replace(\n        note_ids=nids,\n        search=src,\n        replacement=dst,\n        regex=regex,\n        match_case=not fold,\n        field_name=field,\n    ).count\n\n\ndef fieldNamesForNotes(col: Collection, nids: list[NoteId]) -> list[str]:\n    return list(col.field_names_for_note_ids(nids))\n\n\n# Find duplicates\n##########################################################################\n\n\ndef fieldNames(col: Collection, downcase: bool = True) -> list[str]:\n    fields: set[str] = set()\n    for m in col.models.all():\n        for f in m[\"flds\"]:\n            name = f[\"name\"].lower() if downcase else f[\"name\"]\n            if name not in fields:  # slower w/o\n                fields.add(name)\n    return list(fields)\n"
  },
  {
    "path": "pylib/anki/foreign_data/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"Helpers for serializing third-party collections to a common JSON form.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import asdict, dataclass, field\nfrom typing import Union\n\nfrom anki.consts import STARTING_FACTOR_FRACTION\nfrom anki.decks import DeckId\nfrom anki.models import NotetypeId\n\n\n@dataclass\nclass ForeignCardType:\n    name: str\n    qfmt: str\n    afmt: str\n\n    @staticmethod\n    def front_back() -> ForeignCardType:\n        return ForeignCardType(\n            \"Card 1\",\n            qfmt=\"{{Front}}\",\n            afmt=\"{{FrontSide}}\\n\\n<hr id=answer>\\n\\n{{Back}}\",\n        )\n\n    @staticmethod\n    def back_front() -> ForeignCardType:\n        return ForeignCardType(\n            \"Card 2\",\n            qfmt=\"{{Back}}\",\n            afmt=\"{{FrontSide}}\\n\\n<hr id=answer>\\n\\n{{Front}}\",\n        )\n\n    @staticmethod\n    def cloze() -> ForeignCardType:\n        return ForeignCardType(\n            \"Cloze\", qfmt=\"{{cloze:Text}}\", afmt=\"{{cloze:Text}}<br>\\n{{Back Extra}}\"\n        )\n\n\n@dataclass\nclass ForeignNotetype:\n    name: str\n    fields: list[str]\n    templates: list[ForeignCardType]\n    is_cloze: bool = False\n\n    @staticmethod\n    def basic(name: str) -> ForeignNotetype:\n        return ForeignNotetype(name, [\"Front\", \"Back\"], [ForeignCardType.front_back()])\n\n    @staticmethod\n    def basic_reverse(name: str) -> ForeignNotetype:\n        return ForeignNotetype(\n            name,\n            [\"Front\", \"Back\"],\n            [ForeignCardType.front_back(), ForeignCardType.back_front()],\n        )\n\n    @staticmethod\n    def cloze(name: str) -> ForeignNotetype:\n        return ForeignNotetype(\n            name, [\"Text\", \"Back Extra\"], [ForeignCardType.cloze()], is_cloze=True\n        )\n\n\n@dataclass\nclass ForeignCard:\n    \"\"\"Data for creating an Anki card.\n\n    Usually a review card, as the default card generation routine will take care\n    of missing new cards.\n\n    due          --  UNIX timestamp\n    interval     --  days\n    ease_factor  --  decimal fraction (2.5 corresponds to default ease)\n    \"\"\"\n\n    # TODO: support new and learning cards?\n    due: int = 0\n    interval: int = 1\n    ease_factor: float = STARTING_FACTOR_FRACTION\n    reps: int = 0\n    lapses: int = 0\n\n\n@dataclass\nclass ForeignNote:\n    fields: list[str] = field(default_factory=list)\n    tags: list[str] = field(default_factory=list)\n    notetype: str | NotetypeId = \"\"\n    deck: str | DeckId = \"\"\n    cards: list[ForeignCard] = field(default_factory=list)\n\n\n@dataclass\nclass ForeignData:\n    notes: list[ForeignNote] = field(default_factory=list)\n    notetypes: list[ForeignNotetype] = field(default_factory=list)\n    default_deck: str | DeckId = \"\"\n\n    def serialize(self) -> str:\n        return json.dumps(self, cls=ForeignDataEncoder, separators=(\",\", \":\"))\n\n\nclass ForeignDataEncoder(json.JSONEncoder):\n    def default(self, obj: object) -> dict:\n        if isinstance(\n            obj,\n            (ForeignData, ForeignNote, ForeignCard, ForeignNotetype, ForeignCardType),\n        ):\n            return asdict(obj)\n        return json.JSONEncoder.default(self, obj)\n"
  },
  {
    "path": "pylib/anki/foreign_data/mnemosyne.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"Serializer for Mnemosyne collections.\n\nSome notes about their structure:\nhttps://github.com/mnemosyne-proj/mnemosyne/blob/master/mnemosyne/libmnemosyne/docs/source/index.rst\n\nAnki      | Mnemosyne\n----------+-----------\nNote      | Fact\nCard Type | Fact View\nCard      | Card\nNotetype  | Card Type\n\"\"\"\n\nimport re\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass, field\n\nfrom anki.db import DB\nfrom anki.decks import DeckId\nfrom anki.foreign_data import (\n    ForeignCard,\n    ForeignCardType,\n    ForeignData,\n    ForeignNote,\n    ForeignNotetype,\n)\n\n\ndef serialize(db_path: str, deck_id: DeckId) -> str:\n    db = open_mnemosyne_db(db_path)\n    return gather_data(db, deck_id).serialize()\n\n\ndef gather_data(db: DB, deck_id: DeckId) -> ForeignData:\n    facts = gather_facts(db)\n    gather_cards_into_facts(db, facts)\n    used_fact_views: dict[type[MnemoFactView], bool] = {}\n    notes = [fact.foreign_note(used_fact_views) for fact in facts.values()]\n    notetypes = [fact_view.foreign_notetype() for fact_view in used_fact_views]\n    return ForeignData(notes, notetypes, deck_id)\n\n\ndef open_mnemosyne_db(db_path: str) -> DB:\n    db = DB(db_path)\n    ver = db.scalar(\"SELECT value FROM global_variables WHERE key='version'\")\n    if not ver.startswith(\"Mnemosyne SQL 1\") and ver not in (\"2\", \"3\"):\n        print(\"Mnemosyne version unknown, trying to import anyway\")\n    return db\n\n\nclass MnemoFactView(ABC):\n    notetype: str\n    field_keys: tuple[str, ...]\n\n    @classmethod\n    @abstractmethod\n    def foreign_notetype(cls) -> ForeignNotetype:\n        pass\n\n\nclass FrontOnly(MnemoFactView):\n    notetype = \"Mnemosyne-FrontOnly\"\n    field_keys = (\"f\", \"b\")\n\n    @classmethod\n    def foreign_notetype(cls) -> ForeignNotetype:\n        return ForeignNotetype.basic(cls.notetype)\n\n\nclass FrontBack(MnemoFactView):\n    notetype = \"Mnemosyne-FrontBack\"\n    field_keys = (\"f\", \"b\")\n\n    @classmethod\n    def foreign_notetype(cls) -> ForeignNotetype:\n        return ForeignNotetype.basic_reverse(cls.notetype)\n\n\nclass Vocabulary(MnemoFactView):\n    notetype = \"Mnemosyne-Vocabulary\"\n    field_keys = (\"f\", \"p_1\", \"m_1\", \"n\")\n\n    @classmethod\n    def foreign_notetype(cls) -> ForeignNotetype:\n        return ForeignNotetype(\n            cls.notetype,\n            [\"Expression\", \"Pronunciation\", \"Meaning\", \"Notes\"],\n            [cls._recognition_card_type(), cls._production_card_type()],\n        )\n\n    @staticmethod\n    def _recognition_card_type() -> ForeignCardType:\n        return ForeignCardType(\n            name=\"Recognition\",\n            qfmt=\"{{Expression}}\",\n            afmt=\"{{Expression}}\\n\\n<hr id=answer>\\n\\n{{{{Pronunciation}}}}\"\n            \"<br>\\n{{{{Meaning}}}}<br>\\n{{{{Notes}}}}\",\n        )\n\n    @staticmethod\n    def _production_card_type() -> ForeignCardType:\n        return ForeignCardType(\n            name=\"Production\",\n            qfmt=\"{{Meaning}}\",\n            afmt=\"{{Meaning}}\\n\\n<hr id=answer>\\n\\n{{{{Expression}}}}\"\n            \"<br>\\n{{{{Pronunciation}}}}<br>\\n{{{{Notes}}}}\",\n        )\n\n\nclass Cloze(MnemoFactView):\n    notetype = \"Mnemosyne-Cloze\"\n    field_keys = (\"text\",)\n\n    @classmethod\n    def foreign_notetype(cls) -> ForeignNotetype:\n        return ForeignNotetype.cloze(cls.notetype)\n\n\n@dataclass\nclass MnemoCard:\n    fact_view_id: str\n    tags: str\n    next_rep: int\n    last_rep: int\n    easiness: float\n    reps: int\n    lapses: int\n\n    def card_ord(self) -> int:\n        ord = self.fact_view_id.rsplit(\".\", maxsplit=1)[-1]\n        try:\n            return int(ord) - 1\n        except ValueError as err:\n            raise Exception(\n                f\"Fact view id '{self.fact_view_id}' has unknown format\"\n            ) from err\n\n    def is_new(self) -> bool:\n        return self.last_rep == -1\n\n    def foreign_card(self) -> ForeignCard:\n        return ForeignCard(\n            ease_factor=self.easiness,\n            reps=self.reps,\n            lapses=self.lapses,\n            interval=self.anki_interval(),\n            due=int(self.next_rep),\n        )\n\n    def anki_interval(self) -> int:\n        return int(max(1, (self.next_rep - self.last_rep) // 86400))\n\n\n@dataclass\nclass MnemoFact:\n    id: int\n    fields: dict[str, str] = field(default_factory=dict)\n    cards: list[MnemoCard] = field(default_factory=list)\n\n    def foreign_note(\n        self, used_fact_views: dict[type[MnemoFactView], bool]\n    ) -> ForeignNote:\n        fact_view = self.fact_view()\n        used_fact_views[fact_view] = True\n        return ForeignNote(\n            fields=self.anki_fields(fact_view),\n            tags=self.anki_tags(),\n            notetype=fact_view.notetype,\n            cards=self.foreign_cards(),\n        )\n\n    def fact_view(self) -> type[MnemoFactView]:\n        try:\n            fact_view = self.cards[0].fact_view_id\n        except IndexError:\n            return FrontOnly\n\n        if fact_view.startswith(\"1.\") or fact_view.startswith(\"1::\"):\n            return FrontOnly\n        elif fact_view.startswith(\"2.\") or fact_view.startswith(\"2::\"):\n            return FrontBack\n        elif fact_view.startswith(\"3.\") or fact_view.startswith(\"3::\"):\n            return Vocabulary\n        elif fact_view.startswith(\"5.1\"):\n            return Cloze\n\n        raise Exception(f\"Fact {self.id} has unknown fact view: {fact_view}\")\n\n    def anki_fields(self, fact_view: type[MnemoFactView]) -> list[str]:\n        return [munge_field(self.fields.get(k, \"\")) for k in fact_view.field_keys]\n\n    def anki_tags(self) -> list[str]:\n        tags: list[str] = []\n        for card in self.cards:\n            if not card.tags:\n                continue\n            tags.extend(\n                t.replace(\" \", \"_\").replace(\"\\u3000\", \"_\")\n                for t in card.tags.split(\", \")\n            )\n        return tags\n\n    def foreign_cards(self) -> list[ForeignCard]:\n        # generate defaults for new cards\n        return [card.foreign_card() for card in self.cards if not card.is_new()]\n\n\ndef munge_field(field: str) -> str:\n    # \\n -> br\n    field = re.sub(\"\\r?\\n\", \"<br>\", field)\n    # latex differences\n    field = re.sub(r\"(?i)<(/?(\\$|\\$\\$|latex))>\", \"[\\\\1]\", field)\n    # audio differences\n    field = re.sub('<audio src=\"(.+?)\">(</audio>)?', \"[sound:\\\\1]\", field)\n    return field\n\n\ndef gather_facts(db: DB) -> dict[int, MnemoFact]:\n    facts: dict[int, MnemoFact] = {}\n    for id, key, value in db.execute(\n        \"\"\"\nSELECT _id, key, value\nFROM facts, data_for_fact\nWHERE facts._id=data_for_fact._fact_id\"\"\"\n    ):\n        if not (fact := facts.get(id)):\n            facts[id] = fact = MnemoFact(id)\n        fact.fields[key] = value\n    return facts\n\n\ndef gather_cards_into_facts(db: DB, facts: dict[int, MnemoFact]) -> None:\n    for fact_id, *row in db.execute(\n        \"\"\"\nSELECT\n    _fact_id,\n    fact_view_id,\n    tags,\n    next_rep,\n    last_rep,\n    easiness,\n    acq_reps + ret_reps,\n    lapses\nFROM cards\"\"\"\n    ):\n        facts[fact_id].cards.append(MnemoCard(*row))\n    for fact in facts.values():\n        fact.cards.sort(key=lambda c: c.card_ord())\n"
  },
  {
    "path": "pylib/anki/hooks.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\n\"\"\"\nTools for extending Anki.\n\nA hook takes a function that does not return a value.\n\nA filter takes a function that returns its first argument, optionally\nmodifying it.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport decorator\n\n# You can find the definitions in ../tools/genhooks.py\nfrom anki.hooks_gen import *\n\n# Legacy hook handling\n##############################################################################\n\n_hooks: dict[str, list[Callable[..., Any]]] = {}\n\n\ndef runHook(hook: str, *args: Any) -> None:\n    \"Run all functions on hook.\"\n    hookFuncs = _hooks.get(hook, None)\n    if hookFuncs:\n        for func in hookFuncs:\n            try:\n                func(*args)\n            except Exception:\n                hookFuncs.remove(func)\n                raise\n\n\ndef runFilter(hook: str, arg: Any, *args: Any) -> Any:\n    hookFuncs = _hooks.get(hook, None)\n    if hookFuncs:\n        for func in hookFuncs:\n            try:\n                arg = func(arg, *args)\n            except Exception:\n                hookFuncs.remove(func)\n                raise\n    return arg\n\n\ndef addHook(hook: str, func: Callable) -> None:\n    \"Add a function to hook. Ignore if already on hook.\"\n    if not _hooks.get(hook, None):\n        _hooks[hook] = []\n    if func not in _hooks[hook]:\n        _hooks[hook].append(func)\n\n\ndef remHook(hook: Any, func: Any) -> None:\n    \"Remove a function if is on hook.\"\n    hook = _hooks.get(hook, [])\n    if func in hook:\n        hook.remove(func)\n\n\n# Monkey patching\n##############################################################################\n# Please only use this for prototyping or for when hooks are not practical,\n# as add-ons that use monkey patching are more likely to break when Anki is\n# updated.\n#\n# If you call wrap() with pos='around', the original function will not be called\n# automatically but can be called with _old().\ndef wrap(old: Any, new: Any, pos: str = \"after\") -> Callable:\n    \"Override an existing function.\"\n\n    def repl(*args: Any, **kwargs: Any) -> Any:\n        if pos == \"after\":\n            old(*args, **kwargs)\n            return new(*args, **kwargs)\n        elif pos == \"before\":\n            new(*args, **kwargs)\n            return old(*args, **kwargs)\n        else:\n            return new(_old=old, *args, **kwargs)\n\n    def decorator_wrapper(f: Any, *args: Any, **kwargs: Any) -> Any:\n        return repl(*args, **kwargs)\n\n    return decorator.decorator(decorator_wrapper)(old)\n"
  },
  {
    "path": "pylib/anki/httpclient.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nWrapper for requests that adds a callback for tracking upload/download progress.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport io\nimport os\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport requests\nfrom requests import Response\n\nfrom anki._legacy import DeprecatedNamesMixin\n\nHTTP_BUF_SIZE = 64 * 1024\n\nProgressCallback = Callable[[int, int], None]\n\n\nclass HttpClient(DeprecatedNamesMixin):\n    verify = True\n    timeout = 60\n    # args are (upload_bytes_in_chunk, download_bytes_in_chunk)\n    progress_hook: ProgressCallback | None = None\n\n    def __init__(self, progress_hook: ProgressCallback | None = None) -> None:\n        self.progress_hook = progress_hook\n        self.session = requests.Session()\n\n    def __enter__(self) -> HttpClient:\n        return self\n\n    def __exit__(self, *args: Any) -> None:\n        self.close()\n\n    def close(self) -> None:\n        if self.session:\n            self.session.close()\n            self.session = None\n\n    def __del__(self) -> None:\n        self.close()\n\n    def post(self, url: str, data: bytes, headers: dict[str, str] | None) -> Response:\n        headers[\"User-Agent\"] = self._agent_name()\n        return self.session.post(\n            url,\n            data=data,\n            headers=headers,\n            stream=True,\n            timeout=self.timeout,\n            verify=self.verify,\n        )  # pytype: disable=wrong-arg-types\n\n    def get(self, url: str, headers: dict[str, str] | None = None) -> Response:\n        if headers is None:\n            headers = {}\n        headers[\"User-Agent\"] = self._agent_name()\n        return self.session.get(\n            url, stream=True, headers=headers, timeout=self.timeout, verify=self.verify\n        )\n\n    def stream_content(self, resp: Response) -> bytes:\n        resp.raise_for_status()\n\n        buf = io.BytesIO()\n        for chunk in resp.iter_content(chunk_size=HTTP_BUF_SIZE):\n            if self.progress_hook:\n                self.progress_hook(0, len(chunk))\n            buf.write(chunk)\n        return buf.getvalue()\n\n    def _agent_name(self) -> str:\n        from anki.buildinfo import version\n\n        return f\"Anki {version}\"\n\n\n# allow user to accept invalid certs in work/school settings\nif os.environ.get(\"ANKI_NOVERIFYSSL\"):\n    HttpClient.verify = False\n\n    import warnings\n\n    warnings.filterwarnings(\"ignore\")\n"
  },
  {
    "path": "pylib/anki/importing/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom collections.abc import Callable, Sequence\nfrom typing import Any, Type, Union\n\nimport anki\nfrom anki.collection import Collection\nfrom anki.importing.anki2 import Anki2Importer\nfrom anki.importing.apkg import AnkiPackageImporter\nfrom anki.importing.base import Importer\nfrom anki.importing.csvfile import TextImporter\nfrom anki.importing.mnemo import MnemosyneImporter\nfrom anki.lang import TR\n\n\ndef importers(col: Collection) -> Sequence[tuple[str, type[Importer]]]:\n    importers = [\n        (col.tr.importing_text_separated_by_tabs_or_semicolons(), TextImporter),\n        (\n            col.tr.importing_packaged_anki_deckcollection_apkg_colpkg_zip(),\n            AnkiPackageImporter,\n        ),\n        (col.tr.importing_mnemosyne_20_deck_db(), MnemosyneImporter),\n    ]\n    anki.hooks.importing_importers(importers)\n    return importers\n"
  },
  {
    "path": "pylib/anki/importing/anki2.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport os\nimport unicodedata\nfrom typing import Any\n\nfrom anki.cards import CardId\nfrom anki.collection import Collection\nfrom anki.consts import *\nfrom anki.decks import DeckId, DeckManager\nfrom anki.importing.base import Importer\nfrom anki.models import NotetypeId\nfrom anki.notes import NoteId\nfrom anki.utils import int_time, join_fields, split_fields, strip_html_media\n\nGUID = 1\nMID = 2\nMOD = 3\n\n\nclass V2ImportIntoV1(Exception):\n    pass\n\n\nclass MediaMapInvalid(Exception):\n    pass\n\n\nclass Anki2Importer(Importer):\n    needMapper = False\n    deckPrefix: str | None = None\n    allowUpdate = True\n    src: Collection\n    dst: Collection\n\n    def __init__(self, col: Collection, file: str) -> None:\n        super().__init__(col, file)\n\n        # set later, defined here for typechecking\n        self._decks: dict[DeckId, DeckId] = {}\n        self.source_needs_upgrade = False\n\n    def run(self, media: None = None, importing_v2: bool = True) -> None:\n        self._importing_v2 = importing_v2\n        self._prepareFiles()\n        if media is not None:\n            # Anki1 importer has provided us with a custom media folder\n            self.src.media._dir = media\n        try:\n            self._import()\n        finally:\n            self.src.close(downgrade=False)\n\n    def _prepareFiles(self) -> None:\n        self.source_needs_upgrade = False\n\n        self.dst = self.col\n        self.src = Collection(self.file)\n\n        if not self._importing_v2:\n            # any scheduling included?\n            if self.src.db.scalar(\"select 1 from cards where queue != 0 limit 1\"):\n                self.source_needs_upgrade = True\n        elif self._importing_v2 and self.col.sched_ver() == 1:\n            raise V2ImportIntoV1()\n\n    def _import(self) -> None:\n        self._decks = {}\n        if self.deckPrefix:\n            id = self.dst.decks.id(self.deckPrefix)\n            self.dst.decks.select(id)\n        self._prepareTS()\n        self._prepareModels()\n        self._importNotes()\n        self._importCards()\n        self._importStaticMedia()\n        self._postImport()\n        self.dst.optimize()\n\n    # Notes\n    ######################################################################\n\n    def _logNoteRow(self, action: str, noteRow: list[str]) -> None:\n        self.log.append(\n            \"[{}] {}\".format(action, strip_html_media(noteRow[6].replace(\"\\x1f\", \", \")))\n        )\n\n    def _importNotes(self) -> None:\n        # build guid -> (id,mod,mid) hash & map of existing note ids\n        self._notes: dict[str, tuple[NoteId, int, NotetypeId]] = {}\n        existing = {}\n        for id, guid, mod, mid in self.dst.db.execute(\n            \"select id, guid, mod, mid from notes\"\n        ):\n            self._notes[guid] = (id, mod, mid)\n            existing[id] = True\n        # we ignore updates to changed schemas. we need to note the ignored\n        # guids, so we avoid importing invalid cards\n        self._ignoredGuids: dict[str, bool] = {}\n        # iterate over source collection\n        add = []\n        update = []\n        dirty = []\n        usn = self.dst.usn()\n        dupesIdentical = []\n        dupesIgnored = []\n        total = 0\n        for note in self.src.db.execute(\"select * from notes\"):\n            total += 1\n            # turn the db result into a mutable list\n            note = list(note)\n            shouldAdd = self._uniquifyNote(note)\n            if shouldAdd:\n                # ensure id is unique\n                while note[0] in existing:\n                    note[0] += 999\n                existing[note[0]] = True\n                # bump usn\n                note[4] = usn\n                # update media references in case of dupes\n                note[6] = self._mungeMedia(note[MID], note[6])\n                add.append(note)\n                dirty.append(note[0])\n                # note we have the added the guid\n                self._notes[note[GUID]] = (note[0], note[3], note[MID])\n            else:\n                # a duplicate or changed schema - safe to update?\n                if self.allowUpdate:\n                    oldNid, oldMod, oldMid = self._notes[note[GUID]]\n                    # will update if incoming note more recent\n                    if oldMod < note[MOD]:\n                        # safe if note types identical\n                        if oldMid == note[MID]:\n                            # incoming note should use existing id\n                            note[0] = oldNid\n                            note[4] = usn\n                            note[6] = self._mungeMedia(note[MID], note[6])\n                            update.append(note)\n                            dirty.append(note[0])\n                        else:\n                            dupesIgnored.append(note)\n                            self._ignoredGuids[note[GUID]] = True\n                    else:\n                        dupesIdentical.append(note)\n\n        self.log.append(self.dst.tr.importing_notes_found_in_file(val=total))\n\n        if dupesIgnored:\n            self.log.append(\n                self.dst.tr.importing_notes_skipped_update_due_to_notetype(\n                    val=len(dupesIgnored)\n                )\n            )\n        if update:\n            self.log.append(\n                self.dst.tr.importing_notes_updated_as_file_had_newer(val=len(update))\n            )\n        if add:\n            self.log.append(self.dst.tr.importing_notes_added_from_file(val=len(add)))\n        if dupesIdentical:\n            self.log.append(\n                self.dst.tr.importing_notes_skipped_as_theyre_already_in(\n                    val=len(dupesIdentical),\n                )\n            )\n\n        self.log.append(\"\")\n\n        if dupesIgnored:\n            for row in dupesIgnored:\n                self._logNoteRow(self.dst.tr.importing_skipped(), row)\n        if update:\n            for row in update:\n                self._logNoteRow(self.dst.tr.importing_updated(), row)\n        if add:\n            for row in add:\n                self._logNoteRow(self.dst.tr.importing_added(), row)\n        if dupesIdentical:\n            for row in dupesIdentical:\n                self._logNoteRow(self.dst.tr.importing_identical(), row)\n\n        # export info for calling code\n        self.dupes = len(dupesIdentical)\n        self.added = len(add)\n        self.updated = len(update)\n        # add to col\n        self.dst.db.executemany(\n            \"insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)\", add\n        )\n        self.dst.db.executemany(\n            \"insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)\", update\n        )\n        self.dst.after_note_updates(dirty, mark_modified=False, generate_cards=False)\n\n    # determine if note is a duplicate, and adjust mid and/or guid as required\n    # returns true if note should be added\n    def _uniquifyNote(self, note: list[Any]) -> bool:\n        origGuid = note[GUID]\n        srcMid = note[MID]\n        dstMid = self._mid(srcMid)\n        # duplicate schemas?\n        if srcMid == dstMid:\n            return origGuid not in self._notes\n        # differing schemas and note doesn't exist?\n        note[MID] = dstMid\n        if origGuid not in self._notes:\n            return True\n        # schema changed; don't import\n        self._ignoredGuids[origGuid] = True\n        return False\n\n    # Models\n    ######################################################################\n    # Models in the two decks may share an ID but not a schema, so we need to\n    # compare the field & template signature rather than just rely on ID. If\n    # the schemas don't match, we increment the mid and try again, creating a\n    # new model if necessary.\n\n    def _prepareModels(self) -> None:\n        \"Prepare index of schema hashes.\"\n        self._modelMap: dict[NotetypeId, NotetypeId] = {}\n\n    def _mid(self, srcMid: NotetypeId) -> Any:\n        \"Return local id for remote MID.\"\n        # already processed this mid?\n        if srcMid in self._modelMap:\n            return self._modelMap[srcMid]\n        mid = srcMid\n        srcModel = self.src.models.get(srcMid)\n        srcScm = self.src.models.scmhash(srcModel)\n        while True:\n            # missing from target col?\n            if not self.dst.models.have(mid):\n                # copy it over\n                model = srcModel.copy()\n                model[\"id\"] = mid\n                model[\"usn\"] = self.col.usn()\n                self.dst.models.update(model, skip_checks=True)\n                break\n            # there's an existing model; do the schemas match?\n            dstModel = self.dst.models.get(mid)\n            dstScm = self.dst.models.scmhash(dstModel)\n            if srcScm == dstScm:\n                # copy styling changes over if newer\n                if srcModel[\"mod\"] > dstModel[\"mod\"]:\n                    model = srcModel.copy()\n                    model[\"id\"] = mid\n                    model[\"usn\"] = self.col.usn()\n                    self.dst.models.update(model, skip_checks=True)\n                break\n            # as they don't match, try next id\n            mid = NotetypeId(mid + 1)\n        # save map and return new mid\n        self._modelMap[srcMid] = mid\n        return mid\n\n    # Decks\n    ######################################################################\n\n    def _did(self, did: DeckId) -> Any:\n        \"Given did in src col, return local id.\"\n        # already converted?\n        if did in self._decks:\n            return self._decks[did]\n        # get the name in src\n        g = self.src.decks.get(did)\n        name = g[\"name\"]\n        # if there's a prefix, replace the top level deck\n        if self.deckPrefix:\n            tmpname = \"::\".join(DeckManager.path(name)[1:])\n            name = self.deckPrefix\n            if tmpname:\n                name += f\"::{tmpname}\"\n        # manually create any parents so we can pull in descriptions\n        head = \"\"\n        for parent in DeckManager.immediate_parent_path(name):\n            if head:\n                head += \"::\"\n            head += parent\n            idInSrc = self.src.decks.id(head)\n            self._did(idInSrc)\n        # if target is a filtered deck, we'll need a new deck name\n        deck = self.dst.decks.by_name(name)\n        if deck and deck[\"dyn\"]:\n            name = \"%s %d\" % (name, int_time())\n        # create in local\n        newid = self.dst.decks.id(name)\n        # pull conf over\n        if \"conf\" in g and g[\"conf\"] != 1:\n            conf = self.src.decks.get_config(g[\"conf\"])\n            self.dst.decks.save(conf)\n            self.dst.decks.update_config(conf)\n            g2 = self.dst.decks.get(newid)\n            g2[\"conf\"] = g[\"conf\"]\n            self.dst.decks.save(g2)\n        # save desc\n        deck = self.dst.decks.get(newid)\n        deck[\"desc\"] = g[\"desc\"]\n        self.dst.decks.save(deck)\n        # add to deck map and return\n        self._decks[did] = newid\n        return newid\n\n    # Cards\n    ######################################################################\n\n    def _importCards(self) -> None:\n        if self.source_needs_upgrade:\n            self.src.upgrade_to_v2_scheduler()\n        # build map of (guid, ord) -> cid and used id cache\n        self._cards: dict[tuple[str, int], CardId] = {}\n        existing = {}\n        for guid, ord, cid in self.dst.db.execute(\n            \"select f.guid, c.ord, c.id from cards c, notes f where c.nid = f.id\"\n        ):\n            existing[cid] = True\n            self._cards[(guid, ord)] = cid\n        # loop through src\n        cards = []\n        revlog = []\n        cnt = 0\n        usn = self.dst.usn()\n        aheadBy = self.src.sched.today - self.dst.sched.today\n        for card in self.src.db.execute(\n            \"select f.guid, f.mid, c.* from cards c, notes f where c.nid = f.id\"\n        ):\n            guid = card[0]\n            if guid in self._ignoredGuids:\n                continue\n            # does the card's note exist in dst col?\n            if guid not in self._notes:\n                continue\n            # does the card already exist in the dst col?\n            ord = card[5]\n            if (guid, ord) in self._cards:\n                # fixme: in future, could update if newer mod time\n                continue\n            # doesn't exist. strip off note info, and save src id for later\n            card = list(card[2:])\n            scid = card[0]\n            # ensure the card id is unique\n            while card[0] in existing:\n                card[0] += 999\n            existing[card[0]] = True\n            # update cid, nid, etc\n            card[1] = self._notes[guid][0]\n            card[2] = self._did(card[2])\n            card[4] = int_time()\n            card[5] = usn\n            # review cards have a due date relative to collection\n            if (\n                card[7] in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN)\n                or card[6] == CARD_TYPE_REV\n            ):\n                card[8] -= aheadBy\n            # odue needs updating too\n            if card[14]:\n                card[14] -= aheadBy\n            # if odid true, convert card from filtered to normal\n            if card[15]:\n                # odid\n                card[15] = 0\n                # odue\n                card[8] = card[14]\n                card[14] = 0\n                # queue\n                if card[6] == CARD_TYPE_LRN:  # type\n                    card[7] = QUEUE_TYPE_NEW\n                else:\n                    card[7] = card[6]\n                # type\n                if card[6] == CARD_TYPE_LRN:\n                    card[6] = CARD_TYPE_NEW\n            cards.append(card)\n            # we need to import revlog, rewriting card ids and bumping usn\n            for rev in self.src.db.execute(\"select * from revlog where cid = ?\", scid):\n                rev = list(rev)\n                rev[1] = card[0]\n                rev[2] = self.dst.usn()\n                revlog.append(rev)\n            cnt += 1\n        # apply\n        self.dst.db.executemany(\n            \"\"\"\ninsert or ignore into cards values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\"\"\",\n            cards,\n        )\n        self.dst.db.executemany(\n            \"\"\"\ninsert or ignore into revlog values (?,?,?,?,?,?,?,?,?)\"\"\",\n            revlog,\n        )\n\n    # Media\n    ######################################################################\n\n    # note: this func only applies to imports of .anki2. for .apkg files, the\n    # apkg importer does the copying\n    def _importStaticMedia(self) -> None:\n        # Import any '_foo' prefixed media files regardless of whether\n        # they're used on notes or not\n        dir = self.src.media.dir()\n        if not os.path.exists(dir):\n            return\n        for fname in os.listdir(dir):\n            if fname.startswith(\"_\") and not self.dst.media.have(fname):\n                self._writeDstMedia(fname, self._srcMediaData(fname))\n\n    def _mediaData(self, fname: str, dir: str | None = None) -> bytes:\n        if not dir:\n            dir = self.src.media.dir()\n        path = os.path.join(dir, fname)\n        try:\n            with open(path, \"rb\") as f:\n                return f.read()\n        except OSError:\n            return b\"\"\n\n    def _srcMediaData(self, fname: str) -> bytes:\n        \"Data for FNAME in src collection.\"\n        return self._mediaData(fname, self.src.media.dir())\n\n    def _dstMediaData(self, fname: str) -> bytes:\n        \"Data for FNAME in dst collection.\"\n        return self._mediaData(fname, self.dst.media.dir())\n\n    def _writeDstMedia(self, fname: str, data: bytes) -> None:\n        path = os.path.join(self.dst.media.dir(), unicodedata.normalize(\"NFC\", fname))\n        try:\n            with open(path, \"wb\") as f:\n                f.write(data)\n        except OSError:\n            # the user likely used subdirectories\n            pass\n\n    def _mungeMedia(self, mid: NotetypeId, fieldsStr: str) -> str:\n        fields = split_fields(fieldsStr)\n\n        def repl(match):\n            fname = match.group(\"fname\")\n            srcData = self._srcMediaData(fname)\n            dstData = self._dstMediaData(fname)\n            if not srcData:\n                # file was not in source, ignore\n                return match.group(0)\n            # if model-local file exists from a previous import, use that\n            name, ext = os.path.splitext(fname)\n            lname = f\"{name}_{mid}{ext}\"\n            if self.dst.media.have(lname):\n                return match.group(0).replace(fname, lname)\n            # if missing or the same, pass unmodified\n            elif not dstData or srcData == dstData:\n                # need to copy?\n                if not dstData:\n                    self._writeDstMedia(fname, srcData)\n                return match.group(0)\n            # exists but does not match, so we need to dedupe\n            self._writeDstMedia(lname, srcData)\n            return match.group(0).replace(fname, lname)\n\n        for idx, field in enumerate(fields):\n            fields[idx] = self.dst.media.transform_names(field, repl)\n        return join_fields(fields)\n\n    # Post-import cleanup\n    ######################################################################\n\n    def _postImport(self) -> None:\n        for did in list(self._decks.values()):\n            self.col.sched.maybe_randomize_deck(did)\n        # make sure new position is correct\n        self.dst.conf[\"nextPos\"] = (\n            self.dst.db.scalar(\"select max(due)+1 from cards where type = 0\") or 0\n        )\n        self.dst.save()\n"
  },
  {
    "path": "pylib/anki/importing/apkg.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport unicodedata\nimport zipfile\nfrom typing import Any\n\nfrom anki.importing.anki2 import Anki2Importer, MediaMapInvalid\nfrom anki.utils import tmpfile\n\n\nclass AnkiPackageImporter(Anki2Importer):\n    nameToNum: dict[str, str]\n    zip: zipfile.ZipFile | None\n\n    def run(self) -> None:  # type: ignore\n        # extract the deck from the zip file\n        self.zip = z = zipfile.ZipFile(self.file)\n        # v2 scheduler?\n        try:\n            z.getinfo(\"collection.anki21\")\n            suffix = \".anki21\"\n        except KeyError:\n            suffix = \".anki2\"\n\n        col = z.read(f\"collection{suffix}\")\n        colpath = tmpfile(suffix=\".anki2\")\n        with open(colpath, \"wb\") as f:\n            f.write(col)\n        self.file = colpath\n        # we need the media dict in advance, and we'll need a map of fname ->\n        # number to use during the import\n        self.nameToNum = {}\n        dir = self.col.media.dir()\n        try:\n            media_dict = json.loads(z.read(\"media\").decode(\"utf8\"))\n        except Exception as exc:\n            raise MediaMapInvalid() from exc\n        for k, v in list(media_dict.items()):\n            path = os.path.abspath(os.path.join(dir, v))\n            if os.path.commonprefix([path, dir]) != dir:\n                raise Exception(\"Invalid file\")\n\n            self.nameToNum[unicodedata.normalize(\"NFC\", v)] = k\n        # run anki2 importer\n        Anki2Importer.run(self, importing_v2=suffix == \".anki21\")\n        # import static media\n        for file, c in list(self.nameToNum.items()):\n            if not file.startswith(\"_\") and not file.startswith(\"latex-\"):\n                continue\n            path = os.path.join(self.col.media.dir(), file)\n            if not os.path.exists(path):\n                with open(path, \"wb\") as f:\n                    f.write(z.read(c))\n\n    def _srcMediaData(self, fname: str) -> Any:\n        if fname in self.nameToNum:\n            return self.zip.read(\n                self.nameToNum[fname]\n            )  # pytype: disable=attribute-error\n        return None\n"
  },
  {
    "path": "pylib/anki/importing/base.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nfrom anki.collection import Collection\nfrom anki.utils import max_id\n\n# Base importer\n##########################################################################\n\n\nclass Importer:\n    needMapper = False\n    needDelimiter = False\n    dst: Collection | None\n\n    def __init__(self, col: Collection, file: str) -> None:\n        self.file = file\n        self.log: list[str] = []\n        self.col = col.weakref()\n        self.total = 0\n        self.dst = None\n\n    def run(self) -> None:\n        pass\n\n    def open(self) -> None:\n        \"Open file and ensure it's in the right format.\"\n        return\n\n    def close(self) -> None:\n        \"Closes the open file.\"\n        return\n\n    # Timestamps\n    ######################################################################\n    # It's too inefficient to check for existing ids on every object,\n    # and a previous import may have created timestamps in the future, so we\n    # need to make sure our starting point is safe.\n\n    def _prepareTS(self) -> None:\n        self._ts = max_id(self.dst.db)\n\n    def ts(self) -> Any:\n        self._ts += 1\n        return self._ts\n"
  },
  {
    "path": "pylib/anki/importing/csvfile.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport csv\nimport re\nfrom typing import Any, TextIO\n\nfrom anki.collection import Collection\nfrom anki.importing.noteimp import ForeignNote, NoteImporter\n\n\nclass TextImporter(NoteImporter):\n    needDelimiter = True\n    patterns = \"\\t|,;:\"\n\n    def __init__(self, col: Collection, file: str) -> None:\n        NoteImporter.__init__(self, col, file)\n        self.lines = None\n        self.fileobj: TextIO | None = None\n        self.delimiter: str | None = None\n        self.tagsToAdd: list[str] = []\n        self.numFields = 0\n        self.dialect: Any | None\n        self.data: str | list[str] | None\n\n    def foreignNotes(self) -> list[ForeignNote]:\n        self.open()\n        # process all lines\n        log = []\n        notes = []\n        lineNum = 0\n        ignored = 0\n        if self.delimiter:\n            reader = csv.reader(self.data, delimiter=self.delimiter, doublequote=True)\n        else:\n            reader = csv.reader(self.data, self.dialect, doublequote=True)\n        try:\n            for row in reader:\n                if len(row) != self.numFields:\n                    if row:\n                        log.append(\n                            self.col.tr.importing_rows_had_num1d_fields_expected_num2d(\n                                row=\" \".join(row),\n                                found=len(row),\n                                expected=self.numFields,\n                            )\n                        )\n                        ignored += 1\n                    continue\n                note = self.noteFromFields(row)\n                notes.append(note)\n        except csv.Error as e:\n            log.append(self.col.tr.importing_aborted(val=str(e)))\n        self.log = log\n        self.ignored = ignored\n        self.close()\n        return notes\n\n    def open(self) -> None:\n        \"Parse the top line and determine the pattern and number of fields.\"\n        # load & look for the right pattern\n        self.cacheFile()\n\n    def cacheFile(self) -> None:\n        \"Read file into self.lines if not already there.\"\n        if not self.fileobj:\n            self.openFile()\n\n    def openFile(self) -> None:\n        self.dialect = None\n        self.fileobj = open(self.file, encoding=\"utf-8-sig\")\n        self.data = self.fileobj.read()\n\n        def sub(s):\n            return re.sub(r\"^\\#.*$\", \"__comment\", s)\n\n        self.data = [\n            f\"{sub(x)}\\n\" for x in self.data.split(\"\\n\") if sub(x) != \"__comment\"\n        ]\n        if self.data:\n            if self.data[0].startswith(\"tags:\"):\n                tags = str(self.data[0][5:]).strip()\n                self.tagsToAdd = tags.split(\" \")\n                del self.data[0]\n            self.updateDelimiter()\n        if not self.dialect and not self.delimiter:\n            raise Exception(\"unknownFormat\")\n\n    def updateDelimiter(self) -> None:\n        def err():\n            raise Exception(\"unknownFormat\")\n\n        self.dialect = None\n        sniffer = csv.Sniffer()\n        if not self.delimiter:\n            try:\n                self.dialect = sniffer.sniff(\"\\n\".join(self.data[:10]), self.patterns)\n            except Exception:\n                try:\n                    self.dialect = sniffer.sniff(self.data[0], self.patterns)\n                except Exception:\n                    pass\n        if self.dialect:\n            try:\n                reader = csv.reader(self.data, self.dialect, doublequote=True)\n            except Exception:\n                err()\n        else:\n            if not self.delimiter:\n                if \"\\t\" in self.data[0]:\n                    self.delimiter = \"\\t\"\n                elif \";\" in self.data[0]:\n                    self.delimiter = \";\"\n                elif \",\" in self.data[0]:\n                    self.delimiter = \",\"\n                else:\n                    self.delimiter = \" \"\n            reader = csv.reader(self.data, delimiter=self.delimiter, doublequote=True)\n        try:\n            while True:\n                row = next(reader)\n                if row:\n                    self.numFields = len(row)\n                    break\n        except Exception:\n            err()\n        self.initMapping()\n\n    def fields(self) -> int:\n        \"Number of fields.\"\n        self.open()\n        return self.numFields\n\n    def close(self):\n        if self.fileobj:\n            self.fileobj.close()\n            self.fileobj = None\n\n    def __del__(self):\n        self.close()\n        zuper = super()\n        if hasattr(zuper, \"__del__\"):\n            zuper.__del__(self)  # type: ignore\n\n    def noteFromFields(self, fields: list[str]) -> ForeignNote:\n        note = ForeignNote()\n        note.fields.extend([x for x in fields])\n        note.tags.extend(self.tagsToAdd)\n        return note\n"
  },
  {
    "path": "pylib/anki/importing/mnemo.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nimport re\nimport time\nfrom typing import cast\n\nfrom anki.db import DB\nfrom anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter\nfrom anki.stdmodels import _legacy_add_basic_model, _legacy_add_cloze_model\n\n\nclass MnemosyneImporter(NoteImporter):\n    needMapper = False\n    update = False\n    allowHTML = True\n\n    def run(self):\n        db = DB(self.file)\n        ver = db.scalar(\"select value from global_variables where key='version'\")\n        if not ver.startswith(\"Mnemosyne SQL 1\") and ver not in (\"2\", \"3\"):\n            self.log.append(\n                self.col.tr.importing_file_version_unknown_trying_import_anyway()\n            )\n        # gather facts into temp objects\n        curid = None\n        notes = {}\n        note = None\n        for _id, id, k, v in db.execute(\n            \"\"\"\nselect _id, id, key, value from facts f, data_for_fact d where\nf._id=d._fact_id\"\"\"\n        ):\n            if id != curid:\n                if note:\n                    notes[note[\"_id\"]] = note\n                note = {\"_id\": _id}\n                curid = id\n            assert note\n            note[k] = v\n        if note:\n            notes[note[\"_id\"]] = note\n        # gather cards\n        front = []\n        frontback = []\n        vocabulary = []\n        cloze = {}\n        for row in db.execute(\n            \"\"\"\nselect _fact_id, fact_view_id, tags, next_rep, last_rep, easiness,\nacq_reps+ret_reps, lapses, card_type_id from cards\"\"\"\n        ):\n            # categorize note\n            note = notes[row[0]]\n            if row[1].endswith(\".1\"):\n                if row[1].startswith(\"1.\") or row[1].startswith(\"1::\"):\n                    front.append(note)\n                elif row[1].startswith(\"2.\") or row[1].startswith(\"2::\"):\n                    frontback.append(note)\n                elif row[1].startswith(\"3.\") or row[1].startswith(\"3::\"):\n                    vocabulary.append(note)\n                elif row[1].startswith(\"5.1\"):\n                    cloze[row[0]] = note\n            # check for None to fix issue where import can error out\n            rawTags = row[2]\n            if rawTags is None:\n                rawTags = \"\"\n            # merge tags into note\n            tags = rawTags.replace(\", \", \"\\x1f\").replace(\" \", \"_\")\n            tags = tags.replace(\"\\x1f\", \" \")\n            if \"tags\" not in note:\n                note[\"tags\"] = []\n            note[\"tags\"] += self.col.tags.split(tags)\n            # if it's a new card we can go with the defaults\n            if row[3] == -1:\n                continue\n            # add the card\n            c = ForeignCard()\n            c.factor = int(row[5] * 1000)\n            c.reps = row[6]\n            c.lapses = row[7]\n            # ivl is inferred in mnemosyne\n            next, prev = row[3:5]\n            c.ivl = max(1, (next - prev) // 86400)\n            # work out how long we've got left\n            rem = int((next - time.time()) / 86400)\n            c.due = self.col.sched.today + rem\n            # get ord\n            m = re.search(r\".(\\d+)$\", row[1])\n            assert m\n            ord = int(m.group(1)) - 1\n            if \"cards\" not in note:\n                note[\"cards\"] = {}\n            note[\"cards\"][ord] = c\n        self._addFronts(front)\n        total = self.total\n        self._addFrontBacks(frontback)\n        total += self.total\n        self._addVocabulary(vocabulary)\n        self.total += total\n        self._addCloze(cloze)\n        self.total += total\n        self.log.append(self.col.tr.importing_note_imported(count=self.total))\n\n    def fields(self):\n        return self._fields\n\n    def _mungeField(self, fld):\n        # \\n -> br\n        fld = re.sub(\"\\r?\\n\", \"<br>\", fld)\n        # latex differences\n        fld = re.sub(r\"(?i)<(/?(\\$|\\$\\$|latex))>\", \"[\\\\1]\", fld)\n        # audio differences\n        fld = re.sub('<audio src=\"(.+?)\">(</audio>)?', \"[sound:\\\\1]\", fld)\n        return fld\n\n    def _addFronts(self, notes, model=None, fields=(\"f\", \"b\")):\n        data = []\n        for orig in notes:\n            # create a foreign note object\n            n = ForeignNote()\n            n.fields = []\n            for f in fields:\n                fld = self._mungeField(orig.get(f, \"\"))\n                n.fields.append(fld)\n            n.tags = orig[\"tags\"]\n            n.cards = orig.get(\"cards\", {})\n            data.append(n)\n        # add a basic model\n        if not model:\n            model = _legacy_add_basic_model(self.col)\n            model[\"name\"] = \"Mnemosyne-FrontOnly\"\n        mm = self.col.models\n        mm.save(model)\n        mm.set_current(model)\n        self.model = model\n        self._fields = len(model[\"flds\"])\n        self.initMapping()\n        # import\n        self.importNotes(data)\n\n    def _addFrontBacks(self, notes):\n        m = _legacy_add_basic_model(self.col)\n        m[\"name\"] = \"Mnemosyne-FrontBack\"\n        mm = self.col.models\n        t = mm.new_template(\"Back\")\n        t[\"qfmt\"] = \"{{Back}}\"\n        t[\"afmt\"] = f\"{t['qfmt']}\\n\\n<hr id=answer>\\n\\n{{{{Front}}}}\"  # type: ignore\n        mm.add_template(m, t)\n        self._addFronts(notes, m)\n\n    def _addVocabulary(self, notes):\n        mm = self.col.models\n        m = mm.new(\"Mnemosyne-Vocabulary\")\n        for f in \"Expression\", \"Pronunciation\", \"Meaning\", \"Notes\":\n            fm = mm.new_field(f)\n            mm.addField(m, fm)\n        t = mm.new_template(\"Recognition\")\n        t[\"qfmt\"] = \"{{Expression}}\"\n        t[\"afmt\"] = (\n            f\"{cast(str, t['qfmt'])}\\n\\n<hr id=answer>\\n\\n{{{{Pronunciation}}}}<br>\\n{{{{Meaning}}}}<br>\\n{{{{Notes}}}}\"\n        )\n        mm.add_template(m, t)\n        t = mm.new_template(\"Production\")\n        t[\"qfmt\"] = \"{{Meaning}}\"\n        t[\"afmt\"] = (\n            f\"{cast(str, t['qfmt'])}\\n\\n<hr id=answer>\\n\\n{{{{Expression}}}}<br>\\n{{{{Pronunciation}}}}<br>\\n{{{{Notes}}}}\"\n        )\n        mm.add_template(m, t)\n        mm.add(m)\n        self._addFronts(notes, m, fields=(\"f\", \"p_1\", \"m_1\", \"n\"))\n\n    def _addCloze(self, notes):\n        data = []\n        notes = list(notes.values())\n        for orig in notes:\n            # create a foreign note object\n            n = ForeignNote()\n            n.fields = []\n            fld = orig.get(\"text\", \"\")\n            fld = re.sub(\"\\r?\\n\", \"<br>\", fld)\n            state = dict(n=1)\n\n            def repl(match):\n                # replace [...] with cloze refs\n                res = \"{{c%d::%s}}\" % (state[\"n\"], match.group(1))\n                state[\"n\"] += 1\n                return res\n\n            fld = re.sub(r\"\\[(.+?)\\]\", repl, fld)\n            fld = self._mungeField(fld)\n            n.fields.append(fld)\n            n.fields.append(\"\")  # extra\n            n.tags = orig[\"tags\"]\n            n.cards = orig.get(\"cards\", {})\n            data.append(n)\n        # add cloze model\n        model = _legacy_add_cloze_model(self.col)\n        model[\"name\"] = \"Mnemosyne-Cloze\"\n        mm = self.col.models\n        mm.save(model)\n        mm.set_current(model)\n        self.model = model\n        self._fields = len(model[\"flds\"])\n        self.initMapping()\n        self.importNotes(data)\n"
  },
  {
    "path": "pylib/anki/importing/noteimp.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport html\nimport unicodedata\nfrom typing import Union\n\nfrom anki.collection import Collection\nfrom anki.config import Config\nfrom anki.consts import NEW_CARDS_RANDOM, STARTING_FACTOR\nfrom anki.importing.base import Importer\nfrom anki.models import NotetypeId\nfrom anki.notes import NoteId\nfrom anki.utils import (\n    field_checksum,\n    guid64,\n    int_time,\n    join_fields,\n    split_fields,\n    timestamp_id,\n)\n\nTagMappedUpdate = tuple[int, int, str, str, NoteId, str, str]\nTagModifiedUpdate = tuple[int, int, str, str, NoteId, str]\nNoTagUpdate = tuple[int, int, str, NoteId, str]\nUpdates = Union[TagMappedUpdate, TagModifiedUpdate, NoTagUpdate]\n\n# Stores a list of fields, tags and deck\n######################################################################\n\n\nclass ForeignNote:\n    \"An temporary object storing fields and attributes.\"\n\n    def __init__(self) -> None:\n        self.fields: list[str] = []\n        self.tags: list[str] = []\n        self.deck = None\n        self.cards: dict[int, ForeignCard] = {}  # map of ord -> card\n        self.fieldsStr = \"\"\n\n\nclass ForeignCard:\n    def __init__(self) -> None:\n        self.due = 0\n        self.ivl = 1\n        self.factor = STARTING_FACTOR\n        self.reps = 0\n        self.lapses = 0\n\n\n# Base class for CSV and similar text-based imports\n######################################################################\n\n# The mapping is list of input fields, like:\n# ['Expression', 'Reading', '_tags', None]\n# - None means that the input should be discarded\n# - _tags maps to note tags\n# If the first field of the model is not in the map, the map is invalid.\n\n# The import mode is one of:\n# UPDATE_MODE: update if first field matches existing note\n# IGNORE_MODE: ignore if first field matches existing note\n# ADD_MODE: import even if first field matches existing note\nUPDATE_MODE = 0\nIGNORE_MODE = 1\nADD_MODE = 2\n\n\nclass NoteImporter(Importer):\n    needMapper = True\n    needDelimiter = False\n    allowHTML = False\n    importMode = UPDATE_MODE\n    mapping: list[str] | None\n    tagModified: str | None\n\n    def __init__(self, col: Collection, file: str) -> None:\n        Importer.__init__(self, col, file)\n        self.model = col.models.current()\n        self.mapping = None\n        self.tagModified = None\n        self._tagsMapped = False\n\n    def run(self) -> None:\n        \"Import.\"\n        assert self.mapping\n        c = self.foreignNotes()\n        self.importNotes(c)\n\n    def fields(self) -> int:\n        \"The number of fields.\"\n        return 0\n\n    def initMapping(self) -> None:\n        flds = [f[\"name\"] for f in self.model[\"flds\"]]\n        # truncate to provided count\n        flds = flds[0 : self.fields()]\n        # if there's room left, add tags\n        if self.fields() > len(flds):\n            flds.append(\"_tags\")\n        # and if there's still room left, pad\n        flds = flds + [None] * (self.fields() - len(flds))\n        self.mapping = flds\n\n    def mappingOk(self) -> bool:\n        return self.model[\"flds\"][0][\"name\"] in self.mapping\n\n    def foreignNotes(self) -> list:\n        \"Return a list of foreign notes for importing.\"\n        return []\n\n    def importNotes(self, notes: list[ForeignNote]) -> None:\n        \"Convert each card into a note, apply attributes and add to col.\"\n        if not self.mappingOk():\n            raise Exception(\"mapping not ok\")\n        # note whether tags are mapped\n        self._tagsMapped = False\n        for f in self.mapping:\n            if f == \"_tags\":\n                self._tagsMapped = True\n        # gather checks for duplicate comparison\n        csums: dict[str, list[NoteId]] = {}\n        for csum, id in self.col.db.execute(\n            \"select csum, id from notes where mid = ?\", self.model[\"id\"]\n        ):\n            if csum in csums:\n                csums[csum].append(id)\n            else:\n                csums[csum] = [id]\n        firsts: dict[str, bool] = {}\n        fld0idx = self.mapping.index(self.model[\"flds\"][0][\"name\"])\n        self._fmap = self.col.models.field_map(self.model)\n        self._nextID = NoteId(timestamp_id(self.col.db, \"notes\"))\n        # loop through the notes\n        updates: list[Updates] = []\n        updateLog = []\n        new = []\n        self._ids: list[NoteId] = []\n        self._cards: list[tuple] = []\n        dupeCount = 0\n        dupes: list[str] = []\n        for n in notes:\n            for c, field in enumerate(n.fields):\n                if not self.allowHTML:\n                    n.fields[c] = html.escape(field, quote=False)\n                n.fields[c] = field.strip()\n                if not self.allowHTML:\n                    n.fields[c] = field.replace(\"\\n\", \"<br>\")\n            fld0 = unicodedata.normalize(\"NFC\", n.fields[fld0idx])\n            # first field must exist\n            if not fld0:\n                self.log.append(\n                    self.col.tr.importing_empty_first_field(val=\" \".join(n.fields))\n                )\n                continue\n            csum = field_checksum(fld0)\n            # earlier in import?\n            if fld0 in firsts and self.importMode != ADD_MODE:\n                # duplicates in source file; log and ignore\n                self.log.append(self.col.tr.importing_appeared_twice_in_file(val=fld0))\n                continue\n            firsts[fld0] = True\n            # already exists?\n            found = False\n            if csum in csums:  # type: ignore[comparison-overlap]\n                # csum is not a guarantee; have to check\n                for id in csums[csum]:  # type: ignore[index]\n                    flds = self.col.db.scalar(\"select flds from notes where id = ?\", id)\n                    sflds = split_fields(flds)\n                    if fld0 == sflds[0]:\n                        # duplicate\n                        found = True\n                        if self.importMode == UPDATE_MODE:\n                            data = self.updateData(n, id, sflds)\n                            if data:\n                                updates.append(data)\n                                updateLog.append(\n                                    self.col.tr.importing_first_field_matched(val=fld0)\n                                )\n                                dupeCount += 1\n                                found = True\n                        elif self.importMode == IGNORE_MODE:\n                            dupeCount += 1\n                        elif self.importMode == ADD_MODE:\n                            # allow duplicates in this case\n                            if fld0 not in dupes:\n                                # only show message once, no matter how many\n                                # duplicates are in the collection already\n                                updateLog.append(\n                                    self.col.tr.importing_added_duplicate_with_first_field(\n                                        val=fld0,\n                                    )\n                                )\n                                dupes.append(fld0)\n                            found = False\n            # newly add\n            if not found:\n                new_data = self.newData(n)\n                if new_data:\n                    new.append(new_data)\n                    # note that we've seen this note once already\n                    firsts[fld0] = True\n        self.addNew(new)\n        self.addUpdates(updates)\n        # generate cards + update field cache\n        self.col.after_note_updates(self._ids, mark_modified=False)\n        # apply scheduling updates\n        self.updateCards()\n        # we randomize or order here, to ensure that siblings\n        # have the same due#\n        did = self.col.decks.selected()\n        conf = self.col.decks.config_dict_for_deck_id(did)\n        # in order due?\n        if not conf[\"dyn\"] and conf[\"new\"][\"order\"] == NEW_CARDS_RANDOM:\n            self.col.sched.randomize_cards(did)\n\n        part1 = self.col.tr.importing_note_added(count=len(new))\n        part2 = self.col.tr.importing_note_updated(count=self.updateCount)\n        if self.importMode == UPDATE_MODE:\n            unchanged = dupeCount - self.updateCount\n        elif self.importMode == IGNORE_MODE:\n            unchanged = dupeCount\n        else:\n            unchanged = 0\n        part3 = self.col.tr.importing_note_unchanged(count=unchanged)\n        self.log.append(f\"{part1}, {part2}, {part3}.\")\n        self.log.extend(updateLog)\n        self.total = len(self._ids)\n\n    def newData(\n        self, n: ForeignNote\n    ) -> tuple[NoteId, str, NotetypeId, int, int, str, str, str, int, int, str]:\n        id = self._nextID\n        self._nextID = NoteId(self._nextID + 1)\n        self._ids.append(id)\n        self.processFields(n)\n        # note id for card updates later\n        for ord, c in list(n.cards.items()):\n            self._cards.append((id, ord, c))\n        return (\n            id,\n            guid64(),\n            self.model[\"id\"],\n            int_time(),\n            self.col.usn(),\n            self.col.tags.join(n.tags),\n            n.fieldsStr,\n            \"\",\n            0,\n            0,\n            \"\",\n        )\n\n    def addNew(\n        self,\n        rows: list[\n            tuple[NoteId, str, NotetypeId, int, int, str, str, str, int, int, str]\n        ],\n    ) -> None:\n        self.col.db.executemany(\n            \"insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)\", rows\n        )\n\n    def updateData(\n        self, n: ForeignNote, id: NoteId, sflds: list[str]\n    ) -> Updates | None:\n        self._ids.append(id)\n        self.processFields(n, sflds)\n        if self._tagsMapped:\n            tags = self.col.tags.join(n.tags)\n            return (\n                int_time(),\n                self.col.usn(),\n                n.fieldsStr,\n                tags,\n                id,\n                n.fieldsStr,\n                tags,\n            )\n        elif self.tagModified:\n            tags = self.col.db.scalar(\"select tags from notes where id = ?\", id)\n            tagList = self.col.tags.split(tags) + self.tagModified.split()\n            tags = self.col.tags.join(tagList)\n            return (int_time(), self.col.usn(), n.fieldsStr, tags, id, n.fieldsStr)\n        else:\n            return (int_time(), self.col.usn(), n.fieldsStr, id, n.fieldsStr)\n\n    def addUpdates(self, rows: list[Updates]) -> None:\n        changes = self.col.db.scalar(\"select total_changes()\")\n        if self._tagsMapped:\n            self.col.db.executemany(\n                \"\"\"\nupdate notes set mod = ?, usn = ?, flds = ?, tags = ?\nwhere id = ? and (flds != ? or tags != ?)\"\"\",\n                rows,\n            )\n        elif self.tagModified:\n            self.col.db.executemany(\n                \"\"\"\nupdate notes set mod = ?, usn = ?, flds = ?, tags = ?\nwhere id = ? and flds != ?\"\"\",\n                rows,\n            )\n        else:\n            self.col.db.executemany(\n                \"\"\"\nupdate notes set mod = ?, usn = ?, flds = ?\nwhere id = ? and flds != ?\"\"\",\n                rows,\n            )\n        changes2 = self.col.db.scalar(\"select total_changes()\")\n        self.updateCount = changes2 - changes\n\n    def processFields(self, note: ForeignNote, fields: list[str] | None = None) -> None:\n        if not fields:\n            fields = [\"\"] * len(self.model[\"flds\"])\n        for c, f in enumerate(self.mapping):\n            if not f:\n                continue\n            elif f == \"_tags\":\n                note.tags.extend(self.col.tags.split(note.fields[c]))\n            else:\n                sidx = self._fmap[f][0]\n                fields[sidx] = note.fields[c]\n        note.fieldsStr = join_fields(fields)\n        # temporary fix for the following issue until we can update the code:\n        # https://forums.ankiweb.net/t/python-checksum-rust-checksum/8195/16\n        if self.col.get_config_bool(Config.Bool.NORMALIZE_NOTE_TEXT):\n            note.fieldsStr = unicodedata.normalize(\"NFC\", note.fieldsStr)\n\n    def updateCards(self) -> None:\n        data = []\n        for nid, ord, c in self._cards:\n            data.append((c.ivl, c.due, c.factor, c.reps, c.lapses, nid, ord))\n        # we assume any updated cards are reviews\n        self.col.db.executemany(\n            \"\"\"\nupdate cards set type = 2, queue = 2, ivl = ?, due = ?,\nfactor = ?, reps = ?, lapses = ? where nid = ? and ord = ?\"\"\",\n            data,\n        )\n"
  },
  {
    "path": "pylib/anki/lang.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport locale\nimport re\nimport warnings\nimport weakref\nfrom typing import TYPE_CHECKING, Any\n\nimport anki\nimport anki._backend\nimport anki.i18n_pb2 as _pb\nfrom anki._legacy import DeprecatedNamesMixinForModule\n\n# public exports\nTR = anki._fluent.LegacyTranslationEnum\nFormatTimeSpan = _pb.FormatTimespanRequest\n\n# When adding new languages here, check lang_to_disk_lang() below\nlangs = sorted(\n    [\n        (\"Afrikaans\", \"af_ZA\"),\n        (\"Bahasa Melayu\", \"ms_MY\"),\n        (\"Català\", \"ca_ES\"),\n        (\"Dansk\", \"da_DK\"),\n        (\"Deutsch\", \"de_DE\"),\n        (\"Eesti\", \"et_EE\"),\n        (\"English (United States)\", \"en_US\"),\n        (\"English (United Kingdom)\", \"en_GB\"),\n        (\"Español\", \"es_ES\"),\n        (\"Esperanto\", \"eo_UY\"),\n        (\"Euskara\", \"eu_ES\"),\n        (\"Français\", \"fr_FR\"),\n        (\"Galego\", \"gl_ES\"),\n        (\"Hrvatski\", \"hr_HR\"),\n        (\"Italiano\", \"it_IT\"),\n        (\"lo jbobau\", \"jbo_EN\"),\n        (\"Lenga d'òc\", \"oc_FR\"),\n        (\"Қазақша\", \"kk_KZ\"),\n        (\"Magyar\", \"hu_HU\"),\n        (\"Nederlands\", \"nl_NL\"),\n        (\"Norsk\", \"nb_NO\"),\n        (\"Polski\", \"pl_PL\"),\n        (\"Português Brasileiro\", \"pt_BR\"),\n        (\"Português\", \"pt_PT\"),\n        (\"Română\", \"ro_RO\"),\n        (\"Slovenčina\", \"sk_SK\"),\n        (\"Slovenščina\", \"sl_SI\"),\n        (\"Suomi\", \"fi_FI\"),\n        (\"Svenska\", \"sv_SE\"),\n        (\"Tiếng Việt\", \"vi_VN\"),\n        (\"Türkçe\", \"tr_TR\"),\n        (\"简体中文\", \"zh_CN\"),\n        (\"日本語\", \"ja_JP\"),\n        (\"繁體中文\", \"zh_TW\"),\n        (\"한국어\", \"ko_KR\"),\n        (\"Čeština\", \"cs_CZ\"),\n        (\"Ελληνικά\", \"el_GR\"),\n        (\"Български\", \"bg_BG\"),\n        (\"Монгол хэл\", \"mn_MN\"),\n        (\"Pусский язык\", \"ru_RU\"),\n        (\"Српски\", \"sr_SP\"),\n        (\"Українська мова\", \"uk_UA\"),\n        (\"Հայերեն\", \"hy_AM\"),\n        (\"עִבְרִית\", \"he_IL\"),\n        (\"ייִדיש\", \"yi\"),\n        (\"العربية\", \"ar_SA\"),\n        (\"فارسی\", \"fa_IR\"),\n        (\"ภาษาไทย\", \"th_TH\"),\n        (\"Latin\", \"la_LA\"),\n        (\"Gaeilge\", \"ga_IE\"),\n        (\"Беларуская мова\", \"be_BY\"),\n        (\"ଓଡ଼ିଆ\", \"or_OR\"),\n        (\"Filipino\", \"tl\"),\n        (\"ئۇيغۇر\", \"ug\"),\n        (\"Oʻzbekcha\", \"uz_UZ\"),\n    ]\n)\n\n# compatibility with old versions\ncompatMap = {\n    \"af\": \"af_ZA\",\n    \"ar\": \"ar_SA\",\n    \"be\": \"be_BY\",\n    \"bg\": \"bg_BG\",\n    \"ca\": \"ca_ES\",\n    \"cs\": \"cs_CZ\",\n    \"da\": \"da_DK\",\n    \"de\": \"de_DE\",\n    \"el\": \"el_GR\",\n    \"en\": \"en_US\",\n    \"eo\": \"eo_UY\",\n    \"es\": \"es_ES\",\n    \"et\": \"et_EE\",\n    \"eu\": \"eu_ES\",\n    \"fa\": \"fa_IR\",\n    \"fi\": \"fi_FI\",\n    \"fr\": \"fr_FR\",\n    \"gl\": \"gl_ES\",\n    \"he\": \"he_IL\",\n    \"hr\": \"hr_HR\",\n    \"hu\": \"hu_HU\",\n    \"hy\": \"hy_AM\",\n    \"it\": \"it_IT\",\n    \"ja\": \"ja_JP\",\n    \"jbo\": \"jbo_EN\",\n    \"kk\": \"kk_KZ\",\n    \"ko\": \"ko_KR\",\n    \"la\": \"la_LA\",\n    \"mn\": \"mn_MN\",\n    \"ms\": \"ms_MY\",\n    \"nl\": \"nl_NL\",\n    \"nb\": \"nb_NL\",\n    \"no\": \"nb_NL\",\n    \"oc\": \"oc_FR\",\n    \"or\": \"or_OR\",\n    \"pl\": \"pl_PL\",\n    \"pt\": \"pt_PT\",\n    \"ro\": \"ro_RO\",\n    \"ru\": \"ru_RU\",\n    \"sk\": \"sk_SK\",\n    \"sl\": \"sl_SI\",\n    \"sr\": \"sr_SP\",\n    \"sv\": \"sv_SE\",\n    \"th\": \"th_TH\",\n    \"tr\": \"tr_TR\",\n    \"uk\": \"uk_UA\",\n    \"uz\": \"uz_UZ\",\n    \"vi\": \"vi_VN\",\n    \"yi\": \"yi\",\n}\n\n\ndef lang_to_disk_lang(lang: str) -> str:\n    \"\"\"Normalize lang, then convert it to name used on disk.\"\"\"\n    # convert it into our canonical representation first\n    lang = lang.replace(\"-\", \"_\")\n    if lang in compatMap:\n        lang = compatMap[lang]\n\n    # these language/region combinations are fully qualified, but with a hyphen\n    if lang in (\n        \"en_GB\",\n        \"ga_IE\",\n        \"hy_AM\",\n        \"nb_NO\",\n        \"nn_NO\",\n        \"pt_BR\",\n        \"pt_PT\",\n        \"sv_SE\",\n        \"zh_CN\",\n        \"zh_TW\",\n    ):\n        return lang.replace(\"_\", \"-\")\n    # other languages have the region portion stripped\n    match = re.match(\"(.*)_\", lang)\n    if match:\n        return match.group(1)\n    else:\n        return lang\n\n\n# the currently set interface language\ncurrent_lang = \"en\"\n\n# the current Fluent translation instance. Code in pylib/ should\n# not reference this, and should use col.tr instead. The global\n# instance exists for legacy reasons, and as a convenience for the\n# Qt code.\ncurrent_i18n: anki._backend.RustBackend | None = None\ntr_legacyglobal = anki._backend.Translations(None)\n\n\ndef _(str: str) -> str:\n    print(f\"gettext _() is deprecated: {str}\")\n    return str\n\n\ndef ngettext(single: str, plural: str, num: int) -> str:\n    print(f\"ngettext() is deprecated: {plural}\")\n    return plural\n\n\ndef set_lang(lang: str) -> None:\n    global current_lang, current_i18n\n    current_lang = lang\n    current_i18n = anki._backend.RustBackend(langs=[lang])\n    tr_legacyglobal.backend = weakref.ref(current_i18n)\n\n\ndef get_def_lang(user_lang: str | None = None) -> tuple[int, str]:\n    \"\"\"Return user_lang converted to name used on disk and its index, defaulting to system language\n    or English if not available.\"\"\"\n\n    def get_index_of_language(wanted_locale: str) -> int | None:\n        for i, (_, locale_) in enumerate(langs):\n            if locale_ == wanted_locale:\n                return i\n        return None\n\n    try:\n        # getdefaultlocale() is deprecated since Python 3.11, but we need to keep using it as getlocale() behaves differently: https://bugs.python.org/issue38805\n        with warnings.catch_warnings():\n            warnings.simplefilter(\"ignore\", DeprecationWarning)\n            (sys_lang, enc) = locale.getdefaultlocale()\n    except AttributeError:\n        # this will return a different format on Windows (e.g. Italian_Italy), resulting in us falling back to en_US\n        # further below\n        (sys_lang, enc) = locale.getlocale()\n    except Exception:\n        # fails on osx\n        sys_lang = \"en_US\"\n    if user_lang in compatMap:\n        user_lang = compatMap[user_lang]\n\n    idx = None\n    lang = None\n    for preferred_lang in (user_lang, sys_lang):\n        idx = get_index_of_language(preferred_lang)\n        is_language_supported = idx is not None\n        if is_language_supported:\n            assert preferred_lang is not None\n            lang = preferred_lang\n            break\n    # if the specified language and the system language aren't available, revert to english\n    is_preferred_language_supported = idx is not None\n    if not is_preferred_language_supported:\n        lang = \"en_US\"\n        idx = get_index_of_language(lang)\n        is_english_supported = idx is not None\n        if not is_english_supported:\n            raise AssertionError(\"English is supposed to be a supported language.\")\n    assert idx is not None and lang is not None\n    return (idx, lang)\n\n\ndef is_rtl(lang: str) -> bool:\n    return lang in (\"he\", \"ar\", \"fa\", \"ug\", \"yi\")\n\n\n# strip off unicode isolation markers from a translated string\n# for testing purposes\ndef without_unicode_isolation(string: str) -> str:\n    return string.replace(\"\\u2068\", \"\").replace(\"\\u2069\", \"\")\n\n\ndef with_collapsed_whitespace(string: str) -> str:\n    return re.sub(r\"\\s+\", \" \", string)\n\n\n_deprecated_names = DeprecatedNamesMixinForModule(globals())\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name: str) -> Any:\n        return _deprecated_names.__getattr__(name)\n"
  },
  {
    "path": "pylib/anki/latex.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport html\nimport os\nfrom dataclasses import dataclass\n\nimport anki\nimport anki.collection\nfrom anki import card_rendering_pb2, hooks\nfrom anki.config import Config\nfrom anki.models import NotetypeDict\nfrom anki.template import TemplateRenderContext, TemplateRenderOutput\nfrom anki.utils import call, is_mac, namedtmp, tmpdir\n\npngCommands = [\n    [\"latex\", \"-interaction=nonstopmode\", \"tmp.tex\"],\n    [\n        \"dvipng\",\n        \"-bg\",\n        \"Transparent\",\n        \"-D\",\n        \"200\",\n        \"-T\",\n        \"tight\",\n        \"tmp.dvi\",\n        \"-o\",\n        \"tmp.png\",\n    ],\n]\n\nsvgCommands = [\n    [\"latex\", \"-interaction=nonstopmode\", \"tmp.tex\"],\n    [\"dvisvgm\", \"--no-fonts\", \"--exact\", \"-Z\", \"2\", \"tmp.dvi\", \"-o\", \"tmp.svg\"],\n]\n\n# add standard tex install location to osx\nif is_mac:\n    os.environ[\"PATH\"] += \":/usr/texbin:/Library/TeX/texbin\"\n\n\n@dataclass\nclass ExtractedLatex:\n    filename: str\n    latex_body: str\n\n\n@dataclass\nclass ExtractedLatexOutput:\n    html: str\n    latex: list[ExtractedLatex]\n\n    @staticmethod\n    def from_proto(\n        proto: card_rendering_pb2.ExtractLatexResponse,\n    ) -> ExtractedLatexOutput:\n        return ExtractedLatexOutput(\n            html=proto.text,\n            latex=[\n                ExtractedLatex(filename=l.filename, latex_body=l.latex_body)\n                for l in proto.latex\n            ],\n        )\n\n\ndef on_card_did_render(\n    output: TemplateRenderOutput, ctx: TemplateRenderContext\n) -> None:\n    output.question_text = render_latex(\n        output.question_text, ctx.note_type(), ctx.col()\n    )\n    output.answer_text = render_latex(output.answer_text, ctx.note_type(), ctx.col())\n\n\ndef render_latex(\n    html: str, model: NotetypeDict, col: anki.collection.Collection\n) -> str:\n    \"Convert embedded latex tags in text to image links.\"\n    html, err = render_latex_returning_errors(html, model, col)\n    if err:\n        html += \"\\n\".join(err)\n    return html\n\n\ndef render_latex_returning_errors(\n    html: str,\n    model: NotetypeDict,\n    col: anki.collection.Collection,\n    expand_clozes: bool = False,\n) -> tuple[str, list[str]]:\n    \"\"\"Returns (text, errors).\n\n    errors will be non-empty if LaTeX failed to render.\"\"\"\n    svg = model.get(\"latexsvg\", False)\n    header = model[\"latexPre\"]\n    footer = model[\"latexPost\"]\n\n    proto = col._backend.extract_latex(text=html, svg=svg, expand_clozes=expand_clozes)\n    out = ExtractedLatexOutput.from_proto(proto)\n    errors = []\n    html = out.html\n    render_latex = col.get_config_bool(Config.Bool.RENDER_LATEX)\n\n    for latex in out.latex:\n        # don't need to render?\n        if col.media.have(latex.filename):\n            continue\n        if not render_latex:\n            errors.append(col.tr.preferences_latex_generation_disabled())\n            return html, errors\n\n        err = _save_latex_image(col, latex, header, footer, svg)\n        if err is not None:\n            errors.append(err)\n\n    return html, errors\n\n\ndef _save_latex_image(\n    col: anki.collection.Collection,\n    extracted: ExtractedLatex,\n    header: str,\n    footer: str,\n    svg: bool,\n) -> str | None:\n    # add header/footer\n    latex = f\"{header}\\n{extracted.latex_body}\\n{footer}\"\n\n    # commands to use\n    if svg:\n        latex_cmds = svgCommands\n        ext = \"svg\"\n    else:\n        latex_cmds = pngCommands\n        ext = \"png\"\n\n    # write into a temp file\n    log = open(namedtmp(\"latex_log.txt\"), \"w\", encoding=\"utf8\")\n    texpath = namedtmp(\"tmp.tex\")\n    texfile = open(texpath, \"w\", encoding=\"utf8\")\n    texfile.write(latex)\n    texfile.close()\n    oldcwd = os.getcwd()\n    png_or_svg = namedtmp(f\"tmp.{ext}\")\n    try:\n        # generate png/svg\n        os.chdir(tmpdir())\n        for latex_cmd in latex_cmds:\n            if call(latex_cmd, stdout=log, stderr=log):\n                return _err_msg(col, latex_cmd[0], texpath)\n        # add to media\n        with open(png_or_svg, \"rb\") as file:\n            data = file.read()\n        col.media.write_data(extracted.filename, data)\n        os.unlink(png_or_svg)\n        return None\n    finally:\n        os.chdir(oldcwd)\n        log.close()\n\n\ndef _err_msg(col: anki.collection.Collection, type: str, texpath: str) -> str:\n    msg = f\"{col.tr.media_error_executing(val=type)}<br>\"\n    msg += f\"{col.tr.media_generated_file(val=texpath)}<br>\"\n    try:\n        with open(namedtmp(\"latex_log.txt\", remove=False), encoding=\"utf8\") as file:\n            log = file.read()\n        if not log:\n            raise Exception()\n        msg += f\"<small><pre>{html.escape(log)}</pre></small>\"\n    except Exception:\n        msg += col.tr.media_have_you_installed_latex_and_dvipngdvisvgm()\n    return msg\n\n\ndef setup_hook() -> None:\n    hooks.card_did_render.append(on_card_did_render)\n"
  },
  {
    "path": "pylib/anki/media.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport pprint\nimport re\nimport sys\nimport time\nfrom collections.abc import Callable, Sequence\n\nfrom anki import media_pb2\nfrom anki._legacy import DeprecatedNamesMixin, deprecated_keywords\nfrom anki.consts import *\nfrom anki.latex import render_latex, render_latex_returning_errors\nfrom anki.models import NotetypeId\nfrom anki.sound import SoundOrVideoTag\nfrom anki.template import av_tags_to_native\nfrom anki.utils import int_time\n\n\ndef media_paths_from_col_path(col_path: str) -> tuple[str, str]:\n    media_folder = re.sub(r\"(?i)\\.(anki2)$\", \".media\", col_path)\n    media_db = f\"{media_folder}.db2\"\n    return (media_folder, media_db)\n\n\nCheckMediaResponse = media_pb2.CheckMediaResponse\n\n\nclass MediaManager(DeprecatedNamesMixin):\n    sound_regexps = [r\"(?i)(\\[sound:(?P<fname>[^]]+)\\])\"]\n    html_media_regexps = [\n        # src element quoted case\n        r\"(?i)(<(?:img|audio|source)\\b[^>]* src=(?P<str>[\\\"'])(?P<fname>[^>]+?)(?P=str)[^>]*>)\",\n        # unquoted case\n        r\"(?i)(<(?:img|audio|source)\\b[^>]* src=(?!['\\\"])(?P<fname>[^ >]+)[^>]*?>)\",\n        # src element quoted case\n        r\"(?i)(<object\\b[^>]* data=(?P<str>[\\\"'])(?P<fname>[^>]+?)(?P=str)[^>]*>)\",\n        # unquoted case\n        r\"(?i)(<object\\b[^>]* data=(?!['\\\"])(?P<fname>[^ >]+)[^>]*?>)\",\n    ]\n    regexps = sound_regexps + html_media_regexps\n\n    def __init__(self, col: anki.collection.Collection, server: bool) -> None:\n        self.col = col.weakref()\n        if server:\n            return\n        # media directory\n        self._dir = media_paths_from_col_path(self.col.path)[0]\n        if not os.path.exists(self._dir):\n            os.makedirs(self._dir)\n\n    def __repr__(self) -> str:\n        dict_ = dict(self.__dict__)\n        del dict_[\"col\"]\n        return f\"{super().__repr__()} {pprint.pformat(dict_, width=300)}\"\n\n    def dir(self) -> str:\n        return self._dir\n\n    def force_resync(self) -> None:\n        try:\n            os.unlink(media_paths_from_col_path(self.col.path)[1])\n        except FileNotFoundError:\n            pass\n\n    def empty_trash(self) -> None:\n        self.col._backend.empty_trash()\n\n    def restore_trash(self) -> None:\n        self.col._backend.restore_trash()\n\n    def strip_av_tags(self, text: str) -> str:\n        return self.col._backend.strip_av_tags(text)\n\n    def _extract_filenames(self, text: str) -> list[str]:\n        \"This only exists to support a legacy function; do not use.\"\n        out = self.col._backend.extract_av_tags(text=text, question_side=True)\n        return [\n            x.filename\n            for x in av_tags_to_native(out.av_tags)\n            if isinstance(x, SoundOrVideoTag)\n        ]\n\n    # File manipulation\n    ##########################################################################\n\n    def add_file(self, path: str) -> str:\n        \"\"\"Add basename of path to the media folder, renaming if not unique.\n\n        Returns possibly-renamed filename.\"\"\"\n        with open(path, \"rb\") as file:\n            return self.write_data(os.path.basename(path), file.read())\n\n    def write_data(self, desired_fname: str, data: bytes) -> str:\n        \"\"\"Write the file to the media folder, renaming if not unique.\n\n        Returns possibly-renamed filename.\"\"\"\n        return self.col._backend.add_media_file(desired_name=desired_fname, data=data)\n\n    def add_extension_based_on_mime(self, fname: str, content_type: str) -> str:\n        \"Add extension based on mime for common audio and image format if missing extension.\"\n        if not os.path.splitext(fname)[1]:\n            # mimetypes is returning '.jpe' even after calling .init(), so we'll do\n            # it manually instead\n            type_map = {\n                \"audio/mpeg\": \".mp3\",\n                \"audio/ogg\": \".oga\",\n                \"audio/opus\": \".opus\",\n                \"audio/wav\": \".wav\",\n                \"audio/webm\": \".weba\",\n                \"audio/aac\": \".aac\",\n                \"image/jpeg\": \".jpg\",\n                \"image/png\": \".png\",\n                \"image/svg+xml\": \".svg\",\n                \"image/webp\": \".webp\",\n                \"image/avif\": \".avif\",\n            }\n            if content_type in type_map:\n                fname += type_map[content_type]\n        return fname\n\n    def have(self, fname: str) -> bool:\n        return os.path.exists(os.path.join(self.dir(), fname))\n\n    def trash_files(self, fnames: list[str]) -> None:\n        \"Move provided files to the trash.\"\n        self.col._backend.trash_media_files(fnames)\n\n    # String manipulation\n    ##########################################################################\n\n    @deprecated_keywords(includeRemote=\"include_remote\")\n    def files_in_str(\n        self, mid: NotetypeId, string: str, include_remote: bool = False\n    ) -> list[str]:\n        files = []\n        model = self.col.models.get(mid)\n        # handle latex\n        string = render_latex(string, model, self.col)\n        # extract filenames\n        for reg in self.regexps:\n            for match in re.finditer(reg, string):\n                fname = match.group(\"fname\")\n                is_local = not re.match(\"(https?|ftp)://\", fname.lower())\n                if is_local or include_remote:\n                    files.append(fname)\n        return files\n\n    def extract_static_media_files(self, mid: NotetypeId) -> Sequence[str]:\n        return self.col._backend.extract_static_media_files(mid)\n\n    def transform_names(self, txt: str, func: Callable) -> str:\n        for reg in self.regexps:\n            txt = re.sub(reg, func, txt)\n        return txt\n\n    def strip(self, txt: str) -> str:\n        \"Return text with sound and image tags removed.\"\n        for reg in self.regexps:\n            txt = re.sub(reg, \"\", txt)\n        return txt\n\n    def escape_images(self, string: str, unescape: bool = False) -> str:\n        \"escape_media_filenames alias for compatibility with add-ons.\"\n        return self.escape_media_filenames(string, unescape)\n\n    def escape_media_filenames(self, string: str, unescape: bool = False) -> str:\n        \"Apply or remove percent encoding to filenames in html tags (audio, image, object).\"\n        if unescape:\n            return self.col._backend.decode_iri_paths(string)\n        else:\n            return self.col._backend.encode_iri_paths(string)\n\n    # Checking media\n    ##########################################################################\n\n    def check(self) -> CheckMediaResponse:\n        output = self.col._backend.check_media()\n        return output\n\n    def render_all_latex(\n        self, progress_cb: Callable[[int], bool] | None = None\n    ) -> tuple[int, str] | None:\n        \"\"\"Render any LaTeX that is missing.\n\n        If a progress callback is provided and it returns false, the operation\n        will be aborted.\n\n        If an error is encountered, returns (note_id, error_message)\n        \"\"\"\n        last_progress = time.time()\n        checked = 0\n        for nid, mid, flds in self.col.db.execute(\n            \"select id, mid, flds from notes where flds like '%[%'\"\n        ):\n            model = self.col.models.get(mid)\n            _html, errors = render_latex_returning_errors(\n                flds, model, self.col, expand_clozes=True\n            )\n            if errors:\n                return (nid, \"\\n\".join(errors))\n\n            checked += 1\n            elap = time.time() - last_progress\n            if elap >= 0.3 and progress_cb is not None:\n                last_progress = int_time()\n                if not progress_cb(checked):\n                    return None\n\n        return None\n\n    # Legacy\n    ##########################################################################\n\n    _illegalCharReg = re.compile(r'[][><:\"/?*^\\\\|\\0\\r\\n]')\n\n    def _legacy_strip_illegal(self, str: str) -> str:\n        # currently used by ankiconnect\n        return re.sub(self._illegalCharReg, \"\", str)\n\n    def _legacy_has_illegal(self, string: str) -> bool:\n        if re.search(self._illegalCharReg, string):\n            return True\n        try:\n            string.encode(sys.getfilesystemencoding())\n        except UnicodeEncodeError:\n            return True\n        return False\n\n    def _legacy_find_changes(self) -> None:\n        pass\n\n    @deprecated_keywords(typeHint=\"type_hint\")\n    def _legacy_write_data(\n        self, opath: str, data: bytes, type_hint: str | None = None\n    ) -> str:\n        fname = os.path.basename(opath)\n        if type_hint:\n            fname = self.add_extension_based_on_mime(fname, type_hint)\n        return self.write_data(fname, data)\n\n\nMediaManager.register_deprecated_attributes(\n    stripIllegal=(MediaManager._legacy_strip_illegal, None),\n    hasIllegal=(MediaManager._legacy_has_illegal, None),\n    findChanges=(MediaManager._legacy_find_changes, None),\n    writeData=(MediaManager._legacy_write_data, MediaManager.write_data),\n)\n"
  },
  {
    "path": "pylib/anki/models.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport copy\nimport pprint\nimport sys\nimport time\nfrom collections.abc import Sequence\nfrom typing import Any, NewType, Union\n\nimport anki\nimport anki.collection\nimport anki.notes\nfrom anki import notetypes_pb2\nfrom anki._legacy import DeprecatedNamesMixin, deprecated, print_deprecation_warning\nfrom anki.collection import OpChanges, OpChangesWithId\nfrom anki.consts import *\nfrom anki.errors import NotFoundError\nfrom anki.lang import without_unicode_isolation\nfrom anki.stdmodels import StockNotetypeKind\nfrom anki.utils import checksum, from_json_bytes, to_json_bytes\n\n# public exports\nNotetypeNameId = notetypes_pb2.NotetypeNameId\nNotetypeNameIdUseCount = notetypes_pb2.NotetypeNameIdUseCount\nNotetypeNames = notetypes_pb2.NotetypeNames\nChangeNotetypeInfo = notetypes_pb2.ChangeNotetypeInfo\nChangeNotetypeRequest = notetypes_pb2.ChangeNotetypeRequest\nStockNotetype = notetypes_pb2.StockNotetype\n\n# legacy types\nNotetypeDict = dict[str, Any]\nNoteType = NotetypeDict\nFieldDict = dict[str, Any]\nTemplateDict = dict[str, Union[str, int, None]]\nNotetypeId = NewType(\"NotetypeId\", int)\nsys.modules[\"anki.models\"].NoteType = NotetypeDict  # type: ignore\n\n\nclass ModelsDictProxy:\n    def __init__(self, col: anki.collection.Collection):\n        self._col = col.weakref()\n\n    def _warn(self) -> None:\n        print_deprecation_warning(\n            \"add-on should use methods on col.models, not col.models.models dict\"\n        )\n\n    def __getitem__(self, item: Any) -> Any:\n        self._warn()\n        return self._col.models.get(NotetypeId(int(item)))\n\n    def __setitem__(self, key: str, val: Any) -> None:\n        self._warn()\n        self._col.models.save(val)\n\n    def __len__(self) -> int:\n        self._warn()\n        return len(self._col.models.all_names_and_ids())\n\n    def keys(self) -> Any:\n        self._warn()\n        return [str(nt.id) for nt in self._col.models.all_names_and_ids()]\n\n    def values(self) -> Any:\n        self._warn()\n        return self._col.models.all()\n\n    def items(self) -> Any:\n        self._warn()\n        return [(str(nt[\"id\"]), nt) for nt in self._col.models.all()]\n\n    def __contains__(self, item: Any) -> bool:\n        self._warn()\n        return self._col.models.have(item)\n\n\nclass ModelManager(DeprecatedNamesMixin):\n    # Saving/loading registry\n    #############################################################\n\n    def __init__(self, col: anki.collection.Collection) -> None:\n        self.col = col.weakref()\n        self.models = ModelsDictProxy(col)\n        # do not access this directly!\n        self._cache = {}\n\n    def __repr__(self) -> str:\n        attrs = dict(self.__dict__)\n        del attrs[\"col\"]\n        return f\"{super().__repr__()} {pprint.pformat(attrs, width=300)}\"\n\n    # Caching\n    #############################################################\n    # A lot of existing code expects to be able to quickly and\n    # frequently obtain access to an entire notetype, so we currently\n    # need to cache responses from the backend. Please do not\n    # access the cache directly!\n\n    _cache: dict[NotetypeId, NotetypeDict] = {}\n\n    def _update_cache(self, notetype: NotetypeDict) -> None:\n        self._cache[notetype[\"id\"]] = notetype\n\n    def _remove_from_cache(self, ntid: NotetypeId) -> None:\n        if ntid in self._cache:\n            del self._cache[ntid]\n\n    def _get_cached(self, ntid: NotetypeId) -> NotetypeDict | None:\n        return self._cache.get(ntid)\n\n    def _clear_cache(self) -> None:\n        self._cache = {}\n\n    # Listing note types\n    #############################################################\n\n    def all_names_and_ids(self) -> Sequence[NotetypeNameId]:\n        return self.col._backend.get_notetype_names()\n\n    def all_use_counts(self) -> Sequence[NotetypeNameIdUseCount]:\n        return self.col._backend.get_notetype_names_and_counts()\n\n    # only used by importing code\n    def have(self, id: NotetypeId) -> bool:\n        if isinstance(id, str):\n            id = int(id)\n        return any(True for e in self.all_names_and_ids() if e.id == id)\n\n    # Current note type\n    #############################################################\n\n    def current(self, for_deck: bool = True) -> NotetypeDict:\n        \"Get current model. In new code, prefer col.defaults_for_adding()\"\n        notetype = self.get(self.col.decks.current().get(\"mid\"))\n        if not for_deck or not notetype:\n            notetype = self.get(self.col.conf[\"curModel\"])\n        if notetype:\n            return notetype\n        return self.get(NotetypeId(self.all_names_and_ids()[0].id))\n\n    # Retrieving and creating models\n    #############################################################\n\n    def id_for_name(self, name: str) -> NotetypeId | None:\n        try:\n            return NotetypeId(self.col._backend.get_notetype_id_by_name(name))\n        except NotFoundError:\n            return None\n\n    def get(self, id: NotetypeId) -> NotetypeDict | None:\n        \"\"\"Get model with ID, or None.\n\n        This returns a reference to a cached dict. Copy the returned model before modifying it if you're not calling .update_dict() afterward.\n        \"\"\"\n        # deal with various legacy input types\n        if id is None:\n            return None\n        elif isinstance(id, str):\n            id = int(id)\n\n        notetype = self._get_cached(id)\n        if not notetype:\n            try:\n                notetype = from_json_bytes(self.col._backend.get_notetype_legacy(id))\n                self._update_cache(notetype)\n            except NotFoundError:\n                return None\n        return notetype\n\n    def all(self) -> list[NotetypeDict]:\n        \"Get all models.\"\n        return [self.get(NotetypeId(nt.id)) for nt in self.all_names_and_ids()]\n\n    def by_name(self, name: str) -> NotetypeDict | None:\n        \"Get model with NAME.\"\n        id = self.id_for_name(name)\n        if id:\n            return self.get(id)\n        else:\n            return None\n\n    def new(self, name: str) -> NotetypeDict:\n        \"Create a new model, and return it.\"\n        # caller should call save() after modifying\n        notetype = from_json_bytes(\n            self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC)\n        )\n        notetype[\"flds\"] = []\n        notetype[\"tmpls\"] = []\n        notetype[\"name\"] = name\n        return notetype\n\n    def remove_all_notetypes(self) -> None:\n        for notetype in self.all_names_and_ids():\n            self._remove_from_cache(NotetypeId(notetype.id))\n            self.col._backend.remove_notetype(notetype.id)\n\n    def remove(self, id: NotetypeId) -> OpChanges:\n        \"Modifies schema.\"\n        self._remove_from_cache(id)\n        return self.col._backend.remove_notetype(id)\n\n    def add(self, notetype: NotetypeDict) -> OpChangesWithId:\n        \"Replaced with add_dict()\"\n        self.ensure_name_unique(notetype)\n        out = self.col._backend.add_notetype_legacy(to_json_bytes(notetype))\n        notetype[\"id\"] = out.id\n        self._mutate_after_write(notetype)\n        return out\n\n    def add_dict(self, notetype: NotetypeDict) -> OpChangesWithId:\n        \"Notetype needs to be fetched from DB after adding.\"\n        self.ensure_name_unique(notetype)\n        return self.col._backend.add_notetype_legacy(to_json_bytes(notetype))\n\n    def ensure_name_unique(self, notetype: NotetypeDict) -> None:\n        existing_id = self.id_for_name(notetype[\"name\"])\n        if existing_id is not None and existing_id != notetype[\"id\"]:\n            notetype[\"name\"] += f\"-{checksum(str(time.time()))[:5]}\"\n\n    def update_dict(\n        self, notetype: NotetypeDict, skip_checks: bool = False\n    ) -> OpChanges:\n        \"Update a NotetypeDict. Caller will need to re-load notetype if new fields/cards added.\"\n        self._remove_from_cache(notetype[\"id\"])\n        self.ensure_name_unique(notetype)\n        return self.col._backend.update_notetype_legacy(\n            json=to_json_bytes(notetype), skip_checks=skip_checks\n        )\n\n    def _mutate_after_write(self, notetype: NotetypeDict) -> None:\n        # existing code expects the note type to be mutated to reflect\n        # the changes made when adding, such as ordinal assignment :-(\n        updated = self.get(notetype[\"id\"])\n        notetype.update(updated)\n\n    # Tools\n    ##################################################\n\n    def nids(self, ntid: NotetypeId) -> list[anki.notes.NoteId]:\n        \"Note ids for M.\"\n        if isinstance(ntid, dict):\n            # legacy callers passed in note type\n            ntid = ntid[\"id\"]\n        return self.col.db.list(\"select id from notes where mid = ?\", ntid)\n\n    def use_count(self, notetype: NotetypeDict) -> int:\n        \"Number of note using M.\"\n        return self.col.db.scalar(\n            \"select count() from notes where mid = ?\", notetype[\"id\"]\n        )\n\n    # Copying\n    ##################################################\n\n    def copy(self, notetype: NotetypeDict, add: bool = True) -> NotetypeDict:\n        \"Copy, save and return.\"\n        cloned = copy.deepcopy(notetype)\n        cloned[\"name\"] = without_unicode_isolation(\n            self.col.tr.notetypes_copy(val=cloned[\"name\"])\n        )\n        cloned[\"id\"] = 0\n        cloned[\"originalId\"] = None\n        if add:\n            self.add(cloned)\n        return cloned\n\n    # Fields\n    ##################################################\n\n    def field_map(self, notetype: NotetypeDict) -> dict[str, tuple[int, FieldDict]]:\n        \"Mapping of field name -> (ord, field).\"\n        return {f[\"name\"]: (f[\"ord\"], f) for f in notetype[\"flds\"]}\n\n    def field_names(self, notetype: NotetypeDict) -> list[str]:\n        return [f[\"name\"] for f in notetype[\"flds\"]]\n\n    def sort_idx(self, notetype: NotetypeDict) -> int:\n        return notetype[\"sortf\"]\n\n    def cloze_fields(self, mid: NotetypeId) -> Sequence[int]:\n        \"\"\"The list of index of fields that are used by cloze deletion in the note type with id `mid`.\"\"\"\n        return self.col._backend.get_cloze_field_ords(mid)\n\n    # Adding & changing fields\n    ##################################################\n\n    def new_field(self, name: str) -> FieldDict:\n        assert isinstance(name, str)\n        notetype = from_json_bytes(\n            self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC)\n        )\n        field = notetype[\"flds\"][0]\n        field[\"name\"] = name\n        field[\"ord\"] = None\n        return field\n\n    def add_field(self, notetype: NotetypeDict, field: FieldDict) -> None:\n        \"Modifies schema.\"\n        notetype[\"flds\"].append(field)\n\n    def remove_field(self, notetype: NotetypeDict, field: FieldDict) -> None:\n        \"Modifies schema.\"\n        notetype[\"flds\"].remove(field)\n\n    def reposition_field(\n        self, notetype: NotetypeDict, field: FieldDict, idx: int\n    ) -> None:\n        \"Modifies schema.\"\n        oldidx = notetype[\"flds\"].index(field)\n        if oldidx == idx:\n            return\n\n        notetype[\"flds\"].remove(field)\n        notetype[\"flds\"].insert(idx, field)\n\n    def rename_field(\n        self, notetype: NotetypeDict, field: FieldDict, new_name: str\n    ) -> None:\n        if field not in notetype[\"flds\"]:\n            raise Exception(\"invalid field\")\n        field[\"name\"] = new_name\n\n    def set_sort_index(self, notetype: NotetypeDict, idx: int) -> None:\n        \"Modifies schema.\"\n        if not 0 <= idx < len(notetype[\"flds\"]):\n            raise Exception(\"invalid sort index\")\n        notetype[\"sortf\"] = idx\n\n    # Adding & changing templates\n    ##################################################\n\n    def new_template(self, name: str) -> TemplateDict:\n        notetype = from_json_bytes(\n            self.col._backend.get_stock_notetype_legacy(StockNotetypeKind.KIND_BASIC)\n        )\n        template = notetype[\"tmpls\"][0]\n        template[\"name\"] = name\n        template[\"qfmt\"] = \"\"\n        template[\"afmt\"] = \"\"\n        template[\"ord\"] = None\n        return template\n\n    def add_template(self, notetype: NotetypeDict, template: TemplateDict) -> None:\n        \"Modifies schema.\"\n        notetype[\"tmpls\"].append(template)\n\n    def remove_template(self, notetype: NotetypeDict, template: TemplateDict) -> None:\n        \"Modifies schema.\"\n        if not len(notetype[\"tmpls\"]) > 1:\n            raise Exception(\"must have 1 template\")\n        notetype[\"tmpls\"].remove(template)\n\n    def reposition_template(\n        self, notetype: NotetypeDict, template: TemplateDict, idx: int\n    ) -> None:\n        \"Modifies schema.\"\n        oldidx = notetype[\"tmpls\"].index(template)\n        if oldidx == idx:\n            return\n\n        notetype[\"tmpls\"].remove(template)\n        notetype[\"tmpls\"].insert(idx, template)\n\n    def template_use_count(self, ntid: NotetypeId, ord: int) -> int:\n        return self.col.db.scalar(\n            \"\"\"\nselect count() from cards, notes where cards.nid = notes.id\nand notes.mid = ? and cards.ord = ?\"\"\",\n            ntid,\n            ord,\n        )\n\n    # Changing notetypes of notes\n    ##########################################################################\n\n    def get_single_notetype_of_notes(\n        self, note_ids: Sequence[anki.notes.NoteId]\n    ) -> NotetypeId:\n        return NotetypeId(\n            self.col._backend.get_single_notetype_of_notes(note_ids=note_ids)\n        )\n\n    def change_notetype_info(\n        self, *, old_notetype_id: NotetypeId, new_notetype_id: NotetypeId\n    ) -> ChangeNotetypeInfo:\n        return self.col._backend.get_change_notetype_info(\n            old_notetype_id=old_notetype_id, new_notetype_id=new_notetype_id\n        )\n\n    def change_notetype_of_notes(self, input: ChangeNotetypeRequest) -> OpChanges:\n        \"\"\"Assign a new notetype, optionally altering field/template order.\n\n        To get defaults, use\n\n        info = col.models.change_notetype_info(...)\n        input = info.input\n        input.note_ids.extend([...])\n\n        The new_fields and new_templates lists are relative to the new notetype's\n        field/template count. Each value represents the index in the previous\n        notetype. -1 indicates the original value will be discarded.\n        \"\"\"\n        op_bytes = self.col._backend.change_notetype_raw(input.SerializeToString())\n        return OpChanges.FromString(op_bytes)\n\n    def restore_notetype_to_stock(\n        self, notetype_id: NotetypeId, force_kind: StockNotetypeKind.V | None\n    ) -> OpChanges:\n        msg = notetypes_pb2.RestoreNotetypeToStockRequest(\n            notetype_id=notetypes_pb2.NotetypeId(ntid=notetype_id),\n        )\n        if force_kind is not None:\n            msg.force_kind = force_kind\n        return self.col._backend.restore_notetype_to_stock(msg)\n\n    # legacy API - used by unit tests and add-ons\n\n    def change(\n        self,\n        notetype: NotetypeDict,\n        nids: list[anki.notes.NoteId],\n        newModel: NotetypeDict,\n        fmap: dict[int, int | None],\n        cmap: dict[int, int | None] | None,\n    ) -> None:\n        # - maps are ord->ord, and there should not be duplicate targets\n        self.col.mod_schema(check=True)\n        assert fmap\n        field_map = self._convert_legacy_map(fmap, len(newModel[\"flds\"]))\n        is_cloze = newModel[\"type\"] == MODEL_CLOZE or notetype[\"type\"] == MODEL_CLOZE\n        if not cmap or is_cloze:\n            template_map = []\n        else:\n            template_map = self._convert_legacy_map(cmap, len(newModel[\"tmpls\"]))\n\n        self.col._backend.change_notetype(\n            note_ids=nids,\n            new_fields=field_map,\n            new_templates=template_map,\n            old_notetype_name=notetype[\"name\"],\n            old_notetype_id=notetype[\"id\"],\n            new_notetype_id=newModel[\"id\"],\n            current_schema=self.col.db.scalar(\"select scm from col\"),\n            is_cloze=is_cloze,\n        )\n\n    def _convert_legacy_map(\n        self, old_to_new: dict[int, int | None], new_count: int\n    ) -> list[int]:\n        \"Convert old->new map to list of old indexes\"\n        new_to_old = {v: k for k, v in old_to_new.items() if v is not None}\n        out = []\n        for idx in range(new_count):\n            try:\n                val = new_to_old[idx]\n            except KeyError:\n                val = -1\n\n            out.append(val)\n        return out\n\n    # Schema hash\n    ##########################################################################\n\n    def scmhash(self, notetype: NotetypeDict) -> str:\n        \"Return a hash of the schema, to see if models are compatible.\"\n        buf = \"\"\n        for field in notetype[\"flds\"]:\n            buf += field[\"name\"]\n        for template in notetype[\"tmpls\"]:\n            buf += template[\"name\"]\n        return checksum(buf)\n\n    # Legacy\n    ##########################################################################\n\n    @deprecated(info=\"use note.cloze_numbers_in_fields()\")\n    def _availClozeOrds(\n        self, notetype: NotetypeDict, flds: str, allow_empty: bool = True\n    ) -> list[int]:\n        import anki.notes_pb2\n\n        note = anki.notes_pb2.Note(fields=[flds])\n        return list(self.col._backend.cloze_numbers_in_note(note))\n\n    # @deprecated(replaced_by=add_template)\n    def addTemplate(self, notetype: NotetypeDict, template: TemplateDict) -> None:\n        self.add_template(notetype, template)\n        if notetype[\"id\"]:\n            self.update(notetype)\n\n    # @deprecated(replaced_by=remove_template)\n    def remTemplate(self, notetype: NotetypeDict, template: TemplateDict) -> None:\n        self.remove_template(notetype, template)\n        self.update(notetype)\n\n    # @deprecated(replaced_by=reposition_template)\n    def move_template(\n        self, notetype: NotetypeDict, template: TemplateDict, idx: int\n    ) -> None:\n        self.reposition_template(notetype, template, idx)\n        self.update(notetype)\n\n    # @deprecated(replaced_by=add_field)\n    def addField(self, notetype: NotetypeDict, field: FieldDict) -> None:\n        self.add_field(notetype, field)\n        if notetype[\"id\"]:\n            self.update(notetype)\n\n    # @deprecated(replaced_by=remove_field)\n    def remField(self, notetype: NotetypeDict, field: FieldDict) -> None:\n        self.remove_field(notetype, field)\n        self.update(notetype)\n\n    # @deprecated(replaced_by=reposition_field)\n    def moveField(self, notetype: NotetypeDict, field: FieldDict, idx: int) -> None:\n        self.reposition_field(notetype, field, idx)\n        self.update(notetype)\n\n    # @deprecated(replaced_by=rename_field)\n    def renameField(\n        self, notetype: NotetypeDict, field: FieldDict, new_name: str\n    ) -> None:\n        self.rename_field(notetype, field, new_name)\n        self.update(notetype)\n\n    @deprecated(replaced_by=remove)\n    def rem(self, m: NotetypeDict) -> None:\n        \"Delete model, and all its cards/notes.\"\n        self.remove(m[\"id\"])\n\n    # @deprecated(info=\"not needed; is updated on note add\")\n    def set_current(self, m: NotetypeDict) -> None:\n        self.col.set_config(\"curModel\", m[\"id\"])\n\n    @deprecated(replaced_by=all_names_and_ids)\n    def all_names(self) -> list[str]:\n        return [n.name for n in self.all_names_and_ids()]\n\n    @deprecated(replaced_by=all_names_and_ids)\n    def ids(self) -> list[NotetypeId]:\n        return [NotetypeId(n.id) for n in self.all_names_and_ids()]\n\n    @deprecated(info=\"no longer required\")\n    def flush(self) -> None:\n        pass\n\n    # @deprecated(replaced_by=update_dict)\n    def update(\n        self,\n        notetype: NotetypeDict,\n        preserve_usn: bool = True,\n        skip_checks: bool = False,\n    ) -> None:\n        \"Add or update an existing model. Use .update_dict() instead.\"\n        self._remove_from_cache(notetype[\"id\"])\n        self.ensure_name_unique(notetype)\n        notetype[\"id\"] = self.col._backend.add_or_update_notetype(\n            json=to_json_bytes(notetype),\n            preserve_usn_and_mtime=preserve_usn,\n            skip_checks=skip_checks,\n        )\n        self.set_current(notetype)\n        self._mutate_after_write(notetype)\n\n    # @deprecated(replaced_by=update_dict)\n    def save(self, notetype: NotetypeDict | None = None, **legacy_kwargs: bool) -> None:\n        \"Save changes made to provided note type.\"\n        if not notetype:\n            print_deprecation_warning(\n                \"col.models.save() should be passed the changed notetype\"\n            )\n            return\n\n        self.update(notetype, preserve_usn=False)\n"
  },
  {
    "path": "pylib/anki/notes.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport copy\nfrom collections.abc import Sequence\nfrom typing import NewType\n\nimport anki\nimport anki.cards\nimport anki.collection\nimport anki.decks\nimport anki.template\nfrom anki import hooks, notes_pb2\nfrom anki._legacy import DeprecatedNamesMixin, deprecated\nfrom anki.consts import MODEL_STD\nfrom anki.models import NotetypeDict, NotetypeId, TemplateDict\nfrom anki.utils import join_fields\n\nDuplicateOrEmptyResult = notes_pb2.NoteFieldsCheckResponse.State\nNoteFieldsCheckResult = notes_pb2.NoteFieldsCheckResponse.State\nDefaultsForAdding = notes_pb2.DeckAndNotetype\n\n# types\nNoteId = NewType(\"NoteId\", int)\n\n\nclass Note(DeprecatedNamesMixin):\n    # not currently exposed\n    flags = 0\n    data = \"\"\n    id: NoteId\n    mid: NotetypeId\n\n    def __init__(\n        self,\n        col: anki.collection.Collection,\n        model: NotetypeDict | NotetypeId | None = None,\n        id: NoteId | None = None,\n    ) -> None:\n        if model and id:\n            raise Exception(\"only model or id should be provided\")\n        notetype_id = model[\"id\"] if isinstance(model, dict) else model\n        self.col = col.weakref()\n\n        if id:\n            # existing note\n            self.id = id\n            self.load()\n        else:\n            # new note for provided notetype\n            self._load_from_backend_note(self.col._backend.new_note(notetype_id))\n\n    def load(self) -> None:\n        note = self.col._backend.get_note(self.id)\n        assert note\n        self._load_from_backend_note(note)\n\n    def _load_from_backend_note(self, note: notes_pb2.Note) -> None:\n        self.id = NoteId(note.id)\n        self.guid = note.guid\n        self.mid = NotetypeId(note.notetype_id)\n        self.mod = note.mtime_secs\n        self.usn = note.usn\n        self.tags = list(note.tags)\n        self.fields = list(note.fields)\n        self._fmap = self.col.models.field_map(self.note_type())\n\n    def _to_backend_note(self) -> notes_pb2.Note:\n        hooks.note_will_flush(self)\n        return notes_pb2.Note(\n            id=self.id,\n            guid=self.guid,\n            notetype_id=self.mid,\n            mtime_secs=self.mod,\n            usn=self.usn,\n            tags=self.tags,\n            fields=self.fields,\n        )\n\n    @deprecated(info=\"please use col.update_note()\")\n    def flush(self) -> None:\n        \"\"\"For an undo entry, use col.update_note() instead.\"\"\"\n        if self.id == 0:\n            raise Exception(\"can't flush a new note\")\n        self.col._backend.update_notes(\n            notes=[self._to_backend_note()], skip_undo_entry=True\n        )\n\n    def joined_fields(self) -> str:\n        return join_fields(self.fields)\n\n    def ephemeral_card(\n        self,\n        ord: int = 0,\n        *,\n        custom_note_type: NotetypeDict | None = None,\n        custom_template: TemplateDict | None = None,\n        fill_empty: bool = False,\n    ) -> anki.cards.Card:\n        card = anki.cards.Card(self.col)\n        card.ord = ord\n        card.did = anki.decks.DEFAULT_DECK_ID\n\n        if custom_note_type is None:\n            model = self.note_type()\n        else:\n            model = custom_note_type\n        if model is None:\n            raise NotImplementedError\n\n        if custom_template is not None:\n            template = custom_template\n        elif model[\"type\"] == MODEL_STD:\n            template = model[\"tmpls\"][ord]\n        else:\n            template = model[\"tmpls\"][0]\n        template = copy.copy(template)\n        # may differ in cloze case\n        template[\"ord\"] = card.ord\n\n        output = anki.template.TemplateRenderContext.from_card_layout(\n            self,\n            card,\n            notetype=model,\n            template=template,\n            fill_empty=fill_empty,\n        ).render()\n        card.set_render_output(output)\n        card._note = self\n        return card\n\n    def cards(self) -> list[anki.cards.Card]:\n        return [self.col.get_card(id) for id in self.card_ids()]\n\n    def card_ids(self) -> Sequence[anki.cards.CardId]:\n        return self.col.card_ids_of_note(self.id)\n\n    def note_type(self) -> NotetypeDict | None:\n        return self.col.models.get(self.mid)\n\n    _note_type = property(note_type)\n\n    def cloze_numbers_in_fields(self) -> Sequence[int]:\n        return self.col._backend.cloze_numbers_in_note(self._to_backend_note())\n\n    # Dict interface\n    ##################################################\n\n    def keys(self) -> list[str]:\n        return list(self._fmap.keys())\n\n    def values(self) -> list[str]:\n        return self.fields\n\n    def items(self) -> list[tuple[str, str]]:\n        return [(f[\"name\"], self.fields[ord]) for ord, f in sorted(self._fmap.values())]\n\n    def _field_index(self, key: str) -> int:\n        try:\n            return self._fmap[key][0]\n        except Exception as exc:\n            raise KeyError(key) from exc\n\n    def __getitem__(self, key: str) -> str:\n        return self.fields[self._field_index(key)]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.fields[self._field_index(key)] = value\n\n    def __contains__(self, key: str) -> bool:\n        return key in self._fmap\n\n    # Tags\n    ##################################################\n\n    def has_tag(self, tag: str) -> bool:\n        return self.col.tags.in_list(tag, self.tags)\n\n    def remove_tag(self, tag: str) -> None:\n        rem = [tag_ for tag_ in self.tags if tag_.lower() == tag.lower()]\n        for tag_ in rem:\n            self.tags.remove(tag_)\n\n    def add_tag(self, tag: str) -> None:\n        \"Add tag. Duplicates will be stripped on save.\"\n        self.tags.append(tag)\n\n    def string_tags(self) -> str:\n        return self.col.tags.join(self.tags)\n\n    def set_tags_from_str(self, tags: str) -> None:\n        self.tags = self.col.tags.split(tags)\n\n    # Unique/duplicate/cloze check\n    ##################################################\n\n    def fields_check(self) -> NoteFieldsCheckResult.V:\n        return self.col._backend.note_fields_check(self._to_backend_note()).state\n\n    dupeOrEmpty = duplicate_or_empty = fields_check\n\n\nNote.register_deprecated_aliases(\n    delTag=Note.remove_tag, _fieldOrd=Note._field_index, model=Note.note_type\n)\n"
  },
  {
    "path": "pylib/anki/py.typed",
    "content": ""
  },
  {
    "path": "pylib/anki/rsbackend.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n#\n# The backend code has moved into _backend; this file exists only to avoid breaking\n# some add-ons. They should be updated to point to the correct location in the\n# future.\n\n# ruff: noqa: F401\nfrom anki.decks import DeckTreeNode\nfrom anki.errors import InvalidInput, NotFoundError\nfrom anki.lang import TR\nfrom anki.lang import FormatTimeSpan as FormatTimeSpanContext\nfrom anki.utils import from_json_bytes, to_json_bytes\n"
  },
  {
    "path": "pylib/anki/scheduler/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport anki.scheduler.base as _base\n\nUnburyDeck = _base.UnburyDeck\nCongratsInfo = _base.CongratsInfo\nBuryOrSuspend = _base.BuryOrSuspend\nFilteredDeckForUpdate = _base.FilteredDeckForUpdate\nCustomStudyRequest = _base.CustomStudyRequest\n"
  },
  {
    "path": "pylib/anki/scheduler/base.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport anki\nimport anki.collection\nfrom anki import decks_pb2, scheduler_pb2\nfrom anki._legacy import DeprecatedNamesMixin\nfrom anki.cards import Card\nfrom anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId\nfrom anki.config import Config\n\nSchedTimingToday = scheduler_pb2.SchedTimingTodayResponse\nCongratsInfo = scheduler_pb2.CongratsInfoResponse\nUnburyDeck = scheduler_pb2.UnburyDeckRequest\nBuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest\nCustomStudyRequest = scheduler_pb2.CustomStudyRequest\nCustomStudyDefaults = scheduler_pb2.CustomStudyDefaultsResponse\nScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest\nScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse\nFilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate\nRepositionDefaults = scheduler_pb2.RepositionDefaultsResponse\n\nfrom collections.abc import Sequence\nfrom typing import overload\n\nfrom anki import config_pb2\nfrom anki.cards import CardId\nfrom anki.consts import (\n    CARD_TYPE_NEW,\n    NEW_CARDS_RANDOM,\n    QUEUE_TYPE_DAY_LEARN_RELEARN,\n    QUEUE_TYPE_LRN,\n    QUEUE_TYPE_NEW,\n    QUEUE_TYPE_PREVIEW,\n)\nfrom anki.decks import DeckConfigDict, DeckId, DeckTreeNode\nfrom anki.notes import NoteId\nfrom anki.utils import ids2str, int_time\n\n\nclass SchedulerBase(DeprecatedNamesMixin):\n    \"Actions shared between schedulers.\"\n\n    version = 0\n\n    def __init__(self, col: anki.collection.Collection) -> None:\n        self.col = col.weakref()\n\n    def _timing_today(self) -> SchedTimingToday:\n        return self.col._backend.sched_timing_today()\n\n    @property\n    def today(self) -> int:\n        return self._timing_today().days_elapsed\n\n    @property\n    def day_cutoff(self) -> int:\n        return self._timing_today().next_day_at\n\n    def countIdx(self, card: Card) -> int:\n        if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW):\n            return QUEUE_TYPE_LRN\n        return card.queue\n\n    # Deck list\n    ##########################################################################\n\n    @overload\n    def deck_due_tree(self, top_deck_id: None = None) -> DeckTreeNode: ...\n\n    @overload\n    def deck_due_tree(self, top_deck_id: DeckId) -> DeckTreeNode | None: ...\n\n    def deck_due_tree(self, top_deck_id: DeckId | None = None) -> DeckTreeNode | None:\n        \"\"\"Returns a tree of decks with counts.\n        If top_deck_id provided, only the according subtree is returned.\"\"\"\n        tree = self.col._backend.deck_tree(now=int_time())\n        if top_deck_id:\n            return self.col.decks.find_deck_in_tree(tree, top_deck_id)\n        return tree\n\n    # Deck finished state & custom study\n    ##########################################################################\n\n    def congratulations_info(self) -> CongratsInfo:\n        return self.col._backend.congrats_info()\n\n    def have_buried_siblings(self) -> bool:\n        return self.congratulations_info().have_sched_buried\n\n    def have_manually_buried(self) -> bool:\n        return self.congratulations_info().have_user_buried\n\n    def have_buried(self) -> bool:\n        info = self.congratulations_info()\n        return info.have_sched_buried or info.have_user_buried\n\n    def custom_study(self, request: CustomStudyRequest) -> OpChanges:\n        return self.col._backend.custom_study(request)\n\n    def custom_study_defaults(self, deck_id: DeckId) -> CustomStudyDefaults:\n        return self.col._backend.custom_study_defaults(deck_id=deck_id)\n\n    def extend_limits(self, new: int, rev: int) -> None:\n        did = self.col.decks.current()[\"id\"]\n        self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev)\n\n    # fixme: only used by total_rev_for_current_deck and old deck stats;\n    # schedv2 defines separate version\n    def _deck_limit(self) -> str:\n        return ids2str(\n            self.col.decks.deck_and_child_ids(self.col.decks.get_current_id())\n        )\n\n    # Filtered deck handling\n    ##########################################################################\n\n    def rebuild_filtered_deck(self, deck_id: DeckId) -> OpChangesWithCount:\n        return self.col._backend.rebuild_filtered_deck(deck_id)\n\n    def empty_filtered_deck(self, deck_id: DeckId) -> OpChanges:\n        return self.col._backend.empty_filtered_deck(deck_id)\n\n    def get_or_create_filtered_deck(self, deck_id: DeckId) -> FilteredDeckForUpdate:\n        return self.col._backend.get_or_create_filtered_deck(deck_id)\n\n    def add_or_update_filtered_deck(\n        self, deck: FilteredDeckForUpdate\n    ) -> OpChangesWithId:\n        return self.col._backend.add_or_update_filtered_deck(deck)\n\n    def filtered_deck_order_labels(self) -> Sequence[str]:\n        return self.col._backend.filtered_deck_order_labels()\n\n    # Suspending & burying\n    ##########################################################################\n\n    def unsuspend_cards(self, ids: Sequence[CardId]) -> OpChanges:\n        return self.col._backend.restore_buried_and_suspended_cards(ids)\n\n    def unbury_cards(self, ids: Sequence[CardId]) -> OpChanges:\n        return self.col._backend.restore_buried_and_suspended_cards(ids)\n\n    def unbury_deck(\n        self,\n        deck_id: DeckId,\n        mode: UnburyDeck.Mode.V = UnburyDeck.ALL,\n    ) -> OpChanges:\n        return self.col._backend.unbury_deck(deck_id=deck_id, mode=mode)\n\n    def suspend_cards(self, ids: Sequence[CardId]) -> OpChangesWithCount:\n        return self.col._backend.bury_or_suspend_cards(\n            card_ids=ids, note_ids=[], mode=BuryOrSuspend.SUSPEND\n        )\n\n    def suspend_notes(self, ids: Sequence[NoteId]) -> OpChangesWithCount:\n        return self.col._backend.bury_or_suspend_cards(\n            card_ids=[], note_ids=ids, mode=BuryOrSuspend.SUSPEND\n        )\n\n    def bury_cards(\n        self, ids: Sequence[CardId], manual: bool = True\n    ) -> OpChangesWithCount:\n        if manual:\n            mode = BuryOrSuspend.BURY_USER\n        else:\n            mode = BuryOrSuspend.BURY_SCHED\n        return self.col._backend.bury_or_suspend_cards(\n            card_ids=ids, note_ids=[], mode=mode\n        )\n\n    def bury_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount:\n        return self.col._backend.bury_or_suspend_cards(\n            card_ids=[], note_ids=note_ids, mode=BuryOrSuspend.BURY_USER\n        )\n\n    # Resetting/rescheduling\n    ##########################################################################\n\n    def schedule_cards_as_new(\n        self,\n        card_ids: Sequence[CardId],\n        *,\n        restore_position: bool = False,\n        reset_counts: bool = False,\n        context: ScheduleCardsAsNew.Context.V | None = None,\n    ) -> OpChanges:\n        \"Place cards back into the new queue.\"\n        request = ScheduleCardsAsNew(\n            card_ids=card_ids,\n            log=True,\n            restore_position=restore_position,\n            reset_counts=reset_counts,\n            context=context,\n        )\n        return self.col._backend.schedule_cards_as_new(request)\n\n    def schedule_cards_as_new_defaults(\n        self, context: ScheduleCardsAsNew.Context.V\n    ) -> ScheduleCardsAsNewDefaults:\n        return self.col._backend.schedule_cards_as_new_defaults(context)\n\n    def set_due_date(\n        self,\n        card_ids: Sequence[CardId],\n        days: str,\n        config_key: Config.String.V | None = None,\n    ) -> OpChanges:\n        \"\"\"Set cards to be due in `days`, turning them into review cards if necessary.\n        `days` can be of the form '5' or '5-7'\n        If `config_key` is provided, provided days will be remembered in config.\"\"\"\n        key: config_pb2.OptionalStringConfigKey | None\n        if config_key is not None:\n            key = config_pb2.OptionalStringConfigKey(key=config_key)\n        else:\n            key = None\n        return self.col._backend.set_due_date(\n            card_ids=card_ids,\n            days=days,\n            # this value is optional; the auto-generated typing is wrong\n            config_key=key,  # type: ignore\n        )\n\n    def reset_cards(self, ids: list[CardId]) -> None:\n        \"Completely reset cards for export.\"\n        sids = ids2str(ids)\n        assert self.col.db\n        # we want to avoid resetting due number of existing new cards on export\n        non_new = self.col.db.list(\n            f\"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})\"\n            % sids\n        )\n        # reset all cards\n        self.col.db.execute(\n            f\"update cards set reps=0,lapses=0,odid=0,odue=0,queue={QUEUE_TYPE_NEW}\"\n            \" where id in %s\" % sids\n        )\n        # and forget any non-new cards, changing their due numbers\n        request = ScheduleCardsAsNew(card_ids=non_new, log=False, restore_position=True)\n        self.col._backend.schedule_cards_as_new(request)\n\n    # Repositioning new cards\n    ##########################################################################\n\n    def reposition_new_cards(\n        self,\n        card_ids: Sequence[CardId],\n        starting_from: int,\n        step_size: int,\n        randomize: bool,\n        shift_existing: bool,\n    ) -> OpChangesWithCount:\n        return self.col._backend.sort_cards(\n            card_ids=card_ids,\n            starting_from=starting_from,\n            step_size=step_size,\n            randomize=randomize,\n            shift_existing=shift_existing,\n        )\n\n    def reposition_defaults(self) -> RepositionDefaults:\n        return self.col._backend.reposition_defaults()\n\n    def randomize_cards(self, did: DeckId) -> None:\n        self.col._backend.sort_deck(deck_id=did, randomize=True)\n\n    def order_cards(self, did: DeckId) -> None:\n        self.col._backend.sort_deck(deck_id=did, randomize=False)\n\n    def resort_conf(self, conf: DeckConfigDict) -> None:\n        for did in self.col.decks.decks_using_config(conf):\n            if conf[\"new\"][\"order\"] == 0:\n                self.randomize_cards(did)\n            else:\n                self.order_cards(did)\n\n    # for post-import\n    def maybe_randomize_deck(self, did: DeckId | None = None) -> None:\n        if not did:\n            did = self.col.decks.selected()\n        conf = self.col.decks.config_dict_for_deck_id(did)\n        # in order due?\n        if conf[\"new\"][\"order\"] == NEW_CARDS_RANDOM:\n            self.randomize_cards(did)\n\n    def _legacy_sort_cards(\n        self,\n        cids: list[CardId],\n        start: int = 1,\n        step: int = 1,\n        shuffle: bool = False,\n        shift: bool = False,\n    ) -> None:\n        self.reposition_new_cards(cids, start, step, shuffle, shift)\n\n\nSchedulerBase.register_deprecated_attributes(\n    sortCards=(SchedulerBase._legacy_sort_cards, SchedulerBase.reposition_new_cards)\n)\n"
  },
  {
    "path": "pylib/anki/scheduler/dummy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nfrom anki.cards import Card\nfrom anki.decks import DeckId\nfrom anki.scheduler.legacy import SchedulerBaseWithLegacy\n\n\nclass DummyScheduler(SchedulerBaseWithLegacy):\n    reps = 0\n\n    def reset(self) -> None:\n        pass\n\n    def getCard(self) -> Card | None:\n        raise Exception(\"v1/v2 scheduler no longer supported\")\n\n    def answerCard(self, card: Card, ease: int) -> None:\n        raise Exception(\"v1/v2 scheduler no longer supported\")\n\n    def _is_finished(self) -> bool:\n        return False\n\n    @property\n    def active_decks(self) -> list[DeckId]:\n        return []\n\n    def counts(self) -> tuple[int, int, int]:\n        return (0, 0, 0)\n"
  },
  {
    "path": "pylib/anki/scheduler/legacy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nfrom anki._legacy import deprecated\nfrom anki.cards import Card, CardId\nfrom anki.consts import (\n    CARD_TYPE_RELEARNING,\n    QUEUE_TYPE_DAY_LEARN_RELEARN,\n    QUEUE_TYPE_REV,\n)\nfrom anki.decks import DeckConfigDict, DeckId\nfrom anki.notes import NoteId\nfrom anki.scheduler.base import SchedulerBase, UnburyDeck\nfrom anki.utils import from_json_bytes, ids2str\n\n\nclass SchedulerBaseWithLegacy(SchedulerBase):\n    \"Legacy aliases and helpers. These will go away in the future.\"\n\n    def reschedCards(\n        self, card_ids: list[CardId], min_interval: int, max_interval: int\n    ) -> None:\n        self.set_due_date(card_ids, f\"{min_interval}-{max_interval}!\")\n\n    def buryNote(self, nid: NoteId) -> None:\n        note = self.col.get_note(nid)\n        self.bury_cards(note.card_ids())\n\n    def unburyCards(self) -> None:\n        print(\"please use unbury_cards() or unbury_deck() instead of unburyCards()\")\n        self.unbury_deck(self.col.decks.get_current_id())\n\n    def unburyCardsForDeck(self, type: str = \"all\") -> None:\n        print(\"please use unbury_deck() instead of unburyCardsForDeck()\")\n        if type == \"all\":\n            mode = UnburyDeck.ALL\n        elif type == \"manual\":\n            mode = UnburyDeck.USER_ONLY\n        else:  # elif type == \"siblings\":\n            mode = UnburyDeck.SCHED_ONLY\n        self.unbury_deck(self.col.decks.get_current_id(), mode)\n\n    def finishedMsg(self) -> str:\n        print(\"finishedMsg() is obsolete\")\n        return \"\"\n\n    def _nextDueMsg(self) -> str:\n        print(\"_nextDueMsg() is obsolete\")\n        return \"\"\n\n    def rebuildDyn(self, did: DeckId | None = None) -> int | None:\n        did = did or self.col.decks.selected()\n        count = self.rebuild_filtered_deck(did).count or None\n        if not count:\n            return None\n        # and change to our new deck\n        self.col.decks.select(did)\n        return count\n\n    def emptyDyn(self, did: DeckId | None, lim: str | None = None) -> None:\n        if lim is None:\n            self.empty_filtered_deck(did)\n            return\n\n        queue = f\"\"\"\nqueue = (case when queue < 0 then queue\n              when type in (1,{CARD_TYPE_RELEARNING}) then\n  (case when (case when odue then odue else due end) > 1000000000 then 1 else\n  {QUEUE_TYPE_DAY_LEARN_RELEARN} end)\nelse\n  type\nend)\n\"\"\"\n        self.col.db.execute(\n            f\"\"\"\nupdate cards set did = odid, {queue},\ndue = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? where {lim}\"\"\",\n            self.col.usn(),\n        )\n\n    def remFromDyn(self, cids: list[CardId]) -> None:\n        self.emptyDyn(None, f\"id in {ids2str(cids)} and odid\")\n\n    # used by v2 scheduler and some add-ons\n    def update_stats(\n        self,\n        deck_id: DeckId,\n        new_delta: int = 0,\n        review_delta: int = 0,\n        milliseconds_delta: int = 0,\n    ) -> None:\n        self.col._backend.update_stats(\n            deck_id=deck_id,\n            new_delta=new_delta,\n            review_delta=review_delta,\n            millisecond_delta=milliseconds_delta,\n        )\n\n    def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None:\n        did = card.did\n        if type == \"new\":\n            self.update_stats(did, new_delta=cnt)\n        elif type == \"rev\":\n            self.update_stats(did, review_delta=cnt)\n        elif type == \"time\":\n            self.update_stats(did, milliseconds_delta=cnt)\n\n    def deckDueTree(self) -> list:\n        \"List of (base name, did, rev, lrn, new, children)\"\n        print(\n            \"deckDueTree() is deprecated; use decks.deck_tree() for a tree without counts, or sched.deck_due_tree()\"\n        )\n        return from_json_bytes(self.col._backend.deck_tree_legacy())[5]\n\n    @deprecated(info=\"no longer used by Anki; will be removed in the future\")\n    def total_rev_for_current_deck(self) -> int:\n        assert self.col.db\n        return self.col.db.scalar(\n            f\"\"\"\nselect count() from cards where id in (\nselect id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit 9999)\"\"\"\n            % self._deck_limit(),\n            self.today,\n        )\n\n    def answerButtons(self, card: Card) -> int:\n        return 4\n\n    # legacy in v3 but used by unit tests; redefined in v2/v1\n\n    def _cardConf(self, card: Card) -> DeckConfigDict:\n        return self.col.decks.config_dict_for_deck_id(card.did)\n\n    def _fuzzIvlRange(self, ivl: int) -> tuple[int, int]:\n        return (ivl, ivl)\n\n    # simple aliases\n    unsuspendCards = SchedulerBase.unsuspend_cards\n    buryCards = SchedulerBase.bury_cards\n    suspendCards = SchedulerBase.suspend_cards\n    forgetCards = SchedulerBase.schedule_cards_as_new\n"
  },
  {
    "path": "pylib/anki/scheduler/v3.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\n\"\"\"\nThe V3/2021 scheduler.\n\nhttps://faqs.ankiweb.net/the-2021-scheduler.html\n\nIt uses the same DB schema as the V2 scheduler, and 'schedVer' remains\nas '2' internally.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\nfrom typing import Any, Literal\n\nfrom anki import frontend_pb2, scheduler_pb2\nfrom anki._legacy import deprecated\nfrom anki.cards import Card\nfrom anki.collection import OpChanges\nfrom anki.consts import *\nfrom anki.decks import DeckId\nfrom anki.errors import DBError\nfrom anki.scheduler.legacy import SchedulerBaseWithLegacy\nfrom anki.types import assert_exhaustive\nfrom anki.utils import int_time\n\nQueuedCards = scheduler_pb2.QueuedCards\nSchedulingState = scheduler_pb2.SchedulingState\nSchedulingStates = scheduler_pb2.SchedulingStates\nSchedulingContext = scheduler_pb2.SchedulingContext\nSchedulingStatesWithContext = frontend_pb2.SchedulingStatesWithContext\nSetSchedulingStatesRequest = frontend_pb2.SetSchedulingStatesRequest\nCardAnswer = scheduler_pb2.CardAnswer\n\n\nclass Scheduler(SchedulerBaseWithLegacy):\n    version = 3\n\n    # don't rely on this, it will likely be removed in the future\n    reps = 0\n\n    # Fetching the next card\n    ##########################################################################\n\n    def get_queued_cards(\n        self,\n        *,\n        fetch_limit: int = 1,\n        intraday_learning_only: bool = False,\n    ) -> QueuedCards:\n        \"Returns zero or more pending cards, and the remaining counts. Idempotent.\"\n        return self.col._backend.get_queued_cards(\n            fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only\n        )\n\n    def describe_next_states(self, next_states: SchedulingStates) -> Sequence[str]:\n        \"Labels for each of the answer buttons.\"\n        return self.col._backend.describe_next_states(next_states)\n\n    # Answering a card\n    ##########################################################################\n\n    def build_answer(\n        self,\n        *,\n        card: Card,\n        states: SchedulingStates,\n        rating: CardAnswer.Rating.V,\n    ) -> CardAnswer:\n        \"Build input for answer_card().\"\n        if rating == CardAnswer.AGAIN:\n            new_state = states.again\n        elif rating == CardAnswer.HARD:\n            new_state = states.hard\n        elif rating == CardAnswer.GOOD:\n            new_state = states.good\n        elif rating == CardAnswer.EASY:\n            new_state = states.easy\n        else:\n            raise Exception(\"invalid rating\")\n\n        return CardAnswer(\n            card_id=card.id,\n            current_state=states.current,\n            new_state=new_state,\n            rating=rating,\n            answered_at_millis=int_time(1000),\n            milliseconds_taken=card.time_taken(capped=False),\n        )\n\n    def answer_card(self, input: CardAnswer) -> OpChanges:\n        \"Update card to provided state, and remove it from queue.\"\n        self.reps += 1\n        op_bytes = self.col._backend.answer_card_raw(input.SerializeToString())\n        return OpChanges.FromString(op_bytes)\n\n    def state_is_leech(self, new_state: SchedulingState) -> bool:\n        \"True if new state marks the card as a leech.\"\n        return self.col._backend.state_is_leech(new_state)\n\n    # Fetching the next card (legacy API)\n    ##########################################################################\n\n    @deprecated(info=\"no longer required\")\n    def reset(self) -> None:\n        # backend automatically resets queues as operations are performed\n        pass\n\n    def getCard(self) -> Card | None:\n        \"\"\"Fetch the next card from the queue. None if finished.\"\"\"\n        try:\n            queued_card = self.get_queued_cards().cards[0]\n        except IndexError:\n            return None\n\n        card = Card(self.col)\n        card._load_from_backend_card(queued_card.card)\n        card.start_timer()\n        return card\n\n    def _is_finished(self) -> bool:\n        \"Don't use this, it is a stop-gap until this code is refactored.\"\n        return not self.get_queued_cards().cards\n\n    def counts(self, card: Card | None = None) -> tuple[int, int, int]:\n        info = self.get_queued_cards()\n        return (info.new_count, info.learning_count, info.review_count)\n\n    @property\n    def newCount(self) -> int:\n        return self.counts()[0]\n\n    @property\n    def lrnCount(self) -> int:\n        return self.counts()[1]\n\n    @property\n    def reviewCount(self) -> int:\n        return self.counts()[2]\n\n    def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str:\n        \"Return the next interval for CARD as a string.\"\n        states = self.col._backend.get_scheduling_states(card.id)\n        return self.col._backend.describe_next_states(states)[ease - 1]\n\n    # Answering a card (legacy API)\n    ##########################################################################\n\n    def answerCard(self, card: Card, ease: Literal[1, 2, 3, 4]) -> OpChanges:\n        if ease == BUTTON_ONE:\n            rating = CardAnswer.AGAIN\n        elif ease == BUTTON_TWO:\n            rating = CardAnswer.HARD\n        elif ease == BUTTON_THREE:\n            rating = CardAnswer.GOOD\n        elif ease == BUTTON_FOUR:\n            rating = CardAnswer.EASY\n        else:\n            raise Exception(\"invalid ease\")\n\n        states = self.col._backend.get_scheduling_states(card.id)\n        changes = self.answer_card(\n            self.build_answer(card=card, states=states, rating=rating)\n        )\n\n        # tests assume card will be mutated, so we need to reload it\n        card.load()\n\n        return changes\n\n    # Next times (legacy API)\n    ##########################################################################\n    # fixme: move these into tests_schedv2 in the future\n\n    def _interval_for_state(self, state: scheduler_pb2.SchedulingState) -> int:\n        kind = state.WhichOneof(\"kind\")\n        if kind == \"normal\":\n            return self._interval_for_normal_state(state.normal)\n        elif kind == \"filtered\":\n            return self._interval_for_filtered_state(state.filtered)\n        else:\n            assert_exhaustive(kind)\n            return 0\n\n    def _interval_for_normal_state(\n        self, normal: scheduler_pb2.SchedulingState.Normal\n    ) -> int:\n        kind = normal.WhichOneof(\"kind\")\n        if kind == \"new\":\n            return 0\n        elif kind == \"review\":\n            return normal.review.scheduled_days * 86400\n        elif kind == \"learning\":\n            return normal.learning.scheduled_secs\n        elif kind == \"relearning\":\n            return normal.relearning.learning.scheduled_secs\n        else:\n            assert_exhaustive(kind)\n            return 0\n\n    def _interval_for_filtered_state(\n        self, filtered: scheduler_pb2.SchedulingState.Filtered\n    ) -> int:\n        kind = filtered.WhichOneof(\"kind\")\n        if kind == \"preview\":\n            return filtered.preview.scheduled_secs\n        elif kind == \"rescheduling\":\n            return self._interval_for_normal_state(filtered.rescheduling.original_state)\n        else:\n            assert_exhaustive(kind)\n            return 0\n\n    def nextIvl(self, card: Card, ease: int) -> Any:\n        \"Don't use this - it is only required by tests, and will be moved in the future.\"\n        states = self.col._backend.get_scheduling_states(card.id)\n        if ease == BUTTON_ONE:\n            new_state = states.again\n        elif ease == BUTTON_TWO:\n            new_state = states.hard\n        elif ease == BUTTON_THREE:\n            new_state = states.good\n        elif ease == BUTTON_FOUR:\n            new_state = states.easy\n        else:\n            raise Exception(\"invalid ease\")\n\n        return self._interval_for_state(new_state)\n\n    # Other legacy\n    ###################\n\n    # called by col.decks.active(), which add-ons are using\n    @property\n    def active_decks(self) -> list[DeckId]:\n        try:\n            return self.col.db.list(\"select id from active_decks\")\n        except DBError:\n            return []\n"
  },
  {
    "path": "pylib/anki/sound.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nSound/TTS references extracted from card text.\n\nThese can be accessed via eg card.question_av_tags()\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport os.path\nimport re\nfrom dataclasses import dataclass\nfrom typing import Union\n\nfrom anki import hooks\n\n\n@dataclass\nclass TTSTag:\n    \"\"\"Records information about a text to speech tag.\n\n    See tts.py for more information.\n    \"\"\"\n\n    field_text: str\n    lang: str\n    voices: list[str]\n    speed: float\n    # each arg should be in the form 'foo=bar'\n    other_args: list[str]\n\n\n@dataclass\nclass SoundOrVideoTag:\n    \"\"\"Contains the filename inside a [sound:...] tag.\n\n    Video files also use [sound:...].\n\n    SECURITY: We should only ever construct this with basename(filename),\n    as passing arbitrary paths to mpv from a shared deck is a security issue.\n\n    Anki add-ons can supply an absolute file path to play any file on disk\n    using the built-in media player.\n    \"\"\"\n\n    filename: str\n\n    def path(self, media_folder: str) -> str:\n        \"Prepend the media folder to the filename.\"\n        if os.path.basename(self.filename) == self.filename:\n            # Path in the current collection's media folder.\n            # Turn it into a fully-qualified path so mpv can find it, and to\n            # ensure the filename doesn't get treated like a non-file scheme.\n            head, tail = media_folder, self.filename\n        else:\n            # Add-ons can use absolute paths to play arbitrary files on disk.\n            # Example: sound.av_player.play_tags([SoundOrVideoTag(\"/path/to/file\")])\n            head, tail = os.path.split(os.path.abspath(self.filename))\n        tail = hooks.media_file_filter(tail)\n        return os.path.join(head, tail)\n\n\n# note this does not include image tags, which are handled with HTML.\nAVTag = Union[SoundOrVideoTag, TTSTag]\n\nAV_REF_RE = re.compile(r\"\\[anki:(play:(.):(\\d+))\\]\")\n\n\ndef strip_av_refs(text: str) -> str:\n    return AV_REF_RE.sub(\"\", text)\n"
  },
  {
    "path": "pylib/anki/stats.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\nfrom __future__ import annotations\n\nimport json\nimport random\nimport time\nfrom collections.abc import Sequence\nfrom typing import Any\n\nimport anki.cards\nimport anki.collection\nfrom anki.consts import *\nfrom anki.lang import FormatTimeSpan\nfrom anki.utils import base62, ids2str\n\n# Card stats\n##########################################################################\n\n_legacy_nightmode = False\n\n\ndef _legacy_card_stats(\n    col: anki.collection.Collection, card_id: anki.cards.CardId, include_revlog: bool\n) -> str:\n    \"A quick hack to preserve compatibility with the old HTML string API.\"\n    random_id = f\"cardinfo-{base62(random.randint(0, 2**64 - 1))}\"\n    varName = random_id.replace(\"-\", \"\")\n    return f\"\"\"\n<div id=\"{random_id}\"></div>\n<script src=\"js/vendor/bootstrap.bundle.min.js\"></script>\n<link href=\"pages/card-info-base.css\" rel=\"stylesheet\" />\n<link href=\"pages/card-info.css\" rel=\"stylesheet\" />\n<script src=\"pages/card-info.js\"></script>\n<script>\n    if ({1 if _legacy_nightmode else 0}) {{\n        document.documentElement.className = \"night-mode\";\n    }}\n    const {varName} = anki.setupCardInfo(document.getElementById('{random_id}'));\n    {varName}.then((c) => c.$set({{ cardId: {card_id}, includeRevlog: {str(include_revlog).lower()} }}));\n</script>\n    \"\"\"\n\n\nclass CardStats:\n    \"\"\"Do not use - this class is only left around for backwards compatibility.\"\"\"\n\n    def __init__(self, col: anki.collection.Collection, card: anki.cards.Card) -> None:\n        if col:\n            self.col = col.weakref()\n        self.card = card\n        self.txt = \"\"\n\n    def report(self, include_revlog: bool = False) -> str:\n        return _legacy_card_stats(self.col, self.card.id, include_revlog)\n\n    # legacy\n\n    def addLine(self, k: str, v: int | str) -> None:\n        self.txt += self.makeLine(k, v)\n\n    def makeLine(self, k: str, v: str | int) -> str:\n        txt = \"<tr><td align=start style='padding-right: 3px;'>\"\n        txt += f\"<b>{k}</b></td><td>{v}</td></tr>\"\n        return txt\n\n    def date(self, tm: float) -> str:\n        return time.strftime(\"%Y-%m-%d\", time.localtime(tm))\n\n    def time(self, tm: float) -> str:\n        return self.col.format_timespan(tm, context=FormatTimeSpan.PRECISE)\n\n\n# Collection stats\n##########################################################################\n\nPERIOD_MONTH = 0\nPERIOD_YEAR = 1\nPERIOD_LIFE = 2\n\ncolYoung = \"#7c7\"\ncolMature = \"#070\"\ncolCum = \"rgba(0,0,0,0.9)\"\ncolLearn = \"#00F\"\ncolRelearn = \"#c00\"\ncolCram = \"#ff0\"\ncolIvl = \"#077\"\ncolHour = \"#ccc\"\ncolTime = \"#770\"\ncolUnseen = \"#000\"\ncolSusp = \"#ff0\"\n\n\nclass CollectionStats:\n    def __init__(self, col: anki.collection.Collection) -> None:\n        self.col = col.weakref()\n        self._stats = None\n        self.type = PERIOD_MONTH\n        self.width = 600\n        self.height = 200\n        self.wholeCollection = False\n\n    # assumes jquery & plot are available in document\n    def report(self, type: int = PERIOD_MONTH) -> str:\n        # 0=month, 1=year, 2=deck life\n        self.type = type\n        from .statsbg import bg\n\n        txt = self.css % bg\n        txt += self._section(self.todayStats())\n        txt += self._section(self.dueGraph())\n        txt += self.repsGraphs()\n        txt += self._section(self.introductionGraph())\n        txt += self._section(self.ivlGraph())\n        txt += self._section(self.hourGraph())\n        txt += self._section(self.easeGraph())\n        txt += self._section(self.cardGraph())\n        txt += self._section(self.footer())\n        return \"<center>%s</center>\" % txt\n\n    def _section(self, txt: str) -> str:\n        return \"<div class=section>%s</div>\" % txt\n\n    css = \"\"\"\n<style>\nh1 { margin-bottom: 0; margin-top: 1em; }\n.pielabel { text-align:center; padding:0px; color:white; }\nbody:not(.night_mode) {background-image: url(data:image/png;base64,%s); }\n@media print {\n    .section { page-break-inside: avoid; padding-top: 5mm; }\n}\nbody { direction: ltr !important; }\n</style>\n\"\"\"\n\n    # Today stats\n    ######################################################################\n\n    def todayStats(self) -> str:\n        b = self._title(\"Today\")\n        # studied today\n        lim = self._revlogLimit()\n        if lim:\n            lim = \" and \" + lim\n        cards, thetime, failed, lrn, rev, relrn, filt = self.col.db.first(\n            f\"\"\"\nselect count(), sum(time)/1000,\nsum(case when ease = 1 then 1 else 0 end), /* failed */\nsum(case when type = {REVLOG_LRN} then 1 else 0 end), /* learning */\nsum(case when type = {REVLOG_REV} then 1 else 0 end), /* review */\nsum(case when type = {REVLOG_RELRN} then 1 else 0 end), /* relearn */\nsum(case when type = {REVLOG_CRAM} then 1 else 0 end) /* filter */\nfrom revlog where type != {REVLOG_RESCHED} and id > ? \"\"\"\n            + lim,\n            (self.col.sched.day_cutoff - 86400) * 1000,\n        )\n        cards = cards or 0\n        thetime = thetime or 0\n        failed = failed or 0\n        lrn = lrn or 0\n        rev = rev or 0\n        relrn = relrn or 0\n        filt = filt or 0\n\n        # studied\n        def bold(s: str) -> str:\n            return \"<b>\" + str(s) + \"</b>\"\n\n        if cards:\n            b += self.col._backend.studied_today_message(\n                cards=cards, seconds=float(thetime)\n            )\n            # again/pass count\n            b += \"<br>\" + \"Again count: %s\" % bold(str(failed))\n            if cards:\n                b += \" \" + \"(%s correct)\" % bold(\n                    \"%0.1f%%\" % ((1 - failed / float(cards)) * 100)\n                )\n            # type breakdown\n            b += \"<br>\"\n            b += \"Learn: %(a)s, Review: %(b)s, Relearn: %(c)s, Filtered: %(d)s\" % dict(\n                a=bold(str(lrn)),\n                b=bold(str(rev)),\n                c=bold(str(relrn)),\n                d=bold(str(filt)),\n            )\n            # mature today\n            mcnt, msum = self.col.db.first(\n                \"\"\"\n    select count(), sum(case when ease = 1 then 0 else 1 end) from revlog\n    where lastIvl >= 21 and id > ?\"\"\"\n                + lim,\n                (self.col.sched.day_cutoff - 86400) * 1000,\n            )\n            b += \"<br>\"\n            if mcnt:\n                b += \"Correct answers on mature cards: %(a)d/%(b)d (%(c).1f%%)\" % dict(\n                    a=msum, b=mcnt, c=(msum / float(mcnt) * 100)\n                )\n            else:\n                b += \"No mature cards were studied today.\"\n        else:\n            b += \"No cards have been studied today.\"\n        return b\n\n    # Due and cumulative due\n    ######################################################################\n\n    def get_start_end_chunk(self, by: str = \"review\") -> tuple[int, int | None, int]:\n        start = 0\n        if self.type == PERIOD_MONTH:\n            end, chunk = 31, 1\n        elif self.type == PERIOD_YEAR:\n            end, chunk = 52, 7\n        else:  #  self.type == 2:\n            end = None\n            if self._deckAge(by) <= 100:\n                chunk = 1\n            elif self._deckAge(by) <= 700:\n                chunk = 7\n            else:\n                chunk = 31\n        return start, end, chunk\n\n    def dueGraph(self) -> str:\n        start, end, chunk = self.get_start_end_chunk()\n        d = self._due(start, end, chunk)\n        yng = []\n        mtr = []\n        tot = 0\n        totd = []\n        for day in d:\n            yng.append((day[0], day[1]))\n            mtr.append((day[0], day[2]))\n            tot += day[1] + day[2]\n            totd.append((day[0], tot))\n        data: Any = [\n            dict(data=mtr, color=colMature, label=\"Mature\"),\n            dict(data=yng, color=colYoung, label=\"Young\"),\n        ]\n        if len(totd) > 1:\n            data.append(\n                dict(\n                    data=totd,\n                    color=colCum,\n                    label=\"Cumulative\",\n                    yaxis=2,\n                    bars={\"show\": False},\n                    lines=dict(show=True),\n                    stack=False,\n                )\n            )\n        txt = self._title(\"Forecast\", \"The number of reviews due in the future.\")\n        xaxis = dict(tickDecimals=0, min=-0.5)\n        if end is not None:\n            xaxis[\"max\"] = end - 0.5\n        txt += self._graph(\n            id=\"due\",\n            data=data,\n            xunit=chunk,\n            ylabel2=\"Cumulative Cards\",\n            conf=dict(\n                xaxis=xaxis,\n                yaxes=[dict(min=0), dict(min=0, tickDecimals=0, position=\"right\")],\n            ),\n        )\n        txt += self._dueInfo(tot, len(totd) * chunk)\n        return txt\n\n    def _dueInfo(self, tot: int, num: int) -> str:\n        i: list[str] = []\n        self._line(\n            i,\n            \"Total\",\n            self.col.tr.statistics_reviews(reviews=tot),\n        )\n        self._line(i, \"Average\", self._avgDay(tot, num, \"reviews\"))\n        tomorrow = self.col.db.scalar(\n            f\"\"\"\nselect count() from cards where did in %s and queue in ({QUEUE_TYPE_REV},{QUEUE_TYPE_DAY_LEARN_RELEARN})\nand due = ?\"\"\"\n            % self._limit(),\n            self.col.sched.today + 1,\n        )\n        tomorrow = \"%d cards\" % tomorrow\n        self._line(i, \"Due tomorrow\", tomorrow)\n        return self._lineTbl(i)\n\n    def _due(\n        self, start: int | None = None, end: int | None = None, chunk: int = 1\n    ) -> Any:\n        lim = \"\"\n        if start is not None:\n            lim += \" and due-%d >= %d\" % (self.col.sched.today, start)\n        if end is not None:\n            lim += \" and day < %d\" % end\n        return self.col.db.all(\n            f\"\"\"\nselect (due-?)/? as day,\nsum(case when ivl < 21 then 1 else 0 end), -- yng\nsum(case when ivl >= 21 then 1 else 0 end) -- mtr\nfrom cards\nwhere did in %s and queue in ({QUEUE_TYPE_REV},{QUEUE_TYPE_DAY_LEARN_RELEARN})\n%s\ngroup by day order by day\"\"\"\n            % (self._limit(), lim),\n            self.col.sched.today,\n            chunk,\n        )\n\n    # Added, reps and time spent\n    ######################################################################\n\n    def introductionGraph(self) -> str:\n        start, days, chunk = self.get_start_end_chunk()\n        data = self._added(days, chunk)\n        if not data:\n            return \"\"\n        conf: dict[str, Any] = dict(\n            xaxis=dict(tickDecimals=0, max=0.5),\n            yaxes=[dict(min=0), dict(position=\"right\", min=0)],\n        )\n        if days is not None:\n            conf[\"xaxis\"][\"min\"] = -days + 0.5\n\n        def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str:\n            return self._graph(\n                id, data=data, conf=conf, xunit=chunk, ylabel=ylabel, ylabel2=ylabel2\n            )\n\n        # graph\n        repdata, repsum = self._splitRepData(data, ((1, colLearn, \"\"),))\n        txt = self._title(\"Added\", \"The number of new cards you have added.\")\n        txt += plot(\"intro\", repdata, ylabel=\"Cards\", ylabel2=\"Cumulative Cards\")\n        # total and per day average\n        tot = sum(i[1] for i in data)\n        period = self._periodDays()\n        if not period:\n            # base off date of earliest added card\n            period = self._deckAge(\"add\")\n        i: list[str] = []\n        self._line(i, \"Total\", \"%d cards\" % tot)\n        self._line(i, \"Average\", self._avgDay(tot, period, \"cards\"))\n        txt += self._lineTbl(i)\n\n        return txt\n\n    def repsGraphs(self) -> str:\n        start, days, chunk = self.get_start_end_chunk()\n        data = self._done(days, chunk)\n        if not data:\n            return \"\"\n        conf: dict[str, Any] = dict(\n            xaxis=dict(tickDecimals=0, max=0.5),\n            yaxes=[dict(min=0), dict(position=\"right\", min=0)],\n        )\n        if days is not None:\n            conf[\"xaxis\"][\"min\"] = -days + 0.5\n\n        def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str:\n            return self._graph(\n                id, data=data, conf=conf, xunit=chunk, ylabel=ylabel, ylabel2=ylabel2\n            )\n\n        # reps\n        (repdata, repsum) = self._splitRepData(\n            data,\n            (\n                (3, colMature, \"Mature\"),\n                (2, colYoung, \"Young\"),\n                (4, colRelearn, \"Relearn\"),\n                (1, colLearn, \"Learn\"),\n                (5, colCram, \"Cram\"),\n            ),\n        )\n        txt1 = self._title(\"Review Count\", \"The number of questions you have answered.\")\n        txt1 += plot(\"reps\", repdata, ylabel=\"Answers\", ylabel2=\"Cumulative Answers\")\n        (daysStud, fstDay) = self._daysStudied()\n        rep, tot = self._ansInfo(repsum, daysStud, fstDay, \"reviews\")\n        txt1 += rep\n        # time\n        (timdata, timsum) = self._splitRepData(\n            data,\n            (\n                (8, colMature, \"Mature\"),\n                (7, colYoung, \"Young\"),\n                (9, colRelearn, \"Relearn\"),\n                (6, colLearn, \"Learn\"),\n                (10, colCram, \"Cram\"),\n            ),\n        )\n        if self.type == PERIOD_MONTH:\n            t = \"Minutes\"\n            convHours = False\n        else:\n            t = \"Hours\"\n            convHours = True\n        txt2 = self._title(\"Review Time\", \"The time taken to answer the questions.\")\n        txt2 += plot(\"time\", timdata, ylabel=t, ylabel2=\"Cumulative %s\" % t)\n        rep, tot2 = self._ansInfo(\n            timsum, daysStud, fstDay, \"minutes\", convHours, total=tot\n        )\n        txt2 += rep\n        return self._section(txt1) + self._section(txt2)\n\n    def _ansInfo(\n        self,\n        totd: list[tuple[int, float]],\n        studied: int,\n        first: int,\n        unit: str,\n        convHours: bool = False,\n        total: int | None = None,\n    ) -> tuple[str, int]:\n        assert totd\n        tot = totd[-1][1]\n        period = self._periodDays()\n        if not period:\n            # base off earliest repetition date\n            period = self._deckAge(\"review\")\n        i: list[str] = []\n        self._line(\n            i,\n            \"Days studied\",\n            \"<b>%(pct)d%%</b> (%(x)s of %(y)s)\"\n            % dict(x=studied, y=period, pct=studied / float(period) * 100),\n            bold=False,\n        )\n        if convHours:\n            tunit = \"hours\"\n        else:\n            tunit = unit\n        # T: unit: can be hours, minutes, reviews... tot: the number of unit.\n        self._line(i, \"Total\", \"%(tot)s %(unit)s\" % dict(unit=tunit, tot=int(tot)))\n        if convHours:\n            # convert to minutes\n            tot *= 60\n        self._line(i, \"Average for days studied\", self._avgDay(tot, studied, unit))\n        if studied != period:\n            # don't display if you did study every day\n            self._line(i, \"If you studied every day\", self._avgDay(tot, period, unit))\n        if total and tot:\n            perMin = total / float(tot)\n            average_secs = (tot * 60) / total\n            self._line(\n                i,\n                \"Average answer time\",\n                self.col.tr.statistics_average_answer_time(\n                    average_seconds=average_secs, cards_per_minute=perMin\n                ),\n            )\n        return self._lineTbl(i), int(tot)\n\n    def _splitRepData(\n        self,\n        data: list[tuple[Any, ...]],\n        spec: Sequence[tuple[int, str, str]],\n    ) -> tuple[list[dict[str, Any]], list[tuple[Any, Any]]]:\n        sep: dict[int, Any] = {}\n        totcnt = {}\n        totd: dict[int, Any] = {}\n        alltot = []\n        allcnt: float = 0\n        for n, col, lab in spec:\n            totcnt[n] = 0.0\n            totd[n] = []\n        for row in data:\n            for n, col, lab in spec:\n                if n not in sep:\n                    sep[n] = []\n                sep[n].append((row[0], row[n]))\n                totcnt[n] += row[n]\n                allcnt += row[n]\n                totd[n].append((row[0], totcnt[n]))\n            alltot.append((row[0], allcnt))\n        ret = []\n        for n, col, lab in spec:\n            if len(totd[n]) and totcnt[n]:\n                # bars\n                ret.append(dict(data=sep[n], color=col, label=lab))\n                # lines\n                ret.append(\n                    dict(\n                        data=totd[n],\n                        color=col,\n                        label=None,\n                        yaxis=2,\n                        bars={\"show\": False},\n                        lines=dict(show=True),\n                        stack=-n,\n                    )\n                )\n        return (ret, alltot)\n\n    def _added(self, num: int | None = 7, chunk: int = 1) -> Any:\n        lims = []\n        if num is not None:\n            lims.append(\n                \"id > %d\" % ((self.col.sched.day_cutoff - (num * chunk * 86400)) * 1000)\n            )\n        lims.append(\"did in %s\" % self._limit())\n        if lims:\n            lim = \"where \" + \" and \".join(lims)\n        else:\n            lim = \"\"\n        if self.type == PERIOD_MONTH:\n            tf = 60.0  # minutes\n        else:\n            tf = 3600.0  # hours\n        return self.col.db.all(\n            \"\"\"\nselect\n(cast((id/1000.0 - ?) / 86400.0 as int))/? as day,\ncount(id)\nfrom cards %s\ngroup by day order by day\"\"\"\n            % lim,\n            self.col.sched.day_cutoff,\n            chunk,\n        )\n\n    def _done(self, num: int | None = 7, chunk: int = 1) -> Any:\n        lims = []\n        if num is not None:\n            lims.append(\n                \"id > %d\" % ((self.col.sched.day_cutoff - (num * chunk * 86400)) * 1000)\n            )\n        lim = self._revlogLimit()\n        if lim:\n            lims.append(lim)\n        if lims:\n            lim = \"where \" + \" and \".join(lims)\n        else:\n            lim = \"\"\n        if self.type == PERIOD_MONTH:\n            tf = 60.0  # minutes\n        else:\n            tf = 3600.0  # hours\n        return self.col.db.all(\n            f\"\"\"\nselect\n(cast((id/1000.0 - ?) / 86400.0 as int))/? as day,\nsum(case when type = {REVLOG_LRN} then 1 else 0 end), -- lrn count\nsum(case when type = {REVLOG_REV} and lastIvl < 21 then 1 else 0 end), -- yng count\nsum(case when type = {REVLOG_REV} and lastIvl >= 21 then 1 else 0 end), -- mtr count\nsum(case when type = {REVLOG_RELRN} then 1 else 0 end), -- lapse count\nsum(case when type = {REVLOG_CRAM} then 1 else 0 end), -- cram count\nsum(case when type = {REVLOG_LRN} then time/1000.0 else 0 end)/?, -- lrn time\n-- yng + mtr time\nsum(case when type = {REVLOG_REV} and lastIvl < 21 then time/1000.0 else 0 end)/?,\nsum(case when type = {REVLOG_REV} and lastIvl >= 21 then time/1000.0 else 0 end)/?,\nsum(case when type = {REVLOG_RELRN} then time/1000.0 else 0 end)/?, -- lapse time\nsum(case when type = {REVLOG_CRAM} then time/1000.0 else 0 end)/? -- cram time\nfrom revlog %s\ngroup by day order by day\"\"\"\n            % lim,\n            self.col.sched.day_cutoff,\n            chunk,\n            tf,\n            tf,\n            tf,\n            tf,\n            tf,\n        )\n\n    def _daysStudied(self) -> Any:\n        lims = []\n        num = self._periodDays()\n        if num:\n            lims.append(\n                \"id > %d\" % ((self.col.sched.day_cutoff - (num * 86400)) * 1000)\n            )\n        rlim = self._revlogLimit()\n        if rlim:\n            lims.append(rlim)\n        if lims:\n            lim = \"where \" + \" and \".join(lims)\n        else:\n            lim = \"\"\n        ret = self.col.db.first(\n            \"\"\"\nselect count(), abs(min(day)) from (select\n(cast((id/1000 - ?) / 86400.0 as int)+1) as day\nfrom revlog %s\ngroup by day order by day)\"\"\"\n            % lim,\n            self.col.sched.day_cutoff,\n        )\n        assert ret\n        return ret\n\n    # Intervals\n    ######################################################################\n\n    def ivlGraph(self) -> str:\n        (ivls, all, avg, max_), chunk = self._ivls()\n        tot = 0\n        totd = []\n        if not ivls or not all:\n            return \"\"\n        for grp, cnt in ivls:\n            tot += cnt\n            totd.append((grp, tot / float(all) * 100))\n        if self.type == PERIOD_MONTH:\n            ivlmax = 31\n        elif self.type == PERIOD_YEAR:\n            ivlmax = 52\n        else:\n            ivlmax = max(5, ivls[-1][0])\n        txt = self._title(\"Intervals\", \"Delays until reviews are shown again.\")\n        txt += self._graph(\n            id=\"ivl\",\n            ylabel2=\"Percentage\",\n            xunit=chunk,\n            data=[\n                dict(data=ivls, color=colIvl),\n                dict(\n                    data=totd,\n                    color=colCum,\n                    yaxis=2,\n                    bars={\"show\": False},\n                    lines=dict(show=True),\n                    stack=False,\n                ),\n            ],\n            conf=dict(\n                xaxis=dict(min=-0.5, max=ivlmax + 0.5),\n                yaxes=[dict(), dict(position=\"right\", max=105)],\n            ),\n        )\n        i: list[str] = []\n        self._line(i, \"Average interval\", self.col.format_timespan(avg * 86400))\n        self._line(i, \"Longest interval\", self.col.format_timespan(max_ * 86400))\n        return txt + self._lineTbl(i)\n\n    def _ivls(self) -> tuple[list[Any], int]:\n        start, end, chunk = self.get_start_end_chunk()\n        lim = \"and grp <= %d\" % end if end else \"\"\n        data = [\n            self.col.db.all(\n                f\"\"\"\nselect ivl / ? as grp, count() from cards\nwhere did in %s and queue = {QUEUE_TYPE_REV} %s\ngroup by grp\norder by grp\"\"\"\n                % (self._limit(), lim),\n                chunk,\n            )\n        ]\n        return (\n            data\n            + list(\n                self.col.db.first(\n                    f\"\"\"\nselect count(), avg(ivl), max(ivl) from cards where did in %s and queue = {QUEUE_TYPE_REV}\"\"\"\n                    % self._limit()\n                )\n            ),\n            chunk,\n        )\n\n    # Eases\n    ######################################################################\n\n    def easeGraph(self) -> str:\n        # 3 + 4 + 4 + spaces on sides and middle = 15\n        # yng starts at 1+3+1 = 5\n        # mtr starts at 5+4+1 = 10\n        d: dict[str, list] = {\"lrn\": [], \"yng\": [], \"mtr\": []}\n        types = (\"lrn\", \"yng\", \"mtr\")\n        eases = self._eases()\n        for type, ease, cnt in eases:\n            if type == CARD_TYPE_LRN:\n                ease += 5\n            elif type == CARD_TYPE_REV:\n                ease += 10\n            n = types[type]\n            d[n].append((ease, cnt))\n        ticks = [\n            [1, 1],\n            [2, 2],\n            [3, 3],  # [4,4]\n            [6, 1],\n            [7, 2],\n            [8, 3],\n            [9, 4],\n            [11, 1],\n            [12, 2],\n            [13, 3],\n            [14, 4],\n        ]\n        ticks.insert(3, [4, 4])\n        txt = self._title(\n            \"Answer Buttons\", \"The number of times you have pressed each button.\"\n        )\n        txt += self._graph(\n            id=\"ease\",\n            data=[\n                dict(data=d[\"lrn\"], color=colLearn, label=\"Learning\"),\n                dict(data=d[\"yng\"], color=colYoung, label=\"Young\"),\n                dict(data=d[\"mtr\"], color=colMature, label=\"Mature\"),\n            ],\n            type=\"bars\",\n            conf=dict(xaxis=dict(ticks=ticks, min=0, max=15)),\n            ylabel=\"Answers\",\n        )\n        txt += self._easeInfo(eases)\n        return txt\n\n    def _easeInfo(self, eases: list[tuple[int, int, int]]) -> str:\n        types = {PERIOD_MONTH: [0, 0], PERIOD_YEAR: [0, 0], PERIOD_LIFE: [0, 0]}\n        for type, ease, cnt in eases:\n            if ease == 1:\n                types[type][0] += cnt\n            else:\n                types[type][1] += cnt\n        i = []\n        for type in range(3):\n            (bad, good) = types[type]\n            tot = bad + good\n            try:\n                pct = good / float(tot) * 100\n            except Exception:\n                pct = 0\n            i.append(\n                \"Correct: <b>%(pct)0.2f%%</b><br>(%(good)d of %(tot)d)\"\n                % dict(pct=pct, good=good, tot=tot)\n            )\n        return (\n            \"\"\"\n<center><table width=%dpx><tr><td width=50></td><td align=center>\"\"\"\n            % self.width\n            + \"</td><td align=center>\".join(i)\n            + \"</td></tr></table></center>\"\n        )\n\n    def _eases(self) -> Any:\n        lims = []\n        lim = self._revlogLimit()\n        if lim:\n            lims.append(lim)\n        days = self._periodDays()\n        if days is not None:\n            lims.append(\n                \"id > %d\" % ((self.col.sched.day_cutoff - (days * 86400)) * 1000)\n            )\n        if lims:\n            lim = \"and \" + \" and \".join(lims)\n        else:\n            lim = \"\"\n        ease4repl = \"ease\"\n        return self.col.db.all(\n            f\"\"\"\nselect (case\nwhen type in ({REVLOG_LRN},{REVLOG_RELRN}) then 0\nwhen lastIvl < 21 then 1\nelse 2 end) as thetype,\n(case when type in ({REVLOG_LRN},{REVLOG_RELRN}) and ease = 4 then %s else ease end), count() from revlog where type != {REVLOG_RESCHED} %s\ngroup by thetype, ease\norder by thetype, ease\"\"\"\n            % (ease4repl, lim)\n        )\n\n    # Hourly retention\n    ######################################################################\n\n    def hourGraph(self) -> str:\n        data = self._hourRet()\n        if not data:\n            return \"\"\n        shifted = []\n        counts = []\n        mcount = 0\n        trend: list[tuple[int, int]] = []\n        peak = 0\n        for d in data:\n            hour = (d[0] - 4) % 24\n            pct = d[1]\n            if pct > peak:\n                peak = pct\n            shifted.append((hour, pct))\n            counts.append((hour, d[2]))\n            if d[2] > mcount:\n                mcount = d[2]\n        shifted.sort()\n        counts.sort()\n        if len(counts) < 4:\n            return \"\"\n        for d in shifted:\n            hour = d[0]\n            pct = d[1]\n            if not trend:\n                trend.append((hour, pct))\n            else:\n                prev = trend[-1][1]\n                diff = pct - prev\n                diff /= 3.0\n                diff = round(diff, 1)\n                trend.append((hour, prev + diff))\n        txt = self._title(\n            \"Hourly Breakdown\", \"Review success rate for each hour of the day.\"\n        )\n        txt += self._graph(\n            id=\"hour\",\n            data=[\n                dict(data=shifted, color=colCum, label=\"% Correct\"),\n                dict(\n                    data=counts,\n                    color=colHour,\n                    label=\"Answers\",\n                    yaxis=2,\n                    bars=dict(barWidth=0.2),\n                    stack=False,\n                ),\n            ],\n            conf=dict(\n                xaxis=dict(\n                    ticks=[\n                        [0, \"4AM\"],\n                        [6, \"10AM\"],\n                        [12, \"4PM\"],\n                        [18, \"10PM\"],\n                        [23, \"3AM\"],\n                    ]\n                ),\n                yaxes=[dict(max=peak), dict(position=\"right\", max=mcount)],\n            ),\n            ylabel=\"% Correct\",\n            ylabel2=\"Reviews\",\n        )\n        txt += \"Hours with less than 30 reviews are not shown.\"\n        return txt\n\n    def _hourRet(self) -> Any:\n        lim = self._revlogLimit()\n        if lim:\n            lim = \" and \" + lim\n        rolloverHour = self.col.conf.get(\"rollover\", 4)\n        pd = self._periodDays()\n        if pd:\n            lim += \" and id > %d\" % ((self.col.sched.day_cutoff - (86400 * pd)) * 1000)\n        return self.col.db.all(\n            f\"\"\"\nselect\n23 - ((cast((? - id/1000) / 3600.0 as int)) %% 24) as hour,\nsum(case when ease = 1 then 0 else 1 end) /\ncast(count() as float) * 100,\ncount()\nfrom revlog where type in ({REVLOG_LRN},{REVLOG_REV},{REVLOG_RELRN}) %s\ngroup by hour having count() > 30 order by hour\"\"\"\n            % lim,\n            self.col.sched.day_cutoff - (rolloverHour * 3600),\n        )\n\n    # Cards\n    ######################################################################\n\n    def cardGraph(self) -> str:\n        # graph data\n        div = self._cards()\n        d = []\n        for c, (t, col) in enumerate(\n            (\n                (\"Mature\", colMature),\n                (\"Young+Learn\", colYoung),\n                (\"Unseen\", colUnseen),\n                (\"Suspended+Buried\", colSusp),\n            )\n        ):\n            d.append(dict(data=div[c], label=f\"{t}: {div[c]}\", color=col))\n        # text data\n        i: list[str] = []\n        (c, f) = self.col.db.first(\n            \"\"\"\nselect count(id), count(distinct nid) from cards\nwhere did in %s \"\"\"\n            % self._limit()\n        )\n        self._line(i, \"Total cards\", c)\n        self._line(i, \"Total notes\", f)\n        (low, avg, high) = self._factors()\n        if low:\n            self._line(i, \"Lowest ease\", \"%d%%\" % low)\n            self._line(i, \"Average ease\", \"%d%%\" % avg)\n            self._line(i, \"Highest ease\", \"%d%%\" % high)\n        info = \"<table width=100%>\" + \"\".join(i) + \"</table><p>\"\n        info += \"\"\"\\\nA card's <i>ease</i> is the size of the next interval \\\nwhen you answer \"good\" on a review.\"\"\"\n        txt = self._title(\"Card Types\", \"The division of cards in your deck(s).\")\n        txt += \"<table width=%d><tr><td>%s</td><td>%s</td></table>\" % (\n            self.width,\n            self._graph(id=\"cards\", data=d, type=\"pie\"),\n            info,\n        )\n        return txt\n\n    def _line(self, i: list[str], a: str, b: int | str, bold: bool = True) -> None:\n        # T: Symbols separating first and second column in a statistics table. Eg in \"Total:    3 reviews\".\n        colon = \":\"\n        if bold:\n            i.append(\n                (\"<tr><td width=200 align=start>%s%s</td><td><b>%s</b></td></tr>\")\n                % (a, colon, b)\n            )\n        else:\n            i.append(\n                (\"<tr><td width=200 align=end>%s%s</td><td>%s</td></tr>\")\n                % (a, colon, b)\n            )\n\n    def _lineTbl(self, i: list[str]) -> str:\n        return \"<table width=400>\" + \"\".join(i) + \"</table>\"\n\n    def _factors(self) -> Any:\n        return self.col.db.first(\n            f\"\"\"\nselect\nmin(factor) / 10.0,\navg(factor) / 10.0,\nmax(factor) / 10.0\nfrom cards where did in %s and queue = {QUEUE_TYPE_REV}\"\"\"\n            % self._limit()\n        )\n\n    def _cards(self) -> Any:\n        return self.col.db.first(\n            f\"\"\"\nselect\nsum(case when queue={QUEUE_TYPE_REV} and ivl >= 21 then 1 else 0 end), -- mtr\nsum(case when queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) or (queue={QUEUE_TYPE_REV} and ivl < 21) then 1 else 0 end), -- yng/lrn\nsum(case when queue={QUEUE_TYPE_NEW} then 1 else 0 end), -- new\nsum(case when queue<{QUEUE_TYPE_NEW} then 1 else 0 end) -- susp\nfrom cards where did in %s\"\"\"\n            % self._limit()\n        )\n\n    # Footer\n    ######################################################################\n\n    def footer(self) -> str:\n        b = \"<br><br><font size=1>\"\n        b += \"Generated on %s\" % time.asctime(time.localtime(time.time()))\n        b += \"<br>\"\n        if self.wholeCollection:\n            deck = \"whole collection\"\n        else:\n            deck = self.col.decks.current()[\"name\"]\n        b += \"Scope: %s\" % deck\n        b += \"<br>\"\n        b += \"Period: %s\" % [\"1 month\", \"1 year\", \"deck life\"][self.type]\n        return b\n\n    # Tools\n    ######################################################################\n\n    def _graph(\n        self,\n        id: str,\n        data: Any,\n        conf: Any | None = None,\n        type: str = \"bars\",\n        xunit: int = 1,\n        ylabel: str = \"Cards\",\n        ylabel2: str = \"\",\n    ) -> str:\n        if conf is None:\n            conf = {}\n        # display settings\n        if type == \"pie\":\n            conf[\"legend\"] = {\"container\": \"#%sLegend\" % id, \"noColumns\": 2}\n        else:\n            conf[\"legend\"] = {\"container\": \"#%sLegend\" % id, \"noColumns\": 10}\n        conf[\"series\"] = dict(stack=True)\n        if \"yaxis\" not in conf:\n            conf[\"yaxis\"] = {}\n        conf[\"yaxis\"][\"labelWidth\"] = 40\n        if \"xaxis\" not in conf:\n            conf[\"xaxis\"] = {}\n        if xunit is None:\n            conf[\"timeTicks\"] = False\n        else:\n            # T: abbreviation of day\n            d = \"d\"\n            # T: abbreviation of week\n            w = \"w\"\n            # T: abbreviation of month\n            mo = \"mo\"\n            conf[\"timeTicks\"] = {1: d, 7: w, 31: mo}[xunit]\n        # types\n        width = self.width\n        height = self.height\n        if type == \"bars\":\n            conf[\"series\"][\"bars\"] = dict(\n                show=True, barWidth=0.8, align=\"center\", fill=0.7, lineWidth=0\n            )  # pytype: disable=unsupported-operands\n        elif type == \"barsLine\":\n            print(\"deprecated - use 'bars' instead\")\n            conf[\"series\"][\"bars\"] = dict(\n                show=True, barWidth=0.8, align=\"center\", fill=0.7, lineWidth=3\n            )\n        elif type == \"fill\":\n            conf[\"series\"][\"lines\"] = dict(show=True, fill=True)\n        elif type == \"pie\":\n            width = int(float(width) / 2.3)\n            height = int(float(height) * 1.5)\n            ylabel = \"\"\n            conf[\"series\"][\"pie\"] = dict(\n                show=True,\n                radius=1,\n                stroke=dict(color=\"#fff\", width=5),\n                label=dict(\n                    show=True,\n                    radius=0.8,\n                    threshold=0.01,\n                    background=dict(opacity=0.5, color=\"#000\"),\n                ),\n            )\n        return \"\"\"\n<table cellpadding=0 cellspacing=10>\n<tr>\n\n<td><div style=\"width: 150px; text-align: center; position:absolute;\n -webkit-transform: rotate(-90deg) translateY(-85px);\nfont-weight: bold;\n\">%(ylab)s</div></td>\n\n<td>\n<center><div id=%(id)sLegend></div></center>\n<div id=\"%(id)s\" style=\"width:%(w)spx; height:%(h)spx;\"></div>\n</td>\n\n<td><div style=\"width: 150px; text-align: center; position:absolute;\n -webkit-transform: rotate(90deg) translateY(65px);\nfont-weight: bold;\n\">%(ylab2)s</div></td>\n\n</tr></table>\n<script>\n$(function () {\n    var conf = %(conf)s;\n    if (conf.timeTicks) {\n        conf.xaxis.tickFormatter = function (val, axis) {\n            return val.toFixed(0)+conf.timeTicks;\n        }\n    }\n    conf.yaxis.minTickSize = 1;\n    // prevent ticks from having decimals (use whole numbers instead)\n    conf.yaxis.tickDecimals = 0;\n    conf.yaxis.tickFormatter = function (val, axis) {\n            // Just in case we get ticks with decimals, render to one decimal position.  If it's\n            // a whole number then render without any decimal (i.e. without the trailing .0).\n            return val === Math.round(val) ? val.toFixed(0) : val.toFixed(1);\n    }\n    if (conf.series.pie) {\n        conf.series.pie.label.formatter = function(label, series){\n            return '<div class=pielabel>'+Math.round(series.percent)+'%%</div>';\n        };\n    }\n    $.plot($(\"#%(id)s\"), %(data)s, conf);\n});\n</script>\"\"\" % dict(\n            id=id,\n            w=width,\n            h=height,\n            ylab=ylabel,\n            ylab2=ylabel2,\n            data=json.dumps(data),\n            conf=json.dumps(conf),\n        )\n\n    def _limit(self) -> Any:\n        if self.wholeCollection:\n            return ids2str([d[\"id\"] for d in self.col.decks.all()])\n        return self.col.sched._deck_limit()\n\n    def _revlogLimit(self) -> str:\n        if self.wholeCollection:\n            return \"\"\n        return \"cid in (select id from cards where did in %s)\" % ids2str(\n            self.col.decks.active()\n        )\n\n    def _title(self, title: str, subtitle: str = \"\") -> str:\n        return f\"<h1>{title}</h1>{subtitle}\"\n\n    def _deckAge(self, by: str) -> int:\n        lim = self._revlogLimit()\n        if lim:\n            lim = \" where \" + lim\n        t = 0\n        if by == \"review\":\n            t = self.col.db.scalar(\"select id from revlog %s order by id limit 1\" % lim)\n        elif by == \"add\":\n            if self.wholeCollection:\n                lim = \"\"\n            else:\n                lim = \"where did in %s\" % ids2str(self.col.decks.active())\n            t = self.col.db.scalar(\"select id from cards %s order by id limit 1\" % lim)\n        if not t:\n            period = 1\n        else:\n            period = max(1, int(1 + ((self.col.sched.day_cutoff - (t / 1000)) / 86400)))\n        return period\n\n    def _periodDays(self) -> int | None:\n        start, end, chunk = self.get_start_end_chunk()\n        if end is None:\n            return None\n        return end * chunk\n\n    def _avgDay(self, tot: float, num: int, unit: str) -> str:\n        vals = []\n        try:\n            vals.append(\"%(a)0.1f %(b)s/day\" % dict(a=tot / float(num), b=unit))\n            return \", \".join(vals)\n        except ZeroDivisionError:\n            return \"\"\n"
  },
  {
    "path": "pylib/anki/statsbg.py",
    "content": "# from subtlepatterns.com; CC BY 4.0.\n# by Daniel Beaton\n# https://www.toptal.com/designers/subtlepatterns/fancy-deboss/\nbg = \"\"\"\\\niVBORw0KGgoAAAANSUhEUgAAABIAAAANCAMAAACTkM4rAAAAM1BMVEXy8vLz8/P5+fn19fXt7e329vb4+Pj09PTv7+/u7u739/fw8PD7+/vx8fHr6+v6+vrs7Oz2LjW2AAAAkUlEQVR42g3KyXHAQAwDQYAQj12ItvOP1qqZZwMMPVnd06XToQvz4L2HDQ2iRgkvA7yPPB+JD+OUPnfzZ0JNZh6kkQus5NUmR7g4Jpxv5XN6nYWNmtlq9o3zuK6w3XRsE1pQIEGPIsdtTP3m2cYwlPv6MbL8/QASsKppZefyDmJPbxvxa/NrX1TJ1yp20fhj9D+SiAWWLU8myQAAAABJRU5ErkJggg==\n\"\"\"\n"
  },
  {
    "path": "pylib/anki/stdmodels.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom typing import TYPE_CHECKING, Any\n\nimport anki.collection\nimport anki.models\nfrom anki import notetypes_pb2\nfrom anki._legacy import DeprecatedNamesMixinForModule\nfrom anki.utils import from_json_bytes\n\nStockNotetypeKind = notetypes_pb2.StockNotetype.Kind\n\n# add-on authors can add (\"note type name\", function)\n# to this list to have it shown in the add/clone note type screen\nmodels: list[tuple] = []\n\n\ndef _get_stock_notetype(\n    col: anki.collection.Collection, kind: StockNotetypeKind.V\n) -> anki.models.NotetypeDict:\n    return from_json_bytes(col._backend.get_stock_notetype_legacy(kind))\n\n\ndef get_stock_notetypes(\n    col: anki.collection.Collection,\n) -> list[tuple[str, Callable[[anki.collection.Collection], anki.models.NotetypeDict]]]:\n    out: list[\n        tuple[str, Callable[[anki.collection.Collection], anki.models.NotetypeDict]]\n    ] = []\n    # add standard - this order should match the one in notetypes.proto\n    for kind in [\n        StockNotetypeKind.KIND_BASIC,\n        StockNotetypeKind.KIND_BASIC_AND_REVERSED,\n        StockNotetypeKind.KIND_BASIC_OPTIONAL_REVERSED,\n        StockNotetypeKind.KIND_BASIC_TYPING,\n        StockNotetypeKind.KIND_CLOZE,\n        StockNotetypeKind.KIND_IMAGE_OCCLUSION,\n    ]:\n        note_type = from_json_bytes(col._backend.get_stock_notetype_legacy(kind))\n\n        def instance_getter(\n            model: Any,\n        ) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]:\n            return lambda col: model\n\n        out.append((note_type[\"name\"], instance_getter(note_type)))\n    # add extras from add-ons\n    for name_or_func, func in models:\n        if not isinstance(name_or_func, str):\n            name = name_or_func()\n        else:\n            name = name_or_func\n        out.append((name, func))\n    return out\n\n\n#\n# Legacy functions that added the notetype before returning it\n#\n\n\ndef _legacy_add_basic_model(\n    col: anki.collection.Collection,\n) -> anki.models.NotetypeDict:\n    note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC)\n    col.models.add(note_type)\n    return note_type\n\n\ndef _legacy_add_basic_typing_model(\n    col: anki.collection.Collection,\n) -> anki.models.NotetypeDict:\n    note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_TYPING)\n    col.models.add(note_type)\n    return note_type\n\n\ndef _legacy_add_forward_reverse(\n    col: anki.collection.Collection,\n) -> anki.models.NotetypeDict:\n    note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_AND_REVERSED)\n    col.models.add(note_type)\n    return note_type\n\n\ndef _legacy_add_forward_optional_reverse(\n    col: anki.collection.Collection,\n) -> anki.models.NotetypeDict:\n    note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_BASIC_OPTIONAL_REVERSED)\n    col.models.add(note_type)\n    return note_type\n\n\ndef _legacy_add_cloze_model(\n    col: anki.collection.Collection,\n) -> anki.models.NotetypeDict:\n    note_type = _get_stock_notetype(col, StockNotetypeKind.KIND_CLOZE)\n    col.models.add(note_type)\n    return note_type\n\n\n_deprecated_names = DeprecatedNamesMixinForModule(globals())\n_deprecated_names.register_deprecated_attributes(\n    addBasicModel=(_legacy_add_basic_model, get_stock_notetypes),\n    addBasicTypingModel=(_legacy_add_basic_typing_model, get_stock_notetypes),\n    addForwardReverse=(_legacy_add_forward_reverse, get_stock_notetypes),\n    addForwardOptionalReverse=(\n        _legacy_add_forward_optional_reverse,\n        get_stock_notetypes,\n    ),\n    addClozeModel=(_legacy_add_cloze_model, get_stock_notetypes),\n)\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name: str) -> Any:\n        return _deprecated_names.__getattr__(name)\n"
  },
  {
    "path": "pylib/anki/storage.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# Legacy code expects to find Collection in this module.\n\nfrom anki.collection import Collection\n\n_Collection = Collection\n"
  },
  {
    "path": "pylib/anki/sync.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom anki import sync_pb2\n\n# public exports\nSyncAuth = sync_pb2.SyncAuth\nSyncOutput = sync_pb2.SyncCollectionResponse\nSyncStatus = sync_pb2.SyncStatusResponse\n\n\n# Legacy attributes some add-ons may be using\n\nfrom .httpclient import HttpClient\n\nAnkiRequestsClient = HttpClient\n\n\nclass Syncer:\n    def sync(self) -> str:\n        pass\n"
  },
  {
    "path": "pylib/anki/syncserver.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\ndef run_sync_server() -> None:\n    import sys\n    from os import environ as env\n\n    from anki._backend import RustBackend\n\n    env[\"RUST_LOG\"] = env.get(\"RUST_LOG\", \"anki=info\")\n\n    try:\n        RustBackend.syncserver()\n    except Exception as exc:\n        print(\"Sync server failed:\", exc)\n        sys.exit(1)\n    sys.exit(0)\n\n\nif __name__ == \"__main__\":\n    run_sync_server()\n"
  },
  {
    "path": "pylib/anki/tags.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nAnki maintains a cache of used tags so it can quickly present a list of tags\nfor autocomplete and in the browser. For efficiency, deletions are not\ntracked, so unused tags can only be removed from the list with a DB check.\n\nThis module manages the tag cache and tags for notes.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pprint\nimport re\nfrom collections.abc import Collection, Sequence\nfrom typing import Match\n\nimport anki\nimport anki.collection\nfrom anki import tags_pb2\nfrom anki._legacy import DeprecatedNamesMixin, deprecated\nfrom anki.collection import OpChanges, OpChangesWithCount\nfrom anki.decks import DeckId\nfrom anki.notes import NoteId\nfrom anki.utils import ids2str\n\n# public exports\nTagTreeNode = tags_pb2.TagTreeNode\nCompleteTagRequest = tags_pb2.CompleteTagRequest\nMARKED_TAG = \"marked\"\n\n\nclass TagManager(DeprecatedNamesMixin):\n    def __init__(self, col: anki.collection.Collection) -> None:\n        self.col = col.weakref()\n\n    # legacy add-on code expects a List return type\n    def all(self) -> list[str]:\n        return list(self.col._backend.all_tags())\n\n    def __repr__(self) -> str:\n        dict_ = dict(self.__dict__)\n        del dict_[\"col\"]\n        return f\"{super().__repr__()} {pprint.pformat(dict_, width=300)}\"\n\n    def tree(self) -> TagTreeNode:\n        return self.col._backend.tag_tree()\n\n    # Registering and fetching tags\n    #############################################################\n\n    def clear_unused_tags(self) -> OpChangesWithCount:\n        return self.col._backend.clear_unused_tags()\n\n    def set_collapsed(self, tag: str, collapsed: bool) -> OpChanges:\n        \"Set browser expansion state for tag, registering the tag if missing.\"\n        return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed)\n\n    # Bulk addition/removal from specific notes\n    #############################################################\n\n    def bulk_add(self, note_ids: Sequence[NoteId], tags: str) -> OpChangesWithCount:\n        \"\"\"Add space-separate tags to provided notes, returning changed count.\"\"\"\n        return self.col._backend.add_note_tags(note_ids=note_ids, tags=tags)\n\n    def bulk_remove(self, note_ids: Sequence[NoteId], tags: str) -> OpChangesWithCount:\n        return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags)\n\n    # Find&replace\n    #############################################################\n\n    def find_and_replace(\n        self,\n        note_ids: Sequence[int],\n        search: str,\n        replacement: str,\n        regex: bool,\n        match_case: bool,\n    ) -> OpChangesWithCount:\n        \"\"\"Replace instances of 'search' with 'replacement' in tags.\n        Each tag is matched separately. If the replacement results in an empty string,\n        the tag will be removed.\"\"\"\n        return self.col._backend.find_and_replace_tag(\n            note_ids=note_ids,\n            search=search,\n            replacement=replacement,\n            regex=regex,\n            match_case=match_case,\n        )\n\n    # Bulk addition/removal based on tag\n    #############################################################\n\n    def rename(self, old: str, new: str) -> OpChangesWithCount:\n        \"Rename provided tag and its children, returning number of changed notes.\"\n        return self.col._backend.rename_tags(current_prefix=old, new_prefix=new)\n\n    def remove(self, space_separated_tags: str) -> OpChangesWithCount:\n        \"Remove the provided tag(s) and their children from notes and the tag list.\"\n        return self.col._backend.remove_tags(val=space_separated_tags)\n\n    def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount:\n        \"\"\"Change the parent of the provided tags.\n        If new_parent is empty, tags will be reparented to the top-level.\"\"\"\n        return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent)\n\n    # String-based utilities\n    ##########################################################################\n\n    def split(self, tags: str) -> list[str]:\n        \"Parse a string and return a list of tags.\"\n        return [t for t in tags.replace(\"\\u3000\", \" \").split(\" \") if t]\n\n    def join(self, tags: list[str]) -> str:\n        \"Join tags into a single string, with leading and trailing spaces.\"\n        if not tags:\n            return \"\"\n        return f\" {' '.join(tags)} \"\n\n    def rem_from_str(self, deltags: str, tags: str) -> str:\n        \"Delete tags if they exist.\"\n\n        def wildcard(pat: str, repl: str) -> Match:\n            pat = re.escape(pat).replace(\"\\\\*\", \".*\")\n            return re.match(f\"^{pat}$\", repl, re.IGNORECASE)\n\n        current_tags = self.split(tags)\n        for del_tag in self.split(deltags):\n            # find tags, ignoring case\n            remove = []\n            for cur_tag in current_tags:\n                if (del_tag.lower() == cur_tag.lower()) or wildcard(del_tag, cur_tag):\n                    remove.append(cur_tag)\n            # remove them\n            for rem in remove:\n                current_tags.remove(rem)\n        return self.join(current_tags)\n\n    # List-based utilities\n    ##########################################################################\n\n    @deprecated(info=\"no-op - tags are now canonified when note is saved\")\n    def canonify(self, tag_list: list[str]) -> list[str]:\n        return tag_list\n\n    def in_list(self, tag: str, tags: list[str]) -> bool:\n        \"True if TAG is in TAGS. Ignore case.\"\n        return tag.lower() in [t.lower() for t in tags]\n\n    # legacy\n    ##########################################################################\n\n    def _legacy_register_notes(self, nids: list[int] | None = None) -> None:\n        self.clear_unused_tags()\n\n    def register(\n        self, tags: Collection[str], usn: int | None = None, clear: bool = False\n    ) -> None:\n        print(\"tags.register() is deprecated and no longer works\")\n\n    def _legacy_bulk_add(self, ids: list[NoteId], tags: str, add: bool = True) -> None:\n        \"Add tags in bulk. TAGS is space-separated.\"\n        if add:\n            self.bulk_add(ids, tags)\n        else:\n            self.bulk_remove(ids, tags)\n\n    def _legacy_bulk_rem(self, ids: list[NoteId], tags: str) -> None:\n        self._legacy_bulk_add(ids, tags, False)\n\n    @deprecated(info=\"no longer used by Anki, and will be removed in the future\")\n    def by_deck(self, did: DeckId, children: bool = False) -> list[str]:\n        basequery = \"select n.tags from cards c, notes n WHERE c.nid = n.id\"\n        if not children:\n            query = f\"{basequery} AND c.did=?\"\n            res = self.col.db.list(query, did)\n            return list(set(self.split(\" \".join(res))))\n        dids = [did]\n        for name, id in self.col.decks.children(did):\n            dids.append(id)\n        query = f\"{basequery} AND c.did IN {ids2str(dids)}\"\n        res = self.col.db.list(query)\n        return list(set(self.split(\" \".join(res))))\n\n\nTagManager.register_deprecated_attributes(\n    registerNotes=(TagManager._legacy_register_notes, TagManager.clear_unused_tags),\n    bulkAdd=(TagManager._legacy_bulk_add, TagManager.bulk_add),\n    bulkRem=(TagManager._legacy_bulk_rem, TagManager.bulk_remove),\n)\n"
  },
  {
    "path": "pylib/anki/template.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nThis file contains the Python portion of the template rendering code.\n\nTemplates can have filters applied to field replacements. The Rust template\nrendering code will apply any built in filters, and stop at the first\nunrecognized filter. The remaining filters are returned to Python,\nand applied using the hook system. For example,\n{{myfilter:hint:text:Field}} will apply the built in text and hint filters,\nand then attempt to apply myfilter. If no add-ons have provided the filter,\nthe filter is skipped.\n\nAdd-ons can register a filter with the following code:\n\nfrom anki import hooks\nhooks.field_filter.append(myfunc)\n\nThis will call myfunc, passing the field text in as the first argument.\nYour function should decide if it wants to modify the text by checking\nthe filter_name argument, and then return the text whether it has been\nmodified or not.\n\nA Python implementation of the standard filters is currently available in the\ntemplate_legacy.py file, using the legacy addHook() system.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os.path\nfrom collections.abc import Sequence\nfrom dataclasses import dataclass\nfrom typing import Any, Union\n\nimport anki\nimport anki.cards\nimport anki.collection\nimport anki.notes\nfrom anki import card_rendering_pb2, hooks\nfrom anki.decks import DeckManager\nfrom anki.errors import TemplateError\nfrom anki.models import NotetypeDict\nfrom anki.sound import AVTag, SoundOrVideoTag, TTSTag\nfrom anki.utils import to_json_bytes\n\n\n@dataclass\nclass TemplateReplacement:\n    field_name: str\n    current_text: str\n    filters: list[str]\n\n\nTemplateReplacementList = list[Union[str, TemplateReplacement]]\n\n\n@dataclass\nclass PartiallyRenderedCard:\n    qnodes: TemplateReplacementList\n    anodes: TemplateReplacementList\n    css: str\n    latex_svg: bool\n    is_empty: bool\n\n    @classmethod\n    def from_proto(\n        cls, out: card_rendering_pb2.RenderCardResponse\n    ) -> PartiallyRenderedCard:\n        qnodes = cls.nodes_from_proto(out.question_nodes)\n        anodes = cls.nodes_from_proto(out.answer_nodes)\n\n        return PartiallyRenderedCard(\n            qnodes, anodes, out.css, out.latex_svg, out.is_empty\n        )\n\n    @staticmethod\n    def nodes_from_proto(\n        nodes: Sequence[card_rendering_pb2.RenderedTemplateNode],\n    ) -> TemplateReplacementList:\n        results: TemplateReplacementList = []\n        for node in nodes:\n            if node.WhichOneof(\"value\") == \"text\":\n                results.append(node.text)\n            else:\n                results.append(\n                    TemplateReplacement(\n                        field_name=node.replacement.field_name,\n                        current_text=node.replacement.current_text,\n                        filters=list(node.replacement.filters),\n                    )\n                )\n        return results\n\n\ndef av_tag_to_native(tag: card_rendering_pb2.AVTag) -> AVTag:\n    val = tag.WhichOneof(\"value\")\n    if val == \"sound_or_video\":\n        return SoundOrVideoTag(filename=os.path.basename(tag.sound_or_video))\n    else:\n        return TTSTag(\n            field_text=tag.tts.field_text,\n            lang=tag.tts.lang,\n            voices=list(tag.tts.voices),\n            other_args=list(tag.tts.other_args),\n            speed=tag.tts.speed,\n        )\n\n\ndef av_tags_to_native(tags: Sequence[card_rendering_pb2.AVTag]) -> list[AVTag]:\n    return list(map(av_tag_to_native, tags))\n\n\nclass TemplateRenderContext:\n    \"\"\"Holds information for the duration of one card render.\n\n    This may fetch information lazily in the future, so please avoid\n    using the _private fields directly.\"\"\"\n\n    @staticmethod\n    def from_existing_card(\n        card: anki.cards.Card, browser: bool\n    ) -> TemplateRenderContext:\n        return TemplateRenderContext(card.col, card, card.note(), browser)\n\n    @classmethod\n    def from_card_layout(\n        cls,\n        note: anki.notes.Note,\n        card: anki.cards.Card,\n        notetype: NotetypeDict,\n        template: dict,\n        fill_empty: bool,\n    ) -> TemplateRenderContext:\n        return TemplateRenderContext(\n            note.col,\n            card,\n            note,\n            notetype=notetype,\n            template=template,\n            fill_empty=fill_empty,\n        )\n\n    def __init__(\n        self,\n        col: anki.collection.Collection,\n        card: anki.cards.Card,\n        note: anki.notes.Note,\n        browser: bool = False,\n        notetype: NotetypeDict | None = None,\n        template: dict | None = None,\n        fill_empty: bool = False,\n    ) -> None:\n        self._col = col.weakref()\n        self._card = card\n        self._note = note\n        self._browser = browser\n        self._template = template\n        self._fill_empty = fill_empty\n        self._fields: dict | None = None\n        self._latex_svg = False\n        self._question_side: bool = True\n        if not notetype:\n            self._note_type = note.note_type()\n        else:\n            self._note_type = notetype\n\n        # if you need to store extra state to share amongst rendering\n        # hooks, you can insert it into this dictionary\n        self.extra_state: dict[str, Any] = {}\n\n    @property\n    def question_side(self) -> bool:\n        return self._question_side\n\n    def col(self) -> anki.collection.Collection:\n        return self._col\n\n    def fields(self) -> dict[str, str]:\n        print(\".fields() is obsolete, use .note() or .card()\")\n        if not self._fields:\n            # fields from note\n            fields = dict(self._note.items())\n\n            # add (most) special fields\n            fields[\"Tags\"] = self._note.string_tags().strip()\n            fields[\"Type\"] = self._note_type[\"name\"]\n            fields[\"Deck\"] = self._col.decks.name(self._card.current_deck_id())\n            fields[\"Subdeck\"] = DeckManager.basename(fields[\"Deck\"])\n            if self._template:\n                fields[\"Card\"] = self._template[\"name\"]\n            else:\n                fields[\"Card\"] = \"\"\n            flag = self._card.user_flag()\n            fields[\"CardFlag\"] = flag and f\"flag{flag}\" or \"\"\n            self._fields = fields\n\n        return self._fields\n\n    def card(self) -> anki.cards.Card:\n        \"\"\"Returns the card being rendered.\n\n        Be careful not to call .question() or .answer() on the card, or you'll create an\n        infinite loop.\"\"\"\n        return self._card\n\n    def note(self) -> anki.notes.Note:\n        return self._note\n\n    def note_type(self) -> NotetypeDict:\n        return self._note_type\n\n    def latex_svg(self) -> bool:\n        return self._latex_svg\n\n    # legacy\n    def qfmt(self) -> str:\n        return templates_for_card(self.card(), self._browser)[0]\n\n    # legacy\n    def afmt(self) -> str:\n        return templates_for_card(self.card(), self._browser)[1]\n\n    def render(self) -> TemplateRenderOutput:\n        try:\n            partial = self._partially_render()\n        except TemplateError as error:\n            return TemplateRenderOutput(\n                question_text=str(error),\n                answer_text=str(error),\n                question_av_tags=[],\n                answer_av_tags=[],\n            )\n\n        self._question_side = True\n        qtext = apply_custom_filters(partial.qnodes, self, front_side=None)\n        qout = self.col()._backend.extract_av_tags(text=qtext, question_side=True)\n\n        self._question_side = False\n        atext = apply_custom_filters(partial.anodes, self, front_side=qout.text)\n        aout = self.col()._backend.extract_av_tags(text=atext, question_side=False)\n\n        output = TemplateRenderOutput(\n            question_text=qout.text,\n            answer_text=aout.text,\n            question_av_tags=av_tags_to_native(qout.av_tags),\n            answer_av_tags=av_tags_to_native(aout.av_tags),\n            css=partial.css,\n        )\n\n        self._latex_svg = partial.latex_svg\n\n        if not self._browser:\n            hooks.card_did_render(output, self)\n\n        return output\n\n    def _partially_render(self) -> PartiallyRenderedCard:\n        if self._template:\n            # card layout screen\n            out = self._col._backend.render_uncommitted_card_legacy(\n                note=self._note._to_backend_note(),\n                card_ord=self._card.ord,\n                template=to_json_bytes(self._template),\n                fill_empty=self._fill_empty,\n                partial_render=True,\n            )\n            # when rendering card layout, the css changes have not been\n            # committed; we need the current notetype instance instead\n            out.css = self._note_type[\"css\"]\n        else:\n            # existing card (eg study mode)\n            out = self._col._backend.render_existing_card(\n                card_id=self._card.id, browser=self._browser, partial_render=True\n            )\n        return PartiallyRenderedCard.from_proto(out)\n\n\n@dataclass\nclass TemplateRenderOutput:\n    \"Stores the rendered templates and extracted AV tags.\"\n\n    question_text: str\n    answer_text: str\n    question_av_tags: list[AVTag]\n    answer_av_tags: list[AVTag]\n    css: str = \"\"\n\n    def question_and_style(self) -> str:\n        return f\"<style>{self.css}</style>{self.question_text}\"\n\n    def answer_and_style(self) -> str:\n        return f\"<style>{self.css}</style>{self.answer_text}\"\n\n\n# legacy\ndef templates_for_card(card: anki.cards.Card, browser: bool) -> tuple[str, str]:\n    template = card.template()\n    if browser:\n        question, answer = template.get(\"bqfmt\"), template.get(\"bafmt\")\n    else:\n        question, answer = None, None\n    question = question or template.get(\"qfmt\")\n    answer = answer or template.get(\"afmt\")\n    return question, answer  # type: ignore\n\n\ndef apply_custom_filters(\n    rendered: TemplateReplacementList,\n    ctx: TemplateRenderContext,\n    front_side: str | None,\n) -> str:\n    \"Complete rendering by applying any pending custom filters.\"\n    # template already fully rendered?\n    if len(rendered) == 1 and isinstance(rendered[0], str):\n        return rendered[0]\n\n    res = \"\"\n    for node in rendered:\n        if isinstance(node, str):\n            res += node\n        else:\n            # do we need to inject in FrontSide?\n            if node.field_name == \"FrontSide\" and front_side is not None:\n                node.current_text = front_side\n\n            field_text = node.current_text\n            for filter_name in node.filters:\n                field_text = hooks.field_filter(\n                    field_text, node.field_name, filter_name, ctx\n                )\n                # legacy hook - the second and fifth argument are no longer used.\n                field_text = hooks.runFilter(\n                    f\"fmod_{filter_name}\",\n                    field_text,\n                    \"\",\n                    ctx.note().items(),\n                    node.field_name,\n                    \"\",\n                )\n\n            res += field_text\n    return res\n"
  },
  {
    "path": "pylib/anki/types.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom typing import NoReturn\n\n\ndef assert_exhaustive(arg: NoReturn) -> NoReturn:\n    \"\"\"The type definition will cause mypy to tell us if we've missed an enum case.\"\"\"\n    raise Exception(f\"unexpected arg received: {type(arg)} {arg}\")\n"
  },
  {
    "path": "pylib/anki/utils.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json as _json\nimport os\nimport platform\nimport random\nimport shutil\nimport string\nimport subprocess\nimport sys\nimport tempfile\nimport time\nfrom collections.abc import Callable, Iterable, Iterator\nfrom contextlib import contextmanager\nfrom hashlib import sha1\nfrom typing import TYPE_CHECKING, Any\n\nfrom anki._legacy import DeprecatedNamesMixinForModule\nfrom anki.dbproxy import DBProxy\n\n_tmpdir: str | None\n\ntry:\n    import orjson\n\n    to_json_bytes: Callable[[Any], bytes] = orjson.dumps\n    from_json_bytes = orjson.loads\nexcept Exception:\n    print(\"orjson is missing; DB operations will be slower\")\n\n    def to_json_bytes(obj: Any) -> bytes:\n        return _json.dumps(obj).encode(\"utf8\")\n\n    from_json_bytes = _json.loads\n\n\n# Time handling\n##############################################################################\n\n\ndef int_time(scale: int = 1) -> int:\n    \"The time in integer seconds. Pass scale=1000 to get milliseconds.\"\n    return int(time.time() * scale)\n\n\n# HTML\n##############################################################################\n\n\ndef strip_html(txt: str) -> str:\n    import anki.lang\n    from anki.collection import StripHtmlMode\n\n    return anki.lang.current_i18n.strip_html(text=txt, mode=StripHtmlMode.NORMAL)\n\n\ndef strip_html_media(txt: str) -> str:\n    \"Strip HTML but keep media filenames\"\n    import anki.lang\n    from anki.collection import StripHtmlMode\n\n    return anki.lang.current_i18n.strip_html(\n        text=txt, mode=StripHtmlMode.PRESERVE_MEDIA_FILENAMES\n    )\n\n\ndef html_to_text_line(txt: str) -> str:\n    import anki.lang\n\n    return anki.lang.current_i18n.html_to_text_line(\n        text=txt, preserve_media_filenames=True\n    )\n\n\n# IDs\n##############################################################################\n\n\ndef ids2str(ids: Iterable[int | str]) -> str:\n    \"\"\"Given a list of integers, return a string '(int1,int2,...)'.\"\"\"\n    return f\"({','.join(str(i) for i in ids)})\"\n\n\ndef timestamp_id(db: DBProxy, table: str) -> int:\n    \"Return a non-conflicting timestamp for table.\"\n    # be careful not to create multiple objects without flushing them, or they\n    # may share an ID.\n    timestamp = int_time(1000)\n    while db.scalar(f\"select id from {table} where id = ?\", timestamp):\n        timestamp += 1\n    return timestamp\n\n\ndef max_id(db: DBProxy) -> int:\n    \"Return the first safe ID to use.\"\n    now = int_time(1000)\n    for tbl in \"cards\", \"notes\":\n        now = max(now, db.scalar(f\"select max(id) from {tbl}\") or 0)\n    return now + 1\n\n\n# used in ankiweb\ndef base62(num: int, extra: str = \"\") -> str:\n    table = string.ascii_letters + string.digits + extra\n    buf = \"\"\n    while num:\n        num, mod = divmod(num, len(table))\n        buf = table[mod] + buf\n    return buf\n\n\n_BASE91_EXTRA_CHARS = \"!#$%&()*+,-./:;<=>?@[]^_`{|}~\"\n\n\ndef base91(num: int) -> str:\n    # all printable characters minus quotes, backslash and separators\n    return base62(num, _BASE91_EXTRA_CHARS)\n\n\ndef guid64() -> str:\n    \"Return a base91-encoded 64bit random number.\"\n    return base91(random.randint(0, 2**64 - 1))\n\n\n# Fields\n##############################################################################\n\n\ndef join_fields(list: list[str]) -> str:\n    return \"\\x1f\".join(list)\n\n\ndef split_fields(string: str) -> list[str]:\n    return string.split(\"\\x1f\")\n\n\n# Checksums\n##############################################################################\n\n\ndef checksum(data: bytes | str) -> str:\n    if isinstance(data, str):\n        data = data.encode(\"utf-8\")\n    return sha1(data).hexdigest()\n\n\ndef field_checksum(data: str) -> int:\n    # 32 bit unsigned number from first 8 digits of sha1 hash\n    return int(checksum(strip_html_media(data).encode(\"utf-8\"))[:8], 16)\n\n\n# Temp files\n##############################################################################\n\n_tmpdir = None\n\n\ndef tmpdir() -> str:\n    \"A reusable temp folder which we clean out on each program invocation.\"\n    global _tmpdir\n    if not _tmpdir:\n\n        def cleanup() -> None:\n            if os.path.exists(_tmpdir):\n                shutil.rmtree(_tmpdir)\n\n        import atexit\n\n        atexit.register(cleanup)\n        _tmpdir = os.path.join(tempfile.gettempdir(), \"anki_temp\")\n    try:\n        os.mkdir(_tmpdir)\n    except FileExistsError:\n        pass\n    return _tmpdir\n\n\ndef tmpfile(prefix: str = \"\", suffix: str = \"\") -> str:\n    (descriptor, name) = tempfile.mkstemp(dir=tmpdir(), prefix=prefix, suffix=suffix)\n    os.close(descriptor)\n    return name\n\n\ndef namedtmp(name: str, remove: bool = True) -> str:\n    \"Return tmpdir+name. Deletes any existing file.\"\n    path = os.path.join(tmpdir(), name)\n    if remove:\n        try:\n            os.unlink(path)\n        except OSError:\n            pass\n    return path\n\n\n# Cmd invocation\n##############################################################################\n\n\n@contextmanager\ndef no_bundled_libs() -> Iterator[None]:\n    oldlpath = os.environ.pop(\"LD_LIBRARY_PATH\", None)\n    yield\n    if oldlpath is not None:\n        os.environ[\"LD_LIBRARY_PATH\"] = oldlpath\n\n\ndef call(argv: list[str], wait: bool = True, **kwargs: Any) -> int:\n    \"Execute a command. If WAIT, return exit code.\"\n    # ensure we don't open a separate window for forking process on windows\n    if is_win:\n        info = subprocess.STARTUPINFO()  # type: ignore\n        try:\n            info.dwFlags |= subprocess.STARTF_USESHOWWINDOW  # type: ignore\n        except Exception:\n            info.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW  # type: ignore\n    else:\n        info = None\n    # run\n    try:\n        with no_bundled_libs():\n            process = subprocess.Popen(argv, startupinfo=info, **kwargs)\n    except OSError:\n        # command not found\n        return -1\n    # wait for command to finish\n    if wait:\n        while 1:\n            try:\n                ret = process.wait()\n            except OSError:\n                # interrupted system call\n                continue\n            break\n    else:\n        ret = 0\n    return ret\n\n\n# OS helpers\n##############################################################################\n\nis_mac = sys.platform == \"darwin\"\nis_win = sys.platform == \"win32\"\n# also covers *BSD\nis_lin = not is_mac and not is_win\nis_gnome = (\n    \"gnome\" in os.getenv(\"XDG_CURRENT_DESKTOP\", \"\").lower()\n    or \"gnome\" in os.getenv(\"DESKTOP_SESSION\", \"\").lower()\n)\ndev_mode = os.getenv(\"ANKIDEV\", \"\")\nhmr_mode = os.getenv(\"HMR\", \"\")\n\nINVALID_FILENAME_CHARS = ':*?\"<>|'\n\n\ndef invalid_filename(str: str, dirsep: bool = True) -> str | None:\n    for char in INVALID_FILENAME_CHARS:\n        if char in str:\n            return char\n    if (dirsep or is_win) and \"/\" in str:\n        return \"/\"\n    elif (dirsep or not is_win) and \"\\\\\" in str:\n        return \"\\\\\"\n    elif str.strip().startswith(\".\"):\n        return \".\"\n    return None\n\n\ndef plat_desc() -> str:\n    # we may get an interrupted system call, so try this in a loop\n    theos = \"unknown\"\n    for _ in range(100):\n        try:\n            system = platform.system()\n            if is_mac:\n                theos = f\"mac:{platform.mac_ver()[0]}\"\n            elif is_win:\n                theos = f\"win:{platform.win32_ver()[0]}\"\n            elif system == \"Linux\":\n                import distro  # pytype: disable=import-error\n\n                dist_id = distro.id()\n                dist_version = distro.version()\n                theos = f\"lin:{dist_id}:{dist_version}\"\n            else:\n                theos = system\n            break\n        except Exception:\n            continue\n    return theos\n\n\n# Version\n##############################################################################\n\n\ndef version_with_build() -> str:\n    from anki.buildinfo import buildhash, version\n\n    return f\"{version} ({buildhash})\"\n\n\ndef int_version() -> int:\n    \"\"\"Anki's version as an integer in the form YYMMPP, e.g. 230900.\n    (year, month, patch).\n    In 2.1.x releases, this was just the last number.\"\"\"\n    import re\n\n    from anki.buildinfo import version\n\n    # Strip non-numeric characters (handles beta/rc suffixes like '25.02b1' or 'rc3')\n    numeric_version = re.sub(r\"[^0-9.]\", \"\", version)\n\n    try:\n        [year, month, patch] = numeric_version.split(\".\")\n    except ValueError:\n        [year, month] = numeric_version.split(\".\")\n        patch = \"0\"\n\n    year_num = int(year)\n    month_num = int(month)\n    patch_num = int(patch)\n\n    return year_num * 10_000 + month_num * 100 + patch_num\n\n\ndef int_version_to_str(ver: int) -> str:\n    if ver <= 99:\n        return f\"2.1.{ver}\"\n    else:\n        year = ver // 10_000\n        month = (ver // 100) % 100\n        patch = ver % 100\n        out = f\"{year:02}.{month:02}\"\n        if patch:\n            out += f\".{patch}\"\n        return out\n\n\n# these two legacy aliases are provided without deprecation warnings, as add-ons that want to support\n# old versions could not use the new name without catching cases where it doesn't exist\npoint_version = int_version\npointVersion = int_version\n\n_deprecated_names = DeprecatedNamesMixinForModule(globals())\n_deprecated_names.register_deprecated_aliases(\n    stripHTML=strip_html,\n    stripHTMLMedia=strip_html_media,\n    timestampID=timestamp_id,\n    maxID=max_id,\n    invalidFilenameChars=(INVALID_FILENAME_CHARS, \"INVALID_FILENAME_CHARS\"),\n)\n_deprecated_names.register_deprecated_attributes(json=((_json, \"_json\"), None))\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name: str) -> Any:\n        return _deprecated_names.__getattr__(name)\n"
  },
  {
    "path": "pylib/hatch_build.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport platform\nimport sys\nfrom pathlib import Path\nfrom typing import Any, Dict\n\nfrom hatchling.builders.hooks.plugin.interface import BuildHookInterface\n\n\nclass CustomBuildHook(BuildHookInterface):\n    \"\"\"Build hook to include compiled rsbridge from out/pylib.\"\"\"\n\n    PLUGIN_NAME = \"custom\"\n\n    def initialize(self, version: str, build_data: Dict[str, Any]) -> None:\n        \"\"\"Initialize the build hook.\"\"\"\n        force_include = build_data.setdefault(\"force_include\", {})\n\n        # Set platform-specific wheel tag\n        if not (platform_tag := os.environ.get(\"ANKI_WHEEL_TAG\")):\n            # On Windows, uv invokes this build hook during the initial uv sync,\n            # when the tag has not been declared by our build script.\n            return\n        build_data.setdefault(\"tag\", platform_tag)\n\n        # Mark as non-pure Python since we include compiled extension\n        build_data[\"pure_python\"] = False\n\n        # Look for generated files in out/pylib/anki\n        project_root = Path(self.root).parent\n        generated_root = project_root / \"out\" / \"pylib\" / \"anki\"\n\n        assert generated_root.exists(), \"you should build with --wheel\"\n        for path in generated_root.rglob(\"*\"):\n            if path.is_file() and not self._should_exclude(path):\n                relative_path = path.relative_to(generated_root)\n                # Place files under anki/ in the distribution\n                dist_path = \"anki\" / relative_path\n                force_include[str(path)] = str(dist_path)\n\n    def _should_exclude(self, path: Path) -> bool:\n        \"\"\"Check if a file should be excluded from the wheel.\"\"\"\n        # Exclude __pycache__\n        path_str = str(path)\n        if \"/__pycache__/\" in path_str:\n            return True\n        return False\n"
  },
  {
    "path": "pylib/pyproject.toml",
    "content": "[project]\nname = \"anki\"\ndynamic = [\"version\"]\nrequires-python = \">=3.9\"\nlicense = \"AGPL-3.0-or-later\"\ndependencies = [\n  \"decorator\",\n  \"markdown\",\n  \"orjson\",\n  \"protobuf>=6.0,<8.0\",\n  \"requests[socks]\",\n  # remove after we update to min python 3.11+\n  \"typing_extensions\",\n  # platform-specific dependencies\n  \"distro; sys_platform != 'darwin' and sys_platform != 'win32'\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"anki\"]\n\n[tool.hatch.version]\nsource = \"code\"\npath = \"../python/version.py\"\n\n[tool.hatch.build.hooks.custom]\npath = \"hatch_build.py\"\n"
  },
  {
    "path": "pylib/rsbridge/.gitignore",
    "content": "target\nCargo.lock\n"
  },
  {
    "path": "pylib/rsbridge/Cargo.toml",
    "content": "[package]\nname = \"rsbridge\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Anki's Rust library code Python bindings\"\n\n[lib]\nname = \"rsbridge\"\ncrate-type = [\"cdylib\"]\npath = \"lib.rs\"\ntest = false\n\n[dependencies]\nanki.workspace = true\npyo3.workspace = true\n\n[features]\nrustls = [\"anki/rustls\"]\nnative-tls = [\"anki/native-tls\"]\n"
  },
  {
    "path": "pylib/rsbridge/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfn main() {\n    // macOS needs special link flags for PyO3\n    if cfg!(target_os = \"macos\") {\n        println!(\"cargo:rustc-link-arg=-undefined\");\n        println!(\"cargo:rustc-link-arg=dynamic_lookup\");\n        println!(\"cargo:rustc-link-arg=-mmacosx-version-min=11\");\n    }\n\n    // On Windows, we need to be able to link with python3.lib\n    if cfg!(windows) {\n        use std::process::Command;\n\n        // Run Python to get sysconfig paths\n        let output = Command::new(\"../../out/pyenv/scripts/python\")\n            .args([\n                \"-c\",\n                \"import sysconfig; print(sysconfig.get_paths()['stdlib'])\",\n            ])\n            .output()\n            .expect(\"Failed to execute Python\");\n\n        let stdlib_path = String::from_utf8(output.stdout)\n            .expect(\"Failed to parse Python output\")\n            .trim()\n            .to_string();\n\n        let libs_path = stdlib_path + \"s\";\n        println!(\"cargo:rustc-link-search={libs_path}\");\n    }\n}\n"
  },
  {
    "path": "pylib/rsbridge/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki::backend::init_backend;\nuse anki::backend::Backend as RustBackend;\nuse anki::log::set_global_logger;\nuse anki::sync::http_server::SimpleServer;\nuse pyo3::create_exception;\nuse pyo3::exceptions::PyException;\nuse pyo3::prelude::*;\nuse pyo3::types::PyBytes;\nuse pyo3::wrap_pyfunction;\n\n#[pyclass(module = \"_rsbridge\")]\nstruct Backend {\n    backend: RustBackend,\n}\n\ncreate_exception!(_rsbridge, BackendError, PyException);\n\n#[pyfunction]\nfn buildhash() -> &'static str {\n    anki::version::buildhash()\n}\n\n#[pyfunction]\n#[pyo3(signature = (path=None))]\nfn initialize_logging(path: Option<&str>) -> PyResult<()> {\n    set_global_logger(path).map_err(|e| PyException::new_err(e.to_string()))\n}\n\n#[pyfunction]\nfn syncserver() -> PyResult<()> {\n    set_global_logger(None).unwrap();\n    let err = SimpleServer::run();\n    Err(PyException::new_err(err.to_string()))\n}\n\n#[pyfunction]\nfn open_backend(init_msg: &Bound<'_, PyBytes>) -> PyResult<Backend> {\n    match init_backend(init_msg.as_bytes()) {\n        Ok(backend) => Ok(Backend { backend }),\n        Err(e) => Err(PyException::new_err(e)),\n    }\n}\n\n#[pymethods]\nimpl Backend {\n    fn command(\n        &self,\n        py: Python,\n        service: u32,\n        method: u32,\n        input: &Bound<'_, PyBytes>,\n    ) -> PyResult<PyObject> {\n        let in_bytes = input.as_bytes();\n        py.allow_threads(|| self.backend.run_service_method(service, method, in_bytes))\n            .map(|out_bytes| {\n                let out_obj = PyBytes::new(py, &out_bytes);\n                out_obj.into()\n            })\n            .map_err(BackendError::new_err)\n    }\n\n    /// This takes and returns JSON, due to Python's slow protobuf\n    /// encoding/decoding.\n    fn db_command(&self, py: Python, input: &Bound<'_, PyBytes>) -> PyResult<PyObject> {\n        let in_bytes = input.as_bytes();\n        let out_res = py.allow_threads(|| {\n            self.backend\n                .run_db_command_bytes(in_bytes)\n                .map_err(BackendError::new_err)\n        });\n        let out_bytes = out_res?;\n        let out_obj = PyBytes::new(py, &out_bytes);\n        Ok(out_obj.into())\n    }\n}\n\n// Module definition\n//////////////////////////////////\n\n#[pymodule]\nfn _rsbridge(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {\n    m.add_class::<Backend>()?;\n    m.add_wrapped(wrap_pyfunction!(buildhash)).unwrap();\n    m.add_wrapped(wrap_pyfunction!(open_backend)).unwrap();\n    m.add_wrapped(wrap_pyfunction!(initialize_logging)).unwrap();\n    m.add_wrapped(wrap_pyfunction!(syncserver)).unwrap();\n\n    Ok(())\n}\n"
  },
  {
    "path": "pylib/tests/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom anki.lang import set_lang\n\nset_lang(\"en_US\")\n"
  },
  {
    "path": "pylib/tests/shared.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport shutil\nimport tempfile\nimport time\n\nfrom anki.collection import Collection as aopen\n\n# Between 2-4AM, shift the time back so test assumptions hold.\nlt = time.localtime()\nif lt.tm_hour >= 2 and lt.tm_hour < 4:\n    orig_time = time.time\n\n    def adjusted_time():\n        return orig_time() - 60 * 60 * 2\n\n    time.time = adjusted_time\nelse:\n    orig_time = None\n\n\ndef assertException(exception, func):\n    found = False\n    try:\n        func()\n    except exception:\n        found = True\n    assert found\n\n\n# Creating new decks is expensive. Just do it once, and then spin off\n# copies from the master.\n_emptyCol: str | None = None\n\n\ndef getEmptyCol():\n    global _emptyCol\n    if not _emptyCol:\n        (fd, path) = tempfile.mkstemp(suffix=\".anki2\")\n        os.close(fd)\n        os.unlink(path)\n        col = aopen(path)\n        col.close(downgrade=False)\n        _emptyCol = path\n    (fd, path) = tempfile.mkstemp(suffix=\".anki2\")\n    shutil.copy(_emptyCol, path)\n    col = aopen(path)\n    return col\n\n\n# Fallback for when the DB needs options passed in.\ndef getEmptyDeckWith(**kwargs):\n    (fd, nam) = tempfile.mkstemp(suffix=\".anki2\")\n    os.close(fd)\n    os.unlink(nam)\n    return aopen(nam, **kwargs)\n\n\ndef getUpgradeDeckPath(name=\"anki12.anki\"):\n    src = os.path.join(testDir, \"support\", name)\n    (fd, dst) = tempfile.mkstemp(suffix=\".anki2\")\n    shutil.copy(src, dst)\n    return dst\n\n\ntestDir = os.path.dirname(__file__)\n\n\ndef errorsAfterMidnight(func):\n    def wrapper():\n        lt = time.localtime()\n        if lt.tm_hour < 4:\n            print(\"test disabled around cutoff\", func)\n        else:\n            func()\n\n    return wrapper\n\n\ndef isNearCutoff():\n    return orig_time is not None\n"
  },
  {
    "path": "pylib/tests/support/supermemo1.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<SuperMemoCollection>\n  <Count>3572</Count>\n\n  <SuperMemoElement>\n    <ID>1</ID>\n\n    <Type>Topic</Type>\n\n    <Content>\n      <Question />\n\n      <Answer />\n    </Content>\n\n    <SuperMemoElement>\n      <ID>40326</ID>\n\n      <Title>aoeu</Title>\n\n      <Type>Topic</Type>\n\n      <SuperMemoElement>\n        <ID>40327</ID>\n\n        <Title>1-400</Title>\n\n        <Type>Topic</Type>\n\n        <SuperMemoElement>\n          <ID>40615</ID>\n\n          <Title>aoeu</Title>\n\n          <Type>Topic</Type>\n\n          <SuperMemoElement>\n            <ID>10247</ID>\n\n            <Type>Item</Type>\n\n            <Content>\n              <Question>aoeu</Question>\n\n              <Answer>aoeu</Answer>\n            </Content>\n\n            <LearningData>\n              <Interval>1844</Interval>\n\n              <Repetitions>7</Repetitions>\n\n              <Lapses>0</Lapses>\n\n              <LastRepetition>19.09.2002</LastRepetition>\n\n              <AFactor>5,701</AFactor>\n\n              <UFactor>2,452</UFactor>\n            </LearningData>\n          </SuperMemoElement>\n\n        </SuperMemoElement>\n\n        <Type>Topic</Type>\n\n        <Content>\n          <Question>aoeu</Question>\n          <Answer />\n        </Content>\n\n        <LearningData>\n          <Interval>0</Interval>\n\n          <Repetitions>0</Repetitions>\n\n          <Lapses>0</Lapses>\n\n          <LastRepetition>04.08.2000</LastRepetition>\n\n          <AFactor>3,000</AFactor>\n\n          <UFactor>0,000</UFactor>\n        </LearningData>\n\n      </SuperMemoElement>\n    </SuperMemoElement>\n  </SuperMemoElement>\n</SuperMemoCollection>\n"
  },
  {
    "path": "pylib/tests/support/text-2fields.txt",
    "content": "# this is a test file\n食べる\tto eat\n飲む\tto drink\nテスト\ttest\nto eat\t食べる\n飲む\tto drink\n多すぎる\ttoo many\tfields\nnot, enough, fields\n遊ぶ\t\n\tto play\n"
  },
  {
    "path": "pylib/tests/support/text-tags.txt",
    "content": "foo\tbar\tbaz,qux\nfoo2\tbar2\tbaz2\n"
  },
  {
    "path": "pylib/tests/support/text-update.txt",
    "content": "1\tx\n"
  },
  {
    "path": "pylib/tests/test_cards.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nfrom tests.shared import getEmptyCol\n\n\ndef test_delete():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"2\"\n    col.addNote(note)\n    cid = note.cards()[0].id\n    col.sched.answerCard(col.sched.getCard(), 2)\n    col.remove_cards_and_orphaned_notes([cid])\n    assert col.card_count() == 0\n    assert col.note_count() == 0\n    assert col.db.scalar(\"select count() from notes\") == 0\n    assert col.db.scalar(\"select count() from cards\") == 0\n    assert col.db.scalar(\"select count() from graves\") == 2\n\n\ndef test_misc():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"2\"\n    col.addNote(note)\n    c = note.cards()[0]\n    id = col.models.current()[\"id\"]\n    assert c.template()[\"ord\"] == 0\n\n\ndef test_genrem():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"\"\n    col.addNote(note)\n    assert len(note.cards()) == 1\n    m = col.models.current()\n    mm = col.models\n    # adding a new template should automatically create cards\n    t = mm.new_template(\"rev\")\n    t[\"qfmt\"] = \"{{Front}}2\"\n    t[\"afmt\"] = \"\"\n    mm.add_template(m, t)\n    mm.save(m, templates=True)\n    assert len(note.cards()) == 2\n    # if the template is changed to remove cards, they'll be removed\n    t = m[\"tmpls\"][1]\n    t[\"qfmt\"] = \"{{Back}}\"\n    mm.save(m, templates=True)\n    rep = col._backend.get_empty_cards()\n    rep = col._backend.get_empty_cards()\n    for n in rep.notes:\n        col.remove_cards_and_orphaned_notes(n.card_ids)\n    assert len(note.cards()) == 1\n    # if we add to the note, a card should be automatically generated\n    note.load()\n    note[\"Back\"] = \"1\"\n    note.flush()\n    assert len(note.cards()) == 2\n\n\ndef test_gendeck():\n    col = getEmptyCol()\n    cloze = col.models.by_name(\"Cloze\")\n    note = col.new_note(cloze)\n    note[\"Text\"] = \"{{c1::one}}\"\n    col.addNote(note)\n    assert col.card_count() == 1\n    assert note.cards()[0].did == 1\n    # set the model to a new default col\n    newId = col.decks.id(\"new\")\n    col.set_aux_notetype_config(cloze[\"id\"], \"lastDeck\", newId)\n    col.models.save(cloze, updateReqs=False)\n    # a newly generated card should share the first card's col\n    note[\"Text\"] += \"{{c2::two}}\"\n    note.flush()\n    assert note.cards()[1].did == 1\n    # and same with multiple cards\n    note[\"Text\"] += \"{{c3::three}}\"\n    note.flush()\n    assert note.cards()[2].did == 1\n    # if one of the cards is in a different col, it should revert to the\n    # model default\n    c = note.cards()[1]\n    c.did = newId\n    c.flush()\n    note[\"Text\"] += \"{{c4::four}}\"\n    note.flush()\n    assert note.cards()[3].did == newId\n"
  },
  {
    "path": "pylib/tests/test_collection.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nimport os\nimport tempfile\nfrom typing import Any\n\nfrom anki.collection import Collection as aopen\nfrom anki.dbproxy import emulate_named_args\nfrom anki.lang import TR, without_unicode_isolation\nfrom anki.stdmodels import _legacy_add_basic_model, get_stock_notetypes\nfrom anki.utils import is_win\nfrom tests.shared import assertException, getEmptyCol\n\n\ndef test_create_open():\n    (fd, path) = tempfile.mkstemp(suffix=\".anki2\", prefix=\"test_attachNew\")\n    try:\n        os.close(fd)\n        os.unlink(path)\n    except OSError:\n        pass\n    col = aopen(path)\n    # for open()\n    newPath = col.path\n    newMod = col.mod\n    col.close()\n    del col\n\n    # reopen\n    col = aopen(newPath)\n    assert col.mod == newMod\n    col.close()\n\n    # non-writeable dir\n    if is_win:\n        dir = \"c:\\root.anki2\"\n    else:\n        dir = \"/attachroot.anki2\"\n    assertException(Exception, lambda: aopen(dir))\n    # reuse tmp file from before, test non-writeable file\n    os.chmod(newPath, 0)\n    assertException(Exception, lambda: aopen(newPath))\n    os.chmod(newPath, 0o666)\n    os.unlink(newPath)\n\n\ndef test_noteAddDelete():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    n = col.addNote(note)\n    assert n == 1\n    # test multiple cards - add another template\n    m = col.models.current()\n    mm = col.models\n    t = mm.new_template(\"Reverse\")\n    t[\"qfmt\"] = \"{{Back}}\"\n    t[\"afmt\"] = \"{{Front}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    assert col.card_count() == 2\n    # creating new notes should use both cards\n    note = col.newNote()\n    note[\"Front\"] = \"three\"\n    note[\"Back\"] = \"four\"\n    n = col.addNote(note)\n    assert n == 2\n    assert col.card_count() == 4\n    # check q/a generation\n    c0 = note.cards()[0]\n    assert \"three\" in c0.question()\n    # it should not be a duplicate\n    assert not note.fields_check()\n    # now let's make a duplicate\n    note2 = col.newNote()\n    note2[\"Front\"] = \"one\"\n    note2[\"Back\"] = \"\"\n    assert note2.fields_check()\n    # empty first field should not be permitted either\n    note2[\"Front\"] = \" \"\n    assert note2.fields_check()\n\n\ndef test_fieldChecksum():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"new\"\n    note[\"Back\"] = \"new2\"\n    col.addNote(note)\n    assert col.db.scalar(\"select csum from notes\") == int(\"c2a6b03f\", 16)\n    # changing the val should change the checksum\n    note[\"Front\"] = \"newx\"\n    note.flush()\n    assert col.db.scalar(\"select csum from notes\") == int(\"302811ae\", 16)\n\n\ndef test_addDelTags():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    col.addNote(note)\n    note2 = col.newNote()\n    note2[\"Front\"] = \"2\"\n    col.addNote(note2)\n    # adding for a given id\n    col.tags.bulk_add([note.id], \"foo\")\n    note.load()\n    note2.load()\n    assert \"foo\" in note.tags\n    assert \"foo\" not in note2.tags\n    # should be canonified\n    col.tags.bulk_add([note.id], \"foo aaa\")\n    note.load()\n    assert note.tags[0] == \"aaa\"\n    assert len(note.tags) == 2\n\n\ndef test_timestamps():\n    col = getEmptyCol()\n    assert len(col.models.all_names_and_ids()) == len(get_stock_notetypes(col))\n    for i in range(100):\n        _legacy_add_basic_model(col)\n    assert len(col.models.all_names_and_ids()) == 100 + len(get_stock_notetypes(col))\n\n\ndef test_furigana():\n    col = getEmptyCol()\n    mm = col.models\n    m = mm.current()\n    # filter should work\n    m[\"tmpls\"][0][\"qfmt\"] = \"{{kana:Front}}\"\n    mm.save(m)\n    n = col.newNote()\n    n[\"Front\"] = \"foo[abc]\"\n    col.addNote(n)\n    c = n.cards()[0]\n    assert c.question().endswith(\"abc\")\n    # and should avoid sound\n    n[\"Front\"] = \"foo[sound:abc.mp3]\"\n    n.flush()\n    assert \"anki:play\" in c.question(reload=True)\n    # it shouldn't throw an error while people are editing\n    m[\"tmpls\"][0][\"qfmt\"] = \"{{kana:}}\"\n    mm.save(m)\n    c.question(reload=True)\n\n\ndef test_translate():\n    col = getEmptyCol()\n    no_uni = without_unicode_isolation\n\n    assert (\n        col.tr.card_template_rendering_front_side_problem()\n        == \"Front template has a problem:\"\n    )\n    assert no_uni(col.tr.statistics_reviews(reviews=1)) == \"1 review\"\n    assert no_uni(col.tr.statistics_reviews(reviews=2)) == \"2 reviews\"\n\n\ndef test_db_named_args(capsys):\n    sql = \"select a, 2+:test5 from b where arg =:foo and x = :test5\"\n    args: tuple = tuple()\n    kwargs = dict(test5=5, foo=\"blah\")\n\n    s, a = emulate_named_args(sql, args, kwargs)\n    assert s == \"select a, 2+?1 from b where arg =?2 and x = ?1\"\n    assert a == [5, \"blah\"]\n\n    # swallow the warning\n    _ = capsys.readouterr()\n"
  },
  {
    "path": "pylib/tests/test_decks.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nfrom anki.errors import DeckRenameError\nfrom tests.shared import assertException, getEmptyCol\n\n\ndef test_basic():\n    col = getEmptyCol()\n    col.set_v3_scheduler(False)\n    # we start with a standard col\n    assert len(col.decks.all_names_and_ids()) == 1\n    # it should have an id of 1\n    assert col.decks.name(1)\n    # create a new col\n    parentId = col.decks.id(\"new deck\")\n    assert parentId\n    assert len(col.decks.all_names_and_ids()) == 2\n    # should get the same id\n    assert col.decks.id(\"new deck\") == parentId\n    # we start with the default col selected\n    assert col.decks.selected() == 1\n    # we can select a different col\n    col.decks.select(parentId)\n    assert col.decks.selected() == parentId\n    # let's create a child\n    childId = col.decks.id(\"new deck::child\")\n    # it should have been added to the active list\n    assert col.decks.selected() == parentId\n    # we can select the child individually too\n    col.decks.select(childId)\n    assert col.decks.selected() == childId\n    # parents with a different case should be handled correctly\n    col.decks.id(\"ONE\")\n    m = col.models.current()\n    m[\"did\"] = col.decks.id(\"one::two\")\n    col.models.save(m, updateReqs=False)\n    n = col.newNote()\n    n[\"Front\"] = \"abc\"\n    col.addNote(n)\n\n\ndef test_remove():\n    col = getEmptyCol()\n    # create a new col, and add a note/card to it\n    deck1 = col.decks.id(\"deck1\")\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note_type = note.note_type()\n    note_type[\"did\"] = deck1\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    c = note.cards()[0]\n    assert c.did == deck1\n    assert col.card_count() == 1\n    col.decks.remove([deck1])\n    assert col.card_count() == 0\n    # if we try to get it, we get the default\n    assert col.decks.name(c.did) == \"[no deck]\"\n\n\ndef test_rename():\n    col = getEmptyCol()\n    id = col.decks.id(\"hello::world\")\n    # should be able to rename into a completely different branch, creating\n    # parents as necessary\n    col.decks.rename(col.decks.get(id), \"foo::bar\")\n    names = [n.name for n in col.decks.all_names_and_ids()]\n    assert \"foo\" in names\n    assert \"foo::bar\" in names\n    assert \"hello::world\" not in names\n    # create another col\n    id = col.decks.id(\"tmp\")\n    # automatically adjusted if a duplicate name\n    col.decks.rename(col.decks.get(id), \"FOO\")\n    names = [n.name for n in col.decks.all_names_and_ids()]\n    assert \"FOO+\" in names\n    # when renaming, the children should be renamed too\n    col.decks.id(\"one::two::three\")\n    id = col.decks.id(\"one\")\n    col.decks.rename(col.decks.get(id), \"yo\")\n    names = [n.name for n in col.decks.all_names_and_ids()]\n    for n in \"yo\", \"yo::two\", \"yo::two::three\":\n        assert n in names\n    # over filtered\n    filteredId = col.decks.new_filtered(\"filtered\")\n    filtered = col.decks.get(filteredId)\n    childId = col.decks.id(\"child\")\n    child = col.decks.get(childId)\n    assertException(DeckRenameError, lambda: col.decks.rename(child, \"filtered::child\"))\n    assertException(DeckRenameError, lambda: col.decks.rename(child, \"FILTERED::child\"))\n"
  },
  {
    "path": "pylib/tests/test_exporting.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport tempfile\n\nfrom anki.collection import Collection as aopen\nfrom anki.exporting import *\nfrom anki.importing import Anki2Importer\nfrom tests.shared import errorsAfterMidnight\nfrom tests.shared import getEmptyCol as getEmptyColOrig\n\n\ndef getEmptyCol():\n    col = getEmptyColOrig()\n    col.upgrade_to_v2_scheduler()\n    return col\n\n\ncol: Collection | None = None\ntestDir = os.path.dirname(__file__)\n\n\ndef setup1():\n    global col\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"foo\"\n    note[\"Back\"] = \"bar<br>\"\n    note.tags = [\"tag\", \"tag2\"]\n    col.addNote(note)\n    # with a different col\n    note = col.newNote()\n    note[\"Front\"] = \"baz\"\n    note[\"Back\"] = \"qux\"\n    note_type = note.note_type()\n    note_type[\"did\"] = col.decks.id(\"new col\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n\n\n##########################################################################\n\n\ndef test_export_anki():\n    setup1()\n    # create a new col with its own conf to test conf copying\n    did = col.decks.id(\"test\")\n    dobj = col.decks.get(did)\n    confId = col.decks.add_config_returning_id(\"newconf\")\n    conf = col.decks.get_config(confId)\n    conf[\"new\"][\"perDay\"] = 5\n    col.decks.save(conf)\n    col.decks.set_config_id_for_deck_dict(dobj, confId)\n    # export\n    e = AnkiExporter(col)\n    fd, newname = tempfile.mkstemp(prefix=\"ankitest\", suffix=\".anki2\")\n    newname = str(newname)\n    os.close(fd)\n    os.unlink(newname)\n    e.exportInto(newname)\n    # exporting should not have changed conf for original deck\n    conf = col.decks.config_dict_for_deck_id(did)\n    assert conf[\"id\"] != 1\n    # connect to new deck\n    col2 = aopen(newname)\n    assert col2.card_count() == 2\n    # as scheduling was reset, should also revert decks to default conf\n    did = col2.decks.id(\"test\", create=False)\n    assert did\n    conf2 = col2.decks.config_dict_for_deck_id(did)\n    assert conf2[\"new\"][\"perDay\"] == 20\n    dobj = col2.decks.get(did)\n    # conf should be 1\n    assert dobj[\"conf\"] == 1\n    # try again, limited to a deck\n    fd, newname = tempfile.mkstemp(prefix=\"ankitest\", suffix=\".anki2\")\n    newname = str(newname)\n    os.close(fd)\n    os.unlink(newname)\n    e.did = DeckId(1)\n    e.exportInto(newname)\n    col2 = aopen(newname)\n    assert col2.card_count() == 1\n\n\ndef test_export_ankipkg():\n    setup1()\n    # add a test file to the media folder\n    with open(os.path.join(col.media.dir(), \"今日.mp3\"), \"w\") as note:\n        note.write(\"test\")\n    n = col.newNote()\n    n[\"Front\"] = \"[sound:今日.mp3]\"\n    col.addNote(n)\n    e = AnkiPackageExporter(col)\n    fd, newname = tempfile.mkstemp(prefix=\"ankitest\", suffix=\".apkg\")\n    newname = str(newname)\n    os.close(fd)\n    os.unlink(newname)\n    e.exportInto(newname)\n\n\n@errorsAfterMidnight\ndef test_export_anki_due():\n    setup1()\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"foo\"\n    col.addNote(note)\n    col.crt -= 86400 * 10\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 3)\n    col.sched.answerCard(c, 3)\n    # should have ivl of 1, due on day 11\n    assert c.ivl == 1\n    assert c.due == 11\n    assert col.sched.today == 10\n    assert c.due - col.sched.today == 1\n    # export\n    e = AnkiExporter(col)\n    e.includeSched = True\n    fd, newname = tempfile.mkstemp(prefix=\"ankitest\", suffix=\".anki2\")\n    newname = str(newname)\n    os.close(fd)\n    os.unlink(newname)\n    e.exportInto(newname)\n    # importing into a new deck, the due date should be equivalent\n    col2 = getEmptyCol()\n    imp = Anki2Importer(col2, newname)\n    imp.run()\n    c = col2.getCard(c.id)\n    assert c.due - col2.sched.today == 1\n\n\n# def test_export_textcard():\n#     setup1()\n#     e = TextCardExporter(col)\n#     note = unicode(tempfile.mkstemp(prefix=\"ankitest\")[1])\n#     os.unlink(note)\n#     e.exportInto(note)\n#     e.includeTags = True\n#     e.exportInto(note)\n\n\ndef test_export_textnote():\n    setup1()\n    e = TextNoteExporter(col)\n    fd, note = tempfile.mkstemp(prefix=\"ankitest\")\n    note = str(note)\n    os.close(fd)\n    os.unlink(note)\n    e.exportInto(note)\n    with open(note) as file:\n        assert file.readline() == \"foo\\tbar<br>\\ttag tag2\\n\"\n    e.includeTags = False\n    e.includeHTML = False\n    e.exportInto(note)\n    with open(note) as file:\n        assert file.readline() == \"foo\\tbar\\n\"\n\n\ndef test_exporters():\n    assert \"*.apkg\" in str(exporters(getEmptyCol()))\n"
  },
  {
    "path": "pylib/tests/test_find.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\nimport pytest\n\nfrom anki.browser import BrowserConfig\nfrom anki.consts import *\nfrom tests.shared import getEmptyCol, isNearCutoff\n\n\nclass DummyCollection:\n    def weakref(self):\n        return None\n\n\ndef test_find_cards():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"dog\"\n    note[\"Back\"] = \"cat\"\n    note.tags.append(\"monkey animal_1 * %\")\n    col.addNote(note)\n    n1id = note.id\n    firstCardId = note.cards()[0].id\n    note = col.newNote()\n    note[\"Front\"] = \"goats are fun\"\n    note[\"Back\"] = \"sheep\"\n    note.tags.append(\"sheep goat horse animal11\")\n    col.addNote(note)\n    n2id = note.id\n    note = col.newNote()\n    note[\"Front\"] = \"cat\"\n    note[\"Back\"] = \"sheep\"\n    note.tags.append(\"conjunção größte\")\n    col.addNote(note)\n    catCard = note.cards()[0]\n    m = col.models.current()\n    m = col.models.copy(m)\n    mm = col.models\n    t = mm.new_template(\"Reverse\")\n    t[\"qfmt\"] = \"{{Back}}\"\n    t[\"afmt\"] = \"{{Front}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    note = col.newNote()\n    note[\"Front\"] = \"test\"\n    note[\"Back\"] = \"foo bar\"\n    col.addNote(note)\n    latestCardIds = [c.id for c in note.cards()]\n    # tag searches\n    assert len(col.find_cards(\"tag:*\")) == 5\n    assert len(col.find_cards(\"tag:\\\\*\")) == 1\n    assert len(col.find_cards(\"tag:%\")) == 1\n    assert len(col.find_cards(\"tag:sheep_goat\")) == 0\n    assert len(col.find_cards('\"tag:sheep goat\"')) == 0\n    assert len(col.find_cards('\"tag:* *\"')) == 0\n    assert len(col.find_cards(\"tag:animal_1\")) == 2\n    assert len(col.find_cards(\"tag:animal\\\\_1\")) == 1\n    assert not col.find_cards(\"tag:donkey\")\n    assert len(col.find_cards(\"tag:sheep\")) == 1\n    assert len(col.find_cards(\"tag:sheep tag:goat\")) == 1\n    assert len(col.find_cards(\"tag:sheep tag:monkey\")) == 0\n    assert len(col.find_cards(\"tag:monkey\")) == 1\n    assert len(col.find_cards(\"tag:sheep -tag:monkey\")) == 1\n    assert len(col.find_cards(\"-tag:sheep\")) == 4\n    col.tags.bulk_add(col.db.list(\"select id from notes\"), \"foo bar\")\n    assert len(col.find_cards(\"tag:foo\")) == len(col.find_cards(\"tag:bar\")) == 5\n    col.tags.bulk_remove(col.db.list(\"select id from notes\"), \"foo\")\n    assert len(col.find_cards(\"tag:foo\")) == 0\n    assert len(col.find_cards(\"tag:bar\")) == 5\n    assert len(col.find_cards(\"tag:conjuncao tag:groste\")) == 0\n    assert len(col.find_cards(\"tag:nc:conjuncao tag:nc:groste\")) == 1\n    # text searches\n    assert len(col.find_cards(\"cat\")) == 2\n    assert len(col.find_cards(\"cat -dog\")) == 1\n    assert len(col.find_cards(\"cat -dog\")) == 1\n    assert len(col.find_cards(\"are goats\")) == 1\n    assert len(col.find_cards('\"are goats\"')) == 0\n    assert len(col.find_cards('\"goats are\"')) == 1\n    # card states\n    c = note.cards()[0]\n    c.queue = c.type = CARD_TYPE_REV\n    assert col.find_cards(\"is:review\") == []\n    c.flush()\n    assert col.find_cards(\"is:review\") == [c.id]\n    assert col.find_cards(\"is:due\") == []\n    c.due = 0\n    c.queue = QUEUE_TYPE_REV\n    c.flush()\n    assert col.find_cards(\"is:due\") == [c.id]\n    assert len(col.find_cards(\"-is:due\")) == 4\n    c.queue = QUEUE_TYPE_SUSPENDED\n    # ensure this card gets a later mod time\n    c.flush()\n    col.db.execute(\"update cards set mod = mod + 1 where id = ?\", c.id)\n    assert col.find_cards(\"is:suspended\") == [c.id]\n    # nids\n    assert col.find_cards(\"nid:54321\") == []\n    assert len(col.find_cards(f\"nid:{note.id}\")) == 2\n    assert len(col.find_cards(f\"nid:{n1id},{n2id}\")) == 2\n    # templates\n    assert len(col.find_cards(\"card:foo\")) == 0\n    assert len(col.find_cards('\"card:card 1\"')) == 4\n    assert len(col.find_cards(\"card:reverse\")) == 1\n    assert len(col.find_cards(\"card:1\")) == 4\n    assert len(col.find_cards(\"card:2\")) == 1\n    # fields\n    assert len(col.find_cards(\"front:dog\")) == 1\n    assert len(col.find_cards(\"-front:dog\")) == 4\n    assert len(col.find_cards(\"front:sheep\")) == 0\n    assert len(col.find_cards(\"back:sheep\")) == 2\n    assert len(col.find_cards(\"-back:sheep\")) == 3\n    assert len(col.find_cards(\"front:do\")) == 0\n    assert len(col.find_cards(\"front:*\")) == 5\n    # ordering\n    col.conf[\"sortType\"] = \"noteCrt\"\n    assert col.find_cards(\"front:*\", order=True)[-1] in latestCardIds\n    assert col.find_cards(\"\", order=True)[-1] in latestCardIds\n    col.conf[\"sortType\"] = \"noteFld\"\n    assert col.find_cards(\"\", order=True)[0] == catCard.id\n    assert col.find_cards(\"\", order=True)[-1] in latestCardIds\n    col.conf[\"sortType\"] = \"cardMod\"\n    assert col.find_cards(\"\", order=True)[-1] in latestCardIds\n    assert col.find_cards(\"\", order=True)[0] == firstCardId\n    col.set_config(BrowserConfig.CARDS_SORT_BACKWARDS_KEY, True)\n    assert col.find_cards(\"\", order=True)[0] in latestCardIds\n    assert (\n        col.find_cards(\"\", order=col.get_browser_column(\"cardDue\"), reverse=False)[0]\n        == firstCardId\n    )\n    assert (\n        col.find_cards(\"\", order=col.get_browser_column(\"cardDue\"), reverse=True)[0]\n        != firstCardId\n    )\n    # model\n    assert len(col.find_cards(\"note:basic\")) == 3\n    assert len(col.find_cards(\"-note:basic\")) == 2\n    assert len(col.find_cards(\"-note:foo\")) == 5\n    # col\n    assert len(col.find_cards(\"deck:default\")) == 5\n    assert len(col.find_cards(\"-deck:default\")) == 0\n    assert len(col.find_cards(\"-deck:foo\")) == 5\n    assert len(col.find_cards(\"deck:def*\")) == 5\n    assert len(col.find_cards(\"deck:*EFAULT\")) == 5\n    assert len(col.find_cards(\"deck:*cefault\")) == 0\n    # full search\n    note = col.newNote()\n    note[\"Front\"] = \"hello<b>world</b>\"\n    note[\"Back\"] = \"abc\"\n    col.addNote(note)\n    # as it's the sort field, it matches\n    assert len(col.find_cards(\"helloworld\")) == 2\n    # assert len(col.find_cards(\"helloworld\", full=True)) == 2\n    # if we put it on the back, it won't\n    (note[\"Front\"], note[\"Back\"]) = (note[\"Back\"], note[\"Front\"])\n    note.flush()\n    assert len(col.find_cards(\"helloworld\")) == 0\n    # assert len(col.find_cards(\"helloworld\", full=True)) == 2\n    # assert len(col.find_cards(\"back:helloworld\", full=True)) == 2\n    # searching for an invalid special tag should not error\n    with pytest.raises(Exception):\n        len(col.find_cards(\"is:invalid\"))\n    # should be able to limit to parent col, no children\n    id = col.db.scalar(\"select id from cards limit 1\")\n    col.db.execute(\n        \"update cards set did = ? where id = ?\", col.decks.id(\"Default::Child\"), id\n    )\n    assert len(col.find_cards(\"deck:default\")) == 7\n    assert len(col.find_cards(\"deck:default::child\")) == 1\n    assert len(col.find_cards(\"deck:default -deck:default::*\")) == 6\n    # properties\n    id = col.db.scalar(\"select id from cards limit 1\")\n    col.db.execute(\n        \"update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 where id = ?\",\n        id,\n    )\n    assert len(col.find_cards(\"prop:ivl>5\")) == 1\n    assert len(col.find_cards(\"prop:ivl<5\")) > 1\n    assert len(col.find_cards(\"prop:ivl>=5\")) == 1\n    assert len(col.find_cards(\"prop:ivl=9\")) == 0\n    assert len(col.find_cards(\"prop:ivl=10\")) == 1\n    assert len(col.find_cards(\"prop:ivl!=10\")) > 1\n    assert len(col.find_cards(\"prop:due>0\")) == 1\n    # due dates should work\n    assert len(col.find_cards(\"prop:due=29\")) == 0\n    assert len(col.find_cards(\"prop:due=30\")) == 1\n    # ease factors\n    assert len(col.find_cards(\"prop:ease=2.3\")) == 0\n    assert len(col.find_cards(\"prop:ease=2.2\")) == 1\n    assert len(col.find_cards(\"prop:ease>2\")) == 1\n    assert len(col.find_cards(\"-prop:ease>2\")) > 1\n    # recently failed\n    if not isNearCutoff():\n        # rated\n        assert len(col.find_cards(\"rated:1:1\")) == 0\n        assert len(col.find_cards(\"rated:1:2\")) == 0\n        c = col.sched.getCard()\n        col.sched.answerCard(c, 2)\n        assert len(col.find_cards(\"rated:1:1\")) == 0\n        assert len(col.find_cards(\"rated:1:2\")) == 1\n        c = col.sched.getCard()\n        col.sched.answerCard(c, 1)\n        assert len(col.find_cards(\"rated:1:1\")) == 1\n        assert len(col.find_cards(\"rated:1:2\")) == 1\n        assert len(col.find_cards(\"rated:1\")) == 2\n        assert len(col.find_cards(\"rated:2:2\")) == 1\n        assert len(col.find_cards(\"rated:0\")) == len(col.find_cards(\"rated:1\"))\n\n        # added\n        col.db.execute(\"update cards set id = id - 86400*1000 where id = ?\", id)\n        assert len(col.find_cards(\"added:1\")) == col.card_count() - 1\n        assert len(col.find_cards(\"added:2\")) == col.card_count()\n        assert len(col.find_cards(\"added:0\")) == len(col.find_cards(\"added:1\"))\n    else:\n        print(\"some find tests disabled near cutoff\")\n    # empty field\n    assert len(col.find_cards(\"front:\")) == 0\n    note = col.newNote()\n    note[\"Front\"] = \"\"\n    note[\"Back\"] = \"abc2\"\n    assert col.addNote(note) == 1\n    assert len(col.find_cards(\"front:\")) == 1\n    # OR searches and nesting\n    assert len(col.find_cards(\"tag:monkey or tag:sheep\")) == 2\n    assert len(col.find_cards(\"(tag:monkey OR tag:sheep)\")) == 2\n    assert len(col.find_cards(\"-(tag:monkey OR tag:sheep)\")) == 6\n    assert len(col.find_cards(\"tag:monkey or (tag:sheep sheep)\")) == 2\n    assert len(col.find_cards(\"tag:monkey or (tag:sheep octopus)\")) == 1\n    # flag\n    with pytest.raises(Exception):\n        col.find_cards(\"flag:12\")\n\n\ndef test_findReplace():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"foo\"\n    note[\"Back\"] = \"bar\"\n    col.addNote(note)\n    note2 = col.newNote()\n    note2[\"Front\"] = \"baz\"\n    note2[\"Back\"] = \"foo\"\n    col.addNote(note2)\n    nids = [note.id, note2.id]\n    # should do nothing\n    assert (\n        col.find_and_replace(note_ids=nids, search=\"abc\", replacement=\"123\").count == 0\n    )\n    # global replace\n    assert (\n        col.find_and_replace(note_ids=nids, search=\"foo\", replacement=\"qux\").count == 2\n    )\n    note.load()\n    assert note[\"Front\"] == \"qux\"\n    note2.load()\n    assert note2[\"Back\"] == \"qux\"\n    # single field replace\n    assert (\n        col.find_and_replace(\n            note_ids=nids, search=\"qux\", replacement=\"foo\", field_name=\"Front\"\n        ).count\n        == 1\n    )\n    note.load()\n    assert note[\"Front\"] == \"foo\"\n    note2.load()\n    assert note2[\"Back\"] == \"qux\"\n    # regex replace\n    assert (\n        col.find_and_replace(note_ids=nids, search=\"B.r\", replacement=\"reg\").count == 0\n    )\n    note.load()\n    assert note[\"Back\"] != \"reg\"\n    assert (\n        col.find_and_replace(\n            note_ids=nids, search=\"B.r\", replacement=\"reg\", regex=True\n        ).count\n        == 1\n    )\n    note.load()\n    assert note[\"Back\"] == \"reg\"\n\n\ndef test_findDupes():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"foo\"\n    note[\"Back\"] = \"bar\"\n    col.addNote(note)\n    note2 = col.newNote()\n    note2[\"Front\"] = \"baz\"\n    note2[\"Back\"] = \"bar\"\n    col.addNote(note2)\n    note3 = col.newNote()\n    note3[\"Front\"] = \"quux\"\n    note3[\"Back\"] = \"bar\"\n    col.addNote(note3)\n    note4 = col.newNote()\n    note4[\"Front\"] = \"quuux\"\n    note4[\"Back\"] = \"nope\"\n    col.addNote(note4)\n    r = col.find_dupes(\"Back\")\n    assert r[0][0] == \"bar\"\n    assert len(r[0][1]) == 3\n    # valid search\n    r = col.find_dupes(\"Back\", \"bar\")\n    assert r[0][0] == \"bar\"\n    assert len(r[0][1]) == 3\n    # excludes everything\n    r = col.find_dupes(\"Back\", \"invalid\")\n    assert not r\n    # front isn't dupe\n    assert col.find_dupes(\"Front\") == []\n"
  },
  {
    "path": "pylib/tests/test_flags.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom tests.shared import getEmptyCol\n\n\ndef test_flags():\n    col = getEmptyCol()\n    n = col.newNote()\n    n[\"Front\"] = \"one\"\n    n[\"Back\"] = \"two\"\n    cnt = col.addNote(n)\n    c = n.cards()[0]\n    # make sure higher bits are preserved\n    origBits = 0b101 << 3\n    c.flags = origBits\n    c.flush()\n    # no flags to start with\n    assert c.user_flag() == 0\n    assert len(col.find_cards(\"flag:0\")) == 1\n    assert len(col.find_cards(\"flag:1\")) == 0\n    # set flag 2\n    col.set_user_flag_for_cards(2, [c.id])\n    c.load()\n    assert c.user_flag() == 2\n    assert c.flags & origBits == origBits\n    assert len(col.find_cards(\"flag:0\")) == 0\n    assert len(col.find_cards(\"flag:2\")) == 1\n    assert len(col.find_cards(\"flag:3\")) == 0\n    # change to 3\n    col.set_user_flag_for_cards(3, [c.id])\n    c.load()\n    assert c.user_flag() == 3\n    # unset\n    col.set_user_flag_for_cards(0, [c.id])\n    c.load()\n    assert c.user_flag() == 0\n"
  },
  {
    "path": "pylib/tests/test_importing.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nimport os\nfrom tempfile import NamedTemporaryFile\n\nimport pytest\n\nfrom anki.consts import *\nfrom anki.importing import (\n    Anki2Importer,\n    AnkiPackageImporter,\n    MnemosyneImporter,\n    TextImporter,\n)\nfrom tests.shared import getEmptyCol, getUpgradeDeckPath\n\ntestDir = os.path.dirname(__file__)\n\n\ndef clear_tempfile(tf):\n    \"\"\"https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file\"\"\"\n    try:\n        tf.close()\n        os.unlink(tf.name)\n    except Exception:\n        pass\n\n\ndef test_anki2_mediadupes():\n    col = getEmptyCol()\n    # add a note that references a sound\n    n = col.newNote()\n    n[\"Front\"] = \"[sound:foo.mp3]\"\n    mid = n.note_type()[\"id\"]\n    col.addNote(n)\n    # add that sound to media folder\n    with open(os.path.join(col.media.dir(), \"foo.mp3\"), \"w\") as note:\n        note.write(\"foo\")\n    col.close()\n    # it should be imported correctly into an empty deck\n    empty = getEmptyCol()\n    imp = Anki2Importer(empty, col.path)\n    imp.run()\n    assert os.listdir(empty.media.dir()) == [\"foo.mp3\"]\n    # and importing again will not duplicate, as the file content matches\n    empty.remove_cards_and_orphaned_notes(empty.db.list(\"select id from cards\"))\n    imp = Anki2Importer(empty, col.path)\n    imp.run()\n    assert os.listdir(empty.media.dir()) == [\"foo.mp3\"]\n    n = empty.get_note(empty.db.scalar(\"select id from notes\"))\n    assert \"foo.mp3\" in n.fields[0]\n    # if the local file content is different, and import should trigger a\n    # rename\n    empty.remove_cards_and_orphaned_notes(empty.db.list(\"select id from cards\"))\n    with open(os.path.join(empty.media.dir(), \"foo.mp3\"), \"w\") as note:\n        note.write(\"bar\")\n    imp = Anki2Importer(empty, col.path)\n    imp.run()\n    assert sorted(os.listdir(empty.media.dir())) == [\"foo.mp3\", f\"foo_{mid}.mp3\"]\n    n = empty.get_note(empty.db.scalar(\"select id from notes\"))\n    assert \"_\" in n.fields[0]\n    # if the localized media file already exists, we rewrite the note and\n    # media\n    empty.remove_cards_and_orphaned_notes(empty.db.list(\"select id from cards\"))\n    with open(os.path.join(empty.media.dir(), \"foo.mp3\"), \"w\") as note:\n        note.write(\"bar\")\n    imp = Anki2Importer(empty, col.path)\n    imp.run()\n    assert sorted(os.listdir(empty.media.dir())) == [\"foo.mp3\", f\"foo_{mid}.mp3\"]\n    assert sorted(os.listdir(empty.media.dir())) == [\"foo.mp3\", f\"foo_{mid}.mp3\"]\n    n = empty.get_note(empty.db.scalar(\"select id from notes\"))\n    assert \"_\" in n.fields[0]\n\n\ndef test_apkg():\n    col = getEmptyCol()\n    apkg = str(os.path.join(testDir, \"support\", \"media.apkg\"))\n    imp = AnkiPackageImporter(col, apkg)\n    assert os.listdir(col.media.dir()) == []\n    imp.run()\n    assert os.listdir(col.media.dir()) == [\"foo.wav\"]\n    # importing again should be idempotent in terms of media\n    col.remove_cards_and_orphaned_notes(col.db.list(\"select id from cards\"))\n    imp = AnkiPackageImporter(col, apkg)\n    imp.run()\n    assert os.listdir(col.media.dir()) == [\"foo.wav\"]\n    # but if the local file has different data, it will rename\n    col.remove_cards_and_orphaned_notes(col.db.list(\"select id from cards\"))\n    with open(os.path.join(col.media.dir(), \"foo.wav\"), \"w\") as note:\n        note.write(\"xyz\")\n    imp = AnkiPackageImporter(col, apkg)\n    imp.run()\n    assert len(os.listdir(col.media.dir())) == 2\n\n\ndef test_anki2_diffmodel_templates():\n    # different from the above as this one tests only the template text being\n    # changed, not the number of cards/fields\n    dst = getEmptyCol()\n    # import the first version of the model\n    col = getUpgradeDeckPath(\"diffmodeltemplates-1.apkg\")\n    imp = AnkiPackageImporter(dst, col)\n    imp.dupeOnSchemaChange = True  # type: ignore\n    imp.run()\n    # then the version with updated template\n    col = getUpgradeDeckPath(\"diffmodeltemplates-2.apkg\")\n    imp = AnkiPackageImporter(dst, col)\n    imp.dupeOnSchemaChange = True  # type: ignore\n    imp.run()\n    # collection should contain the note we imported\n    assert dst.note_count() == 1\n    # the front template should contain the text added in the 2nd package\n    tcid = dst.find_cards(\"\")[0]  # only 1 note in collection\n    tnote = dst.getCard(tcid).note()\n    assert \"Changed Front Template\" in tnote.cards()[0].template()[\"qfmt\"]\n\n\ndef test_anki2_updates():\n    # create a new empty deck\n    dst = getEmptyCol()\n    col = getUpgradeDeckPath(\"update1.apkg\")\n    imp = AnkiPackageImporter(dst, col)\n    imp.run()\n    assert imp.dupes == 0\n    assert imp.added == 1\n    assert imp.updated == 0\n    # importing again should be idempotent\n    imp = AnkiPackageImporter(dst, col)\n    imp.run()\n    assert imp.dupes == 1\n    assert imp.added == 0\n    assert imp.updated == 0\n    # importing a newer note should update\n    assert dst.note_count() == 1\n    assert dst.db.scalar(\"select flds from notes\").startswith(\"hello\")\n    col = getUpgradeDeckPath(\"update2.apkg\")\n    imp = AnkiPackageImporter(dst, col)\n    imp.run()\n    assert imp.dupes == 0\n    assert imp.added == 0\n    assert imp.updated == 1\n    assert dst.note_count() == 1\n    assert dst.db.scalar(\"select flds from notes\").startswith(\"goodbye\")\n\n\ndef test_csv():\n    col = getEmptyCol()\n    file = str(os.path.join(testDir, \"support\", \"text-2fields.txt\"))\n    i = TextImporter(col, file)\n    i.initMapping()\n    i.run()\n    # four problems - too many & too few fields, a missing front, and a\n    # duplicate entry\n    assert len(i.log) == 5\n    assert i.total == 5\n    # if we run the import again, it should update instead\n    i.run()\n    assert len(i.log) == 10\n    assert i.total == 5\n    # but importing should not clobber tags if they're unmapped\n    n = col.get_note(col.db.scalar(\"select id from notes\"))\n    n.add_tag(\"test\")\n    n.flush()\n    i.run()\n    n.load()\n    assert n.tags == [\"test\"]\n    # if add-only mode, count will be 0\n    i.importMode = 1\n    i.run()\n    assert i.total == 0\n    # and if dupes mode, will reimport everything\n    assert col.card_count() == 5\n    i.importMode = 2\n    i.run()\n    # includes repeated field\n    assert i.total == 6\n    assert col.card_count() == 11\n    col.close()\n\n\ndef test_csv2():\n    col = getEmptyCol()\n    mm = col.models\n    m = mm.current()\n    note = mm.new_field(\"Three\")\n    mm.addField(m, note)\n    mm.save(m)\n    n = col.newNote()\n    n[\"Front\"] = \"1\"\n    n[\"Back\"] = \"2\"\n    n[\"Three\"] = \"3\"\n    col.addNote(n)\n    # an update with unmapped fields should not clobber those fields\n    file = str(os.path.join(testDir, \"support\", \"text-update.txt\"))\n    i = TextImporter(col, file)\n    i.initMapping()\n    i.run()\n    n.load()\n    assert n[\"Front\"] == \"1\"\n    assert n[\"Back\"] == \"x\"\n    assert n[\"Three\"] == \"3\"\n    col.close()\n\n\ndef test_tsv_tag_modified():\n    col = getEmptyCol()\n    mm = col.models\n    m = mm.current()\n    note = mm.new_field(\"Top\")\n    mm.addField(m, note)\n    mm.save(m)\n    n = col.newNote()\n    n[\"Front\"] = \"1\"\n    n[\"Back\"] = \"2\"\n    n[\"Top\"] = \"3\"\n    n.add_tag(\"four\")\n    col.addNote(n)\n\n    # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file\n    with NamedTemporaryFile(mode=\"w\", delete=False) as tf:\n        tf.write(\"1\\tb\\tc\\n\")\n        tf.flush()\n        i = TextImporter(col, tf.name)\n        i.initMapping()\n        i.tagModified = \"boom\"\n        i.run()\n        clear_tempfile(tf)\n\n    n.load()\n    assert n[\"Front\"] == \"1\"\n    assert n[\"Back\"] == \"b\"\n    assert n[\"Top\"] == \"c\"\n    assert \"four\" in n.tags\n    assert \"boom\" in n.tags\n    assert len(n.tags) == 2\n    assert i.updateCount == 1\n\n    col.close()\n\n\ndef test_tsv_tag_multiple_tags():\n    col = getEmptyCol()\n    mm = col.models\n    m = mm.current()\n    note = mm.new_field(\"Top\")\n    mm.addField(m, note)\n    mm.save(m)\n    n = col.newNote()\n    n[\"Front\"] = \"1\"\n    n[\"Back\"] = \"2\"\n    n[\"Top\"] = \"3\"\n    n.add_tag(\"four\")\n    n.add_tag(\"five\")\n    col.addNote(n)\n\n    # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file\n    with NamedTemporaryFile(mode=\"w\", delete=False) as tf:\n        tf.write(\"1\\tb\\tc\\n\")\n        tf.flush()\n        i = TextImporter(col, tf.name)\n        i.initMapping()\n        i.tagModified = \"five six\"\n        i.run()\n        clear_tempfile(tf)\n\n    n.load()\n    assert n[\"Front\"] == \"1\"\n    assert n[\"Back\"] == \"b\"\n    assert n[\"Top\"] == \"c\"\n    assert list(sorted(n.tags)) == list(sorted([\"four\", \"five\", \"six\"]))\n\n    col.close()\n\n\ndef test_csv_tag_only_if_modified():\n    col = getEmptyCol()\n    mm = col.models\n    m = mm.current()\n    note = mm.new_field(\"Left\")\n    mm.addField(m, note)\n    mm.save(m)\n    n = col.newNote()\n    n[\"Front\"] = \"1\"\n    n[\"Back\"] = \"2\"\n    n[\"Left\"] = \"3\"\n    col.addNote(n)\n\n    # https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file\n    with NamedTemporaryFile(mode=\"w\", delete=False) as tf:\n        tf.write(\"1,2,3\\n\")\n        tf.flush()\n        i = TextImporter(col, tf.name)\n        i.initMapping()\n        i.tagModified = \"right\"\n        i.run()\n        clear_tempfile(tf)\n\n    n.load()\n    assert n.tags == []\n    assert i.updateCount == 0\n\n    col.close()\n\n\ndef test_mnemo():\n    col = getEmptyCol()\n    file = str(os.path.join(testDir, \"support\", \"mnemo.db\"))\n    i = MnemosyneImporter(col, file)\n    i.run()\n    assert col.card_count() == 7\n    assert \"a_longer_tag\" in col.tags.all()\n    assert col.db.scalar(f\"select count() from cards where type = {CARD_TYPE_NEW}\") == 1\n    col.close()\n"
  },
  {
    "path": "pylib/tests/test_latex.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nimport os\nimport shutil\n\nfrom anki.config import Config\nfrom anki.lang import without_unicode_isolation\nfrom tests.shared import getEmptyCol\n\n\ndef test_latex():\n    col = getEmptyCol()\n    col.set_config_bool(Config.Bool.RENDER_LATEX, True)\n    # change latex cmd to simulate broken build\n    import anki.latex\n\n    anki.latex.pngCommands[0][0] = \"nolatex\"\n    # add a note with latex\n    note = col.newNote()\n    note[\"Front\"] = \"[latex]hello[/latex]\"\n    col.addNote(note)\n    # but since latex couldn't run, there's nothing there\n    assert len(os.listdir(col.media.dir())) == 0\n    # check the error message\n    msg = note.cards()[0].question()\n    assert \"executing nolatex\" in without_unicode_isolation(msg)\n    assert \"installed\" in msg\n    # check if we have latex installed, and abort test if we don't\n    if not shutil.which(\"latex\") or not shutil.which(\"dvipng\"):\n        print(\"aborting test; latex or dvipng is not installed\")\n        return\n    # fix path\n    anki.latex.pngCommands[0][0] = \"latex\"\n    # check media db should cause latex to be generated\n    col.media.render_all_latex()\n    assert len(os.listdir(col.media.dir())) == 1\n    assert \".png\" in note.cards()[0].question()\n    # adding new notes should cause generation on question display\n    note = col.newNote()\n    note[\"Front\"] = \"[latex]world[/latex]\"\n    col.addNote(note)\n    note.cards()[0].question()\n    assert len(os.listdir(col.media.dir())) == 2\n    # another note with the same media should reuse\n    note = col.newNote()\n    note[\"Front\"] = \" [latex]world[/latex]\"\n    col.addNote(note)\n    assert len(os.listdir(col.media.dir())) == 2\n    oldcard = note.cards()[0]\n    assert \".png\" in oldcard.question()\n    # if we turn off building, then previous cards should work, but cards with\n    # missing media will show a broken image\n    col.set_config_bool(Config.Bool.RENDER_LATEX, False)\n    note = col.newNote()\n    note[\"Front\"] = \"[latex]foo[/latex]\"\n    col.addNote(note)\n    assert len(os.listdir(col.media.dir())) == 2\n    assert \".png\" in oldcard.question()\n"
  },
  {
    "path": "pylib/tests/test_media.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\n\nimport os\nimport tempfile\n\nfrom tests.shared import getEmptyCol, testDir\n\n\n# copying files to media folder\ndef test_add():\n    col = getEmptyCol()\n    dir = tempfile.mkdtemp(prefix=\"anki\")\n    path = os.path.join(dir, \"foo.jpg\")\n    with open(path, \"w\") as note:\n        note.write(\"hello\")\n    # new file, should preserve name\n    assert col.media.add_file(path) == \"foo.jpg\"\n    # adding the same file again should not create a duplicate\n    assert col.media.add_file(path) == \"foo.jpg\"\n    # but if it has a different sha1, it should\n    with open(path, \"w\") as note:\n        note.write(\"world\")\n    assert (\n        col.media.add_file(path) == \"foo-7c211433f02071597741e6ff5a8ea34789abbf43.jpg\"\n    )\n\n\ndef test_strings():\n    col = getEmptyCol()\n    mf = col.media.files_in_str\n    mid = col.models.current()[\"id\"]\n    assert mf(mid, \"aoeu\") == []\n    assert mf(mid, \"aoeu<img src='foo.jpg'>ao\") == [\"foo.jpg\"]\n    assert mf(mid, \"aoeu<img src='foo.jpg' style='test'>ao\") == [\"foo.jpg\"]\n    assert mf(mid, \"aoeu<img src='foo.jpg'><img src=\\\"bar.jpg\\\">ao\") == [\n        \"foo.jpg\",\n        \"bar.jpg\",\n    ]\n    assert mf(mid, \"aoeu<img src=foo.jpg style=bar>ao\") == [\"foo.jpg\"]\n    assert mf(mid, \"<img src=one><img src=two>\") == [\"one\", \"two\"]\n    assert mf(mid, 'aoeu<img src=\"foo.jpg\">ao') == [\"foo.jpg\"]\n    assert mf(mid, 'aoeu<img src=\"foo.jpg\"><img class=yo src=fo>ao') == [\n        \"foo.jpg\",\n        \"fo\",\n    ]\n    assert mf(mid, \"aou[sound:foo.mp3]aou\") == [\"foo.mp3\"]\n    sp = col.media.strip\n    assert sp(\"aoeu\") == \"aoeu\"\n    assert sp(\"aoeu[sound:foo.mp3]aoeu\") == \"aoeuaoeu\"\n    assert sp(\"a<img src=yo>oeu\") == \"aoeu\"\n    es = col.media.escape_media_filenames\n    assert es(\"aoeu\") == \"aoeu\"\n    assert es(\"<img src='http://foo.com'>\") == \"<img src='http://foo.com'>\"\n    assert es('<img src=\"foo bar.jpg\">') == '<img src=\"foo%20bar.jpg\">'\n\n\ndef test_deckIntegration():\n    col = getEmptyCol()\n    # create a media dir\n    col.media.dir()\n    # put a file into it\n    file = str(os.path.join(testDir, \"support\", \"fake.png\"))\n    col.media.add_file(file)\n    # add a note which references it\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"<img src='fake.png'>\"\n    col.addNote(note)\n    # and one which references a non-existent file\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"<img src='fake2.png'>\"\n    col.addNote(note)\n    # and add another file which isn't used\n    with open(os.path.join(col.media.dir(), \"foo.jpg\"), \"w\") as note:\n        note.write(\"test\")\n    # check media\n    ret = col.media.check()\n    assert ret.missing == [\"fake2.png\"]\n    assert ret.unused == [\"foo.jpg\"]\n"
  },
  {
    "path": "pylib/tests/test_models.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# coding: utf-8\nimport html\nimport re\nimport time\n\nfrom anki.consts import MODEL_CLOZE\nfrom anki.errors import NotFoundError\nfrom anki.utils import is_win, strip_html\nfrom tests.shared import getEmptyCol\n\n\ndef encode_attribute(s):\n    return \"\".join(\n        c if c.isalnum() else \"&#x{:X};\".format(ord(c)) for c in html.escape(s)\n    )\n\n\ndef test_modelDelete():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"2\"\n    col.addNote(note)\n    assert col.card_count() == 1\n    col.models.remove(col.models.current()[\"id\"])\n    assert col.card_count() == 0\n\n\ndef test_modelCopy():\n    col = getEmptyCol()\n    m = col.models.current()\n    m2 = col.models.copy(m)\n    assert m2[\"name\"] == \"Basic copy\"\n    assert m2[\"id\"] != m[\"id\"]\n    assert len(m2[\"flds\"]) == 2\n    assert len(m[\"flds\"]) == 2\n    assert len(m2[\"flds\"]) == len(m[\"flds\"])\n    assert len(m[\"tmpls\"]) == 1\n    assert len(m2[\"tmpls\"]) == 1\n    assert col.models.scmhash(m) == col.models.scmhash(m2)\n\n\ndef test_fields():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"2\"\n    col.addNote(note)\n    m = col.models.current()\n    # make sure renaming a field updates the templates\n    col.models.renameField(m, m[\"flds\"][0], \"NewFront\")\n    assert \"{{NewFront}}\" in m[\"tmpls\"][0][\"qfmt\"]\n    h = col.models.scmhash(m)\n    # add a field\n    field = col.models.new_field(\"foo\")\n    col.models.addField(m, field)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"1\", \"2\", \"\"]\n    assert col.models.scmhash(m) != h\n    # rename it\n    field = m[\"flds\"][2]\n    col.models.renameField(m, field, \"bar\")\n    assert col.get_note(col.models.nids(m)[0])[\"bar\"] == \"\"\n    # delete back\n    col.models.remField(m, m[\"flds\"][1])\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"1\", \"\"]\n    # move 0 -> 1\n    col.models.moveField(m, m[\"flds\"][0], 1)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"\", \"1\"]\n    # move 1 -> 0\n    col.models.moveField(m, m[\"flds\"][1], 0)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"1\", \"\"]\n    # add another and put in middle\n    field = col.models.new_field(\"baz\")\n    col.models.addField(m, field)\n    note = col.get_note(col.models.nids(m)[0])\n    note[\"baz\"] = \"2\"\n    note.flush()\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"1\", \"\", \"2\"]\n    # move 2 -> 1\n    col.models.moveField(m, m[\"flds\"][2], 1)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"1\", \"2\", \"\"]\n    # move 0 -> 2\n    col.models.moveField(m, m[\"flds\"][0], 2)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"2\", \"\", \"1\"]\n    # move 0 -> 1\n    col.models.moveField(m, m[\"flds\"][0], 1)\n    assert col.get_note(col.models.nids(m)[0]).fields == [\"\", \"2\", \"1\"]\n\n\ndef test_templates():\n    col = getEmptyCol()\n    m = col.models.current()\n    mm = col.models\n    t = mm.new_template(\"Reverse\")\n    t[\"qfmt\"] = \"{{Back}}\"\n    t[\"afmt\"] = \"{{Front}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"2\"\n    col.addNote(note)\n    assert col.card_count() == 2\n    (c, c2) = note.cards()\n    # first card should have first ord\n    assert c.ord == 0\n    assert c2.ord == 1\n    # switch templates\n    col.models.reposition_template(m, c.template(), 1)\n    col.models.update(m)\n    c.load()\n    c2.load()\n    assert c.ord == 1\n    assert c2.ord == 0\n    # removing a template should delete its cards\n    col.models.remove_template(m, m[\"tmpls\"][0])\n    col.models.update(m)\n    assert col.card_count() == 1\n    # and should have updated the other cards' ordinals\n    c = note.cards()[0]\n    assert c.ord == 0\n    assert strip_html(c.question()) == \"1\"\n    # it shouldn't be possible to orphan notes by removing templates\n    t = mm.new_template(\"template name\")\n    t[\"qfmt\"] = \"{{Front}}2\"\n    mm.add_template(m, t)\n    col.models.remove_template(m, m[\"tmpls\"][0])\n    col.models.update(m)\n    assert (\n        col.db.scalar(\n            \"select count() from cards where nid not in (select id from notes)\"\n        )\n        == 0\n    )\n\n\ndef test_cloze_ordinals():\n    col = getEmptyCol()\n    m = col.models.by_name(\"Cloze\")\n    mm = col.models\n\n    # We replace the default Cloze template\n    t = mm.new_template(\"ChainedCloze\")\n    t[\"qfmt\"] = \"{{text:cloze:Text}}\"\n    t[\"afmt\"] = \"{{text:cloze:Text}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    col.models.remove_template(m, m[\"tmpls\"][0])\n    col.models.update(m)\n\n    note = col.newNote()\n    note[\"Text\"] = \"{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}\"\n    col.addNote(note)\n    assert col.card_count() == 2\n    (c, c2) = note.cards()\n    # first card should have first ord\n    assert c.ord == 0\n    assert c2.ord == 1\n\n\ndef test_text():\n    col = getEmptyCol()\n    m = col.models.current()\n    m[\"tmpls\"][0][\"qfmt\"] = \"{{text:Front}}\"\n    col.models.save(m)\n    note = col.newNote()\n    note[\"Front\"] = \"hello<b>world\"\n    col.addNote(note)\n    assert \"helloworld\" in note.cards()[0].question()\n\n\ndef test_cloze():\n    col = getEmptyCol()\n    m = col.models.by_name(\"Cloze\")\n    note = col.new_note(m)\n    assert note.note_type()[\"name\"] == \"Cloze\"\n    # a cloze model with no clozes is not empty\n    note[\"Text\"] = \"nothing\"\n    assert col.addNote(note)\n    # try with one cloze\n    note = col.new_note(m)\n    note[\"Text\"] = \"hello {{c1::world}}\"\n    assert col.addNote(note) == 1\n    assert (\n        f'hello <span class=\"cloze\" data-cloze=\"{encode_attribute(\"world\")}\" data-ordinal=\"1\">[...]</span>'\n        in note.cards()[0].question()\n    )\n    assert (\n        'hello <span class=\"cloze\" data-ordinal=\"1\">world</span>'\n        in note.cards()[0].answer()\n    )\n    # and with a comment\n    note = col.new_note(m)\n    note[\"Text\"] = \"hello {{c1::world::typical}}\"\n    assert col.addNote(note) == 1\n    assert (\n        f'<span class=\"cloze\" data-cloze=\"{encode_attribute(\"world\")}\" data-ordinal=\"1\">[typical]</span>'\n        in note.cards()[0].question()\n    )\n    assert (\n        '<span class=\"cloze\" data-ordinal=\"1\">world</span>' in note.cards()[0].answer()\n    )\n    # and with 2 clozes\n    note = col.new_note(m)\n    note[\"Text\"] = \"hello {{c1::world}} {{c2::bar}}\"\n    assert col.addNote(note) == 2\n    (c1, c2) = note.cards()\n    assert (\n        f'<span class=\"cloze\" data-cloze=\"{encode_attribute(\"world\")}\" data-ordinal=\"1\">[...]</span> <span class=\"cloze-inactive\" data-ordinal=\"2\">bar</span>'\n        in c1.question()\n    )\n    assert (\n        '<span class=\"cloze\" data-ordinal=\"1\">world</span> <span class=\"cloze-inactive\" data-ordinal=\"2\">bar</span>'\n        in c1.answer()\n    )\n    assert (\n        f'<span class=\"cloze-inactive\" data-ordinal=\"1\">world</span> <span class=\"cloze\" data-cloze=\"{encode_attribute(\"bar\")}\" data-ordinal=\"2\">[...]</span>'\n        in c2.question()\n    )\n    assert (\n        '<span class=\"cloze-inactive\" data-ordinal=\"1\">world</span> <span class=\"cloze\" data-ordinal=\"2\">bar</span>'\n        in c2.answer()\n    )\n    # if there are multiple answers for a single cloze, they are given in a\n    # list\n    note = col.new_note(m)\n    note[\"Text\"] = \"a {{c1::b}} {{c1::c}}\"\n    assert col.addNote(note) == 1\n    assert (\n        '<span class=\"cloze\" data-ordinal=\"1\">b</span> <span class=\"cloze\" data-ordinal=\"1\">c</span>'\n        in (note.cards()[0].answer())\n    )\n    # if we add another cloze, a card should be generated\n    cnt = col.card_count()\n    note[\"Text\"] = \"{{c2::hello}} {{c1::foo}}\"\n    note.flush()\n    assert col.card_count() == cnt + 1\n    # 0 or negative indices are not supported\n    note[\"Text\"] += \"{{c0::zero}} {{c-1:foo}}\"\n    note.flush()\n    assert len(note.cards()) == 2\n\n\ndef test_cloze_mathjax():\n    col = getEmptyCol()\n    m = col.models.by_name(\"Cloze\")\n    note = col.new_note(m)\n    q1 = \"ok\"\n    q2 = \"not ok\"\n    q3 = \"2\"\n    q4 = \"blah\"\n    q5 = \"text with \\(x^2\\) jax\"\n    note[\"Text\"] = (\n        \"{{{{c1::{}}}}} \\(2^2\\) {{{{c2::{}}}}} \\(2^{{{{c3::{}}}}}\\) \\(x^3\\) {{{{c4::{}}}}} {{{{c5::{}}}}}\".format(\n            q1,\n            q2,\n            q3,\n            q4,\n            q5,\n        )\n    )\n    assert col.addNote(note)\n    assert len(note.cards()) == 5\n    assert (\n        f'class=\"cloze\" data-cloze=\"{encode_attribute(q1)}\"'\n        in note.cards()[0].question()\n    )\n    assert (\n        f'class=\"cloze\" data-cloze=\"{encode_attribute(q2)}\"'\n        in note.cards()[1].question()\n    )\n    assert (\n        f'class=\"cloze\" data-cloze=\"{encode_attribute(q3)}\"'\n        not in note.cards()[2].question()\n    )\n    assert (\n        f'class=\"cloze\" data-cloze=\"{encode_attribute(q4)}\"'\n        in note.cards()[3].question()\n    )\n    assert (\n        f'class=\"cloze\" data-cloze=\"{encode_attribute(q5)}\"'\n        in note.cards()[4].question()\n    )\n\n    note = col.new_note(m)\n    note[\"Text\"] = r\"\\(a\\) {{c1::b}} \\[ {{c1::c}} \\]\"\n    assert col.addNote(note)\n    assert len(note.cards()) == 1\n    assert (\n        note.cards()[0]\n        .question()\n        .endswith(\n            r'\\(a\\) <span class=\"cloze\" data-cloze=\"b\" data-ordinal=\"1\">[...]</span> \\[ [...] \\]'\n        )\n    )\n\n\ndef test_typecloze():\n    col = getEmptyCol()\n    m = col.models.by_name(\"Cloze\")\n    m[\"tmpls\"][0][\"qfmt\"] = \"{{cloze:Text}}{{type:cloze:Text}}\"\n    col.models.save(m)\n    note = col.new_note(m)\n    note[\"Text\"] = \"hello {{c1::world}}\"\n    col.addNote(note)\n    assert \"[[type:cloze:Text]]\" in note.cards()[0].question()\n\n\ndef test_chained_mods():\n    col = getEmptyCol()\n    m = col.models.by_name(\"Cloze\")\n    mm = col.models\n\n    # We replace the default Cloze template\n    t = mm.new_template(\"ChainedCloze\")\n    t[\"qfmt\"] = \"{{cloze:text:Text}}\"\n    t[\"afmt\"] = \"{{cloze:text:Text}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    col.models.remove_template(m, m[\"tmpls\"][0])\n    col.models.update(m)\n\n    note = col.newNote()\n    a1 = '<span style=\"color:red\">phrase</span>'\n    h1 = \"<b>sentence</b>\"\n    a2 = '<span style=\"color:red\">en chaine</span>'\n    h2 = \"<i>chained</i>\"\n    note[\"Text\"] = (\n        \"This {{{{c1::{}::{}}}}} demonstrates {{{{c1::{}::{}}}}} clozes.\".format(\n            a1,\n            h1,\n            a2,\n            h2,\n        )\n    )\n    assert col.addNote(note) == 1\n    assert (\n        'This <span class=\"cloze\" data-cloze=\"phrase\" data-ordinal=\"1\">[sentence]</span>'\n        f' demonstrates <span class=\"cloze\" data-cloze=\"{encode_attribute(\"en chaine\")}\" data-ordinal=\"1\">[chained]</span> clozes.'\n        in note.cards()[0].question()\n    )\n    assert (\n        'This <span class=\"cloze\" data-ordinal=\"1\">phrase</span> demonstrates <span class=\"cloze\" data-ordinal=\"1\">en chaine</span> clozes.'\n        in note.cards()[0].answer()\n    )\n\n\ndef test_modelChange():\n    col = getEmptyCol()\n    cloze = col.models.by_name(\"Cloze\")\n    # enable second template and add a note\n    m = col.models.current()\n    mm = col.models\n    t = mm.new_template(\"Reverse\")\n    t[\"qfmt\"] = \"{{Back}}\"\n    t[\"afmt\"] = \"{{Front}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    basic = m\n    note = col.newNote()\n    note[\"Front\"] = \"note\"\n    note[\"Back\"] = \"b123\"\n    col.addNote(note)\n    # switch fields\n    map = {0: 1, 1: 0}\n    noop = {0: 0, 1: 1}\n    col.models.change(basic, [note.id], basic, map, None)\n    note.load()\n    assert note[\"Front\"] == \"b123\"\n    assert note[\"Back\"] == \"note\"\n    # switch cards\n    c0 = note.cards()[0]\n    c1 = note.cards()[1]\n    assert \"b123\" in c0.question()\n    assert \"note\" in c1.question()\n    assert c0.ord == 0\n    assert c1.ord == 1\n    col.models.change(basic, [note.id], basic, noop, map)\n    note.load()\n    c0.load()\n    c1.load()\n    assert \"note\" in c0.question()\n    assert \"b123\" in c1.question()\n    assert c0.ord == 1\n    assert c1.ord == 0\n    # .cards() returns cards in order\n    assert note.cards()[0].id == c1.id\n    # delete first card\n    map = {0: None, 1: 1}\n    time.sleep(0.25)\n    col.models.change(basic, [note.id], basic, noop, map)\n    note.load()\n    c0.load()\n    # the card was deleted\n    try:\n        c1.load()\n        assert 0\n    except NotFoundError:\n        pass\n    # but we have two cards, as a new one was generated\n    assert len(note.cards()) == 2\n    # an unmapped field becomes blank\n    assert note[\"Front\"] == \"b123\"\n    assert note[\"Back\"] == \"note\"\n    col.models.change(basic, [note.id], basic, map, None)\n    note.load()\n    assert note[\"Front\"] == \"\"\n    assert note[\"Back\"] == \"note\"\n    # another note to try model conversion\n    note = col.newNote()\n    note[\"Front\"] = \"f2\"\n    note[\"Back\"] = \"b2\"\n    col.addNote(note)\n    counts = col.models.all_use_counts()\n    assert next(c.use_count for c in counts if c.name == \"Basic\") == 2\n    assert next(c.use_count for c in counts if c.name == \"Cloze\") == 0\n    map = {0: 0, 1: 1}\n    col.models.change(basic, [note.id], cloze, map, map)\n    note.load()\n    assert note[\"Text\"] == \"f2\"\n    assert len(note.cards()) == 2\n    # back the other way, with deletion of second ord\n    col.models.remove_template(basic, basic[\"tmpls\"][1])\n    col.models.update(basic)\n    assert col.db.scalar(\"select count() from cards where nid = ?\", note.id) == 2\n    map = {0: 0}\n    col.models.change(cloze, [note.id], basic, map, map)\n    assert col.db.scalar(\"select count() from cards where nid = ?\", note.id) == 1\n\n\ndef test_req():\n    def reqSize(model):\n        if model[\"type\"] == MODEL_CLOZE:\n            return\n        assert len(model[\"tmpls\"]) == len(model[\"req\"])\n\n    col = getEmptyCol()\n    mm = col.models\n    basic = mm.by_name(\"Basic\")\n    assert \"req\" in basic\n    reqSize(basic)\n    r = basic[\"req\"][0]\n    assert r[0] == 0\n    assert r[1] in (\"any\", \"all\")\n    assert r[2] == [0]\n    opt = mm.by_name(\"Basic (optional reversed card)\")\n    reqSize(opt)\n    r = opt[\"req\"][0]\n    assert r[1] in (\"any\", \"all\")\n    assert r[2] == [0]\n    assert opt[\"req\"][1] == [1, \"all\", [1, 2]]\n    # testing any\n    opt[\"tmpls\"][1][\"qfmt\"] = \"{{Back}}{{Add Reverse}}\"\n    mm.save(opt, templates=True)\n    assert opt[\"req\"][1] == [1, \"any\", [1, 2]]\n    # testing None\n    opt[\"tmpls\"][1][\"qfmt\"] = \"{{^Add Reverse}}{{Tags}}{{/Add Reverse}}\"\n    mm.save(opt, templates=True)\n    assert opt[\"req\"][1] == [1, \"none\", []]\n\n    opt = mm.by_name(\"Basic (type in the answer)\")\n    reqSize(opt)\n    r = opt[\"req\"][0]\n    assert r[1] in (\"any\", \"all\")\n    assert r[2] == [0, 1]\n"
  },
  {
    "path": "pylib/tests/test_schedv3.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport copy\nimport os\nimport time\nfrom collections.abc import Callable\nfrom typing import Dict\n\nimport pytest\n\nfrom anki import hooks\nfrom anki.consts import *\nfrom anki.lang import without_unicode_isolation\nfrom anki.scheduler import UnburyDeck\nfrom anki.utils import int_time\nfrom tests.shared import getEmptyCol as getEmptyColOrig\n\n\ndef getEmptyCol():\n    col = getEmptyColOrig()\n    return col\n\n\ndef test_clock():\n    col = getEmptyCol()\n    if (col.sched.day_cutoff - int_time()) < 10 * 60:\n        raise Exception(\"Unit tests will fail around the day rollover.\")\n\n\ndef test_basics():\n    col = getEmptyCol()\n    assert not col.sched.getCard()\n\n\ndef test_new():\n    col = getEmptyCol()\n    assert col.sched.newCount == 0\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    assert col.sched.newCount == 1\n    # fetch it\n    c = col.sched.getCard()\n    assert c\n    assert c.queue == QUEUE_TYPE_NEW\n    assert c.type == CARD_TYPE_NEW\n    # if we answer it, it should become a learn card\n    t = int_time()\n    col.sched.answerCard(c, 1)\n    assert c.queue == QUEUE_TYPE_LRN\n    assert c.type == CARD_TYPE_LRN\n    assert c.due >= t\n\n    # disabled for now, as the learn fudging makes this randomly fail\n    # # the default order should ensure siblings are not seen together, and\n    # # should show all cards\n    # m = col.models.current(); mm = col.models\n    # t = mm.new_template(\"Reverse\")\n    # t['qfmt'] = \"{{Back}}\"\n    # t['afmt'] = \"{{Front}}\"\n    # mm.add_template(m, t)\n    # mm.save(m)\n    # note = col.newNote()\n    # note['Front'] = u\"2\"; note['Back'] = u\"2\"\n    # col.addNote(note)\n    # note = col.newNote()\n    # note['Front'] = u\"3\"; note['Back'] = u\"3\"\n    # col.addNote(note)\n    # col.reset()\n    # qs = (\"2\", \"3\", \"2\", \"3\")\n    # for n in range(4):\n    #     c = col.sched.getCard()\n    #     assert qs[n] in c.question()\n    #     col.sched.answerCard(c, 2)\n\n\ndef test_newLimits():\n    col = getEmptyCol()\n    # add some notes\n    deck2 = col.decks.id(\"Default::foo\")\n    for i in range(30):\n        note = col.newNote()\n        note[\"Front\"] = str(i)\n        if i > 4:\n            note_type = note.note_type()\n            note_type[\"did\"] = deck2\n            col.models.update_dict(note_type)\n        col.addNote(note)\n    # give the child deck a different configuration\n    c2 = col.decks.add_config_returning_id(\"new conf\")\n    col.decks.set_config_id_for_deck_dict(col.decks.get(deck2), c2)\n    # both confs have defaulted to a limit of 20\n    assert col.sched.newCount == 20\n    # first card we get comes from parent\n    c = col.sched.getCard()\n    assert c.did == 1\n    # limit the parent to 10 cards, meaning we get 10 in total\n    conf1 = col.decks.config_dict_for_deck_id(1)\n    conf1[\"new\"][\"perDay\"] = 10\n    col.decks.save(conf1)\n    assert col.sched.newCount == 10\n    # if we limit child to 4, we should get 9\n    conf2 = col.decks.config_dict_for_deck_id(deck2)\n    conf2[\"new\"][\"perDay\"] = 4\n    col.decks.save(conf2)\n    assert col.sched.newCount == 9\n\n\ndef test_newBoxes():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = col.sched.getCard()\n    conf = col.sched._cardConf(c)\n    conf[\"new\"][\"delays\"] = [1, 2, 3, 4, 5]\n    col.decks.save(conf)\n    col.sched.answerCard(c, 2)\n    # should handle gracefully\n    conf[\"new\"][\"delays\"] = [1]\n    col.decks.save(conf)\n    col.sched.answerCard(c, 2)\n\n\ndef test_learn():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    # set as a new card and rebuild queues\n    col.db.execute(f\"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}\")\n    # sched.getCard should return it, since it's due in the past\n    c = col.sched.getCard()\n    assert c\n    conf = col.sched._cardConf(c)\n    conf[\"new\"][\"delays\"] = [0.5, 3, 10]\n    col.decks.save(conf)\n    # fail it\n    col.sched.answerCard(c, 1)\n    # it should have three reps left to graduation\n    assert c.left % 1000 == 3\n    # it should be due in 30 seconds\n    t = round(c.due - time.time())\n    assert t >= 25 and t <= 40\n    # pass it once\n    col.sched.answerCard(c, 3)\n    # it should be due in 3 minutes\n    dueIn = c.due - time.time()\n    assert 178 <= dueIn <= 180 * 1.25\n    assert c.left % 1000 == 2\n    # check log is accurate\n    log = col.db.first(\"select * from revlog order by id desc\")\n    assert log[3] == 3\n    assert log[4] == -180\n    assert log[5] == -30\n    # pass again\n    col.sched.answerCard(c, 3)\n    # it should be due in 10 minutes\n    dueIn = c.due - time.time()\n    assert 598 <= dueIn <= 600 * 1.25\n    assert c.left % 1000 == 1\n    # the next pass should graduate the card\n    assert c.queue == QUEUE_TYPE_LRN\n    assert c.type == CARD_TYPE_LRN\n    col.sched.answerCard(c, 3)\n    assert c.queue == QUEUE_TYPE_REV\n    assert c.type == CARD_TYPE_REV\n    # should be due tomorrow, with an interval of 1\n    assert c.due == col.sched.today + 1\n    assert c.ivl == 1\n    # or normal removal\n    c.type = CARD_TYPE_NEW\n    c.queue = QUEUE_TYPE_LRN\n    c.flush()\n    col.sched.answerCard(c, 4)\n    assert c.type == CARD_TYPE_REV\n    assert c.queue == QUEUE_TYPE_REV\n    # revlog should have been updated each time\n    assert col.db.scalar(\"select count() from revlog where type = 0\") == 5\n\n\ndef test_relearn():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.ivl = 100\n    c.due = col.sched.today\n    c.queue = CARD_TYPE_REV\n    c.type = QUEUE_TYPE_REV\n    c.flush()\n\n    # fail the card\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    assert c.queue == QUEUE_TYPE_LRN\n    assert c.type == CARD_TYPE_RELEARNING\n    assert c.ivl == 1\n\n    # immediately graduate it\n    col.sched.answerCard(c, 4)\n    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV\n    assert c.ivl == 2\n    assert c.due == col.sched.today + c.ivl\n\n\ndef test_relearn_no_steps():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.ivl = 100\n    c.due = col.sched.today\n    c.queue = CARD_TYPE_REV\n    c.type = QUEUE_TYPE_REV\n    c.flush()\n\n    conf = col.decks.config_dict_for_deck_id(1)\n    conf[\"lapse\"][\"delays\"] = []\n    col.decks.save(conf)\n\n    # fail the card\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV\n\n\ndef test_learn_collapsed():\n    col = getEmptyCol()\n    # add 2 notes\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    col.addNote(note)\n    note = col.newNote()\n    note[\"Front\"] = \"2\"\n    col.addNote(note)\n    # set as a new card and rebuild queues\n    col.db.execute(f\"update cards set queue={QUEUE_TYPE_NEW}, type={CARD_TYPE_NEW}\")\n    # should get '1' first\n    c = col.sched.getCard()\n    assert c.question().endswith(\"1\")\n    # pass it so it's due in 10 minutes\n    col.sched.answerCard(c, 3)\n    # get the other card\n    c = col.sched.getCard()\n    assert c.question().endswith(\"2\")\n    # fail it so it's due in 1 minute\n    col.sched.answerCard(c, 1)\n    # we shouldn't get the same card again\n    c = col.sched.getCard()\n    assert not c.question().endswith(\"2\")\n\n\ndef test_learn_day():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    col.addNote(note)\n    c = col.sched.getCard()\n    conf = col.sched._cardConf(c)\n    conf[\"new\"][\"delays\"] = [1, 10, 1440, 2880]\n    col.decks.save(conf)\n    # pass it\n    col.sched.answerCard(c, 3)\n    # two reps to graduate, 1 more today\n    assert c.left % 1000 == 3\n    assert col.sched.counts() == (1, 1, 0)\n    c.load()\n    ni = col.sched.nextIvl\n    assert ni(c, 3) == 86400\n    # answer the other dummy card\n    col.sched.answerCard(col.sched.getCard(), 4)\n    # answering the first one will place it in queue 3\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 3)\n    assert c.due == col.sched.today + 1\n    assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN\n    assert not col.sched.getCard()\n    # for testing, move it back a day\n    c.due -= 1\n    c.flush()\n    assert col.sched.counts() == (0, 1, 0)\n    c = col.sched.getCard()\n    # nextIvl should work\n    assert ni(c, 3) == 86400 * 2\n    # if we fail it, it should be back in the correct queue\n    col.sched.answerCard(c, 1)\n    assert c.queue == QUEUE_TYPE_LRN\n    col.undo()\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 3)\n    # simulate the passing of another two days\n    c.due -= 2\n    c.flush()\n    # the last pass should graduate it into a review card\n    assert ni(c, 3) == 86400\n    col.sched.answerCard(c, 3)\n    assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV\n    # if the lapse step is tomorrow, failing it should handle the counts\n    # correctly\n    c.due = 0\n    c.flush()\n    assert col.sched.counts() == (0, 0, 1)\n    conf = col.sched._cardConf(c)\n    conf[\"lapse\"][\"delays\"] = [1440]\n    col.decks.save(conf)\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN\n    assert col.sched.counts() == (0, 0, 0)\n\n\ndef test_reviews():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    # set the card up as a review card, due 8 days ago\n    c = note.cards()[0]\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.due = col.sched.today - 8\n    c.factor = STARTING_FACTOR\n    c.reps = 3\n    c.lapses = 1\n    c.ivl = 100\n    c.start_timer()\n    c.flush()\n    # save it for later use as well\n    cardcopy = copy.copy(c)\n    # try with an ease of 2\n    ##################################################\n    c = copy.copy(cardcopy)\n    c.flush()\n    col.sched.answerCard(c, 2)\n    assert c.queue == QUEUE_TYPE_REV\n    # the new interval should be (100) * 1.2 = 120\n    assert c.due == col.sched.today + c.ivl\n    # factor should have been decremented\n    assert c.factor == 2350\n    # check counters\n    assert c.lapses == 1\n    assert c.reps == 4\n    # ease 3\n    ##################################################\n    c = copy.copy(cardcopy)\n    c.flush()\n    col.sched.answerCard(c, 3)\n    # the new interval should be (100 + 8/2) * 2.5 = 260\n    assert c.due == col.sched.today + c.ivl\n    # factor should have been left alone\n    assert c.factor == STARTING_FACTOR\n    # ease 4\n    ##################################################\n    c = copy.copy(cardcopy)\n    c.flush()\n    col.sched.answerCard(c, 4)\n    # the new interval should be (100 + 8) * 2.5 * 1.3 = 351\n    assert c.due == col.sched.today + c.ivl\n    # factor should have been increased\n    assert c.factor == 2650\n    # leech handling\n    ##################################################\n    conf = col.decks.get_config(1)\n    conf[\"lapse\"][\"leechAction\"] = LEECH_SUSPEND\n    col.decks.save(conf)\n    c = copy.copy(cardcopy)\n    c.lapses = 7\n    c.flush()\n\n    col.sched.answerCard(c, 1)\n    assert c.queue == QUEUE_TYPE_SUSPENDED\n    c.load()\n    assert c.queue == QUEUE_TYPE_SUSPENDED\n    assert \"leech\" in c.note().tags\n\n\ndef review_limits_setup() -> tuple[anki.collection.Collection, dict]:\n    col = getEmptyCol()\n\n    parent = col.decks.get(col.decks.id(\"parent\"))\n    child = col.decks.get(col.decks.id(\"parent::child\"))\n\n    pconf = col.decks.get_config(col.decks.add_config_returning_id(\"parentConf\"))\n    cconf = col.decks.get_config(col.decks.add_config_returning_id(\"childConf\"))\n\n    pconf[\"rev\"][\"perDay\"] = 5\n    col.decks.update_config(pconf)\n    col.decks.set_config_id_for_deck_dict(parent, pconf[\"id\"])\n    cconf[\"rev\"][\"perDay\"] = 10\n    col.decks.update_config(cconf)\n    col.decks.set_config_id_for_deck_dict(child, cconf[\"id\"])\n\n    m = col.models.current()\n    m[\"did\"] = child[\"id\"]\n    col.models.save(m, updateReqs=False)\n\n    # add some cards\n    for i in range(20):\n        note = col.newNote()\n        note[\"Front\"] = \"one\"\n        note[\"Back\"] = \"two\"\n        col.addNote(note)\n\n        # make them reviews\n        c = note.cards()[0]\n        c.queue = CARD_TYPE_REV\n        c.type = QUEUE_TYPE_REV\n        c.due = 0\n        c.flush()\n\n    return col, child\n\n\ndef test_review_limits():\n    col, child = review_limits_setup()\n\n    tree = col.sched.deck_due_tree().children\n    # (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),)))\n    assert tree[0].review_count == 5  # parent\n    assert tree[0].children[0].review_count == 10  # child\n\n    # .counts() should match\n    col.decks.select(child[\"id\"])\n    col.sched.reset()\n    assert col.sched.counts() == (0, 0, 10)\n\n    # answering a card in the child should decrement parent count\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 3)\n    assert col.sched.counts() == (0, 0, 9)\n\n    tree = col.sched.deck_due_tree().children\n    assert tree[0].review_count == 4  # parent\n    assert tree[0].children[0].review_count == 9  # child\n\n\ndef test_button_spacing():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    # 1 day ivl review card due now\n    c = note.cards()[0]\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.due = col.sched.today\n    c.reps = 1\n    c.ivl = 1\n    c.start_timer()\n    c.flush()\n    ni = col.sched.nextIvlStr\n    wo = without_unicode_isolation\n    assert wo(ni(c, 2)) == \"2d\"\n    assert wo(ni(c, 3)) == \"3d\"\n    assert wo(ni(c, 4)) == \"4d\"\n\n    # if hard factor is <= 1, then hard may not increase\n    conf = col.decks.config_dict_for_deck_id(1)\n    conf[\"rev\"][\"hardFactor\"] = 1\n    col.decks.save(conf)\n    assert wo(ni(c, 2)) == \"1d\"\n\n\ndef test_nextIvl():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    conf = col.decks.config_dict_for_deck_id(1)\n    conf[\"new\"][\"delays\"] = [0.5, 3, 10]\n    conf[\"lapse\"][\"delays\"] = [1, 5, 9]\n    col.decks.save(conf)\n    c = col.sched.getCard()\n    # new cards\n    ##################################################\n    ni = col.sched.nextIvl\n    assert ni(c, 1) == 30\n    assert ni(c, 2) == (30 + 180) // 2\n    assert ni(c, 3) == 180\n    assert ni(c, 4) == 4 * 86400\n    col.sched.answerCard(c, 1)\n    # cards in learning\n    ##################################################\n    assert ni(c, 1) == 30\n    assert ni(c, 2) == (30 + 180) // 2\n    assert ni(c, 3) == 180\n    assert ni(c, 4) == 4 * 86400\n    col.sched.answerCard(c, 3)\n    assert ni(c, 1) == 30\n    assert ni(c, 2) == 180\n    assert ni(c, 3) == 600\n    assert ni(c, 4) == 4 * 86400\n    col.sched.answerCard(c, 3)\n    # normal graduation is tomorrow\n    assert ni(c, 3) == 1 * 86400\n    assert ni(c, 4) == 4 * 86400\n    # lapsed cards\n    ##################################################\n    c.type = CARD_TYPE_RELEARNING\n    c.ivl = 100\n    c.factor = STARTING_FACTOR\n    c.flush()\n    assert ni(c, 1) == 60\n    assert ni(c, 3) == 100 * 86400\n    assert ni(c, 4) == 101 * 86400\n    # review cards\n    ##################################################\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.ivl = 100\n    c.factor = STARTING_FACTOR\n    c.flush()\n    # failing it should put it at 60s\n    assert ni(c, 1) == 60\n    # or 1 day if relearn is false\n    conf[\"lapse\"][\"delays\"] = []\n    col.decks.save(conf)\n    assert ni(c, 1) == 1 * 86400\n    # (* 100 1.2 86400)10368000.0\n    assert ni(c, 2) == 10368000\n    # (* 100 2.5 86400)21600000.0\n    assert ni(c, 3) == 21600000\n    # (* 100 2.5 1.3 86400)28080000.0\n    assert ni(c, 4) == 28080000\n    assert without_unicode_isolation(col.sched.nextIvlStr(c, 4)) == \"10.7mo\"\n\n\ndef test_bury():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    col.addNote(note)\n    c2 = note.cards()[0]\n    # burying\n    col.sched.bury_cards([c.id], manual=True)\n    c.load()\n    assert c.queue == QUEUE_TYPE_MANUALLY_BURIED\n    col.sched.bury_cards([c2.id], manual=False)\n    c2.load()\n    assert c2.queue == QUEUE_TYPE_SIBLING_BURIED\n\n    assert not col.sched.getCard()\n\n    col.sched.unbury_deck(deck_id=col.decks.get_current_id(), mode=UnburyDeck.USER_ONLY)\n    c.load()\n    assert c.queue == QUEUE_TYPE_NEW\n    c2.load()\n    assert c2.queue == QUEUE_TYPE_SIBLING_BURIED\n\n    col.sched.unbury_deck(\n        deck_id=col.decks.get_current_id(), mode=UnburyDeck.SCHED_ONLY\n    )\n    c2.load()\n    assert c2.queue == QUEUE_TYPE_NEW\n\n    col.sched.bury_cards([c.id, c2.id])\n    col.sched.unbury_deck(deck_id=col.decks.get_current_id())\n\n    assert col.sched.counts() == (2, 0, 0)\n\n\ndef test_suspend():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    # suspending\n    assert col.sched.getCard()\n    col.sched.suspend_cards([c.id])\n    assert not col.sched.getCard()\n    # unsuspending\n    col.sched.unsuspend_cards([c.id])\n    assert col.sched.getCard()\n    # should cope with rev cards being relearnt\n    c.due = 0\n    c.ivl = 100\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.flush()\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    assert c.due >= time.time()\n    due = c.due\n    assert c.queue == QUEUE_TYPE_LRN\n    assert c.type == CARD_TYPE_RELEARNING\n    col.sched.suspend_cards([c.id])\n    col.sched.unsuspend_cards([c.id])\n    c.load()\n    assert c.queue == QUEUE_TYPE_LRN\n    assert c.type == CARD_TYPE_RELEARNING\n    assert c.due == due\n    # should cope with cards in cram decks\n    c.due = 1\n    c.flush()\n    did = col.decks.new_filtered(\"tmp\")\n    col.sched.rebuild_filtered_deck(did)\n    c.load()\n    assert c.due != 1\n    assert c.did != 1\n    col.sched.suspend_cards([c.id])\n    c.load()\n    assert c.due != 1\n    assert c.did != 1\n    assert c.odue == 1\n\n\ndef test_filt_reviewing_early_normal():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.ivl = 100\n    c.queue = CARD_TYPE_REV\n    c.type = QUEUE_TYPE_REV\n    # due in 25 days, so it's been waiting 75 days\n    c.due = col.sched.today + 25\n    c.mod = 1\n    c.factor = STARTING_FACTOR\n    c.start_timer()\n    c.flush()\n    assert col.sched.counts() == (0, 0, 0)\n    # create a dynamic deck and refresh it\n    did = col.decks.new_filtered(\"Cram\")\n    col.sched.rebuild_filtered_deck(did)\n    # should appear as normal in the deck list\n    assert sorted(col.sched.deck_due_tree().children)[0].review_count == 1\n    # and should appear in the counts\n    assert col.sched.counts() == (0, 0, 1)\n    # grab it and check estimates\n    c = col.sched.getCard()\n    assert col.sched.answerButtons(c) == 4\n    assert col.sched.nextIvl(c, 1) == 600\n    assert col.sched.nextIvl(c, 2) == round(75 * 1.2) * 86400\n    assert col.sched.nextIvl(c, 3) == round(75 * 2.5) * 86400\n    assert col.sched.nextIvl(c, 4) == round(75 * 2.5 * 1.15) * 86400\n\n    # answer 'good'\n    col.sched.answerCard(c, 3)\n    assert c.due == col.sched.today + c.ivl\n    # should not be in learning\n    assert c.queue == QUEUE_TYPE_REV\n    # should be logged as a cram rep\n    assert col.db.scalar(\"select type from revlog order by id desc limit 1\") == 3\n\n    # due in 75 days, so it's been waiting 25 days\n    c.ivl = 100\n    c.due = col.sched.today + 75\n    c.flush()\n    col.sched.rebuild_filtered_deck(did)\n    c = col.sched.getCard()\n\n    assert col.sched.nextIvl(c, 2) == 100 * 1.2 / 2 * 86400\n    assert col.sched.nextIvl(c, 3) == 100 * 86400\n    assert col.sched.nextIvl(c, 4) == round(100 * (1.3 - (1.3 - 1) / 2)) * 86400\n\n\ndef test_filt_keep_lrn_state():\n    col = getEmptyCol()\n\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n\n    # fail the card outside filtered deck\n    c = col.sched.getCard()\n    conf = col.sched._cardConf(c)\n    conf[\"new\"][\"delays\"] = [1, 10, 61]\n    col.decks.save(conf)\n\n    col.sched.answerCard(c, 1)\n\n    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN\n    assert c.left % 1000 == 3\n\n    col.sched.answerCard(c, 3)\n    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN\n\n    # create a dynamic deck and refresh it\n    did = col.decks.new_filtered(\"Cram\")\n    col.sched.rebuild_filtered_deck(did)\n\n    # card should still be in learning state\n    c.load()\n    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN\n    assert c.left % 1000 == 2\n\n    # should be able to advance learning steps\n    col.sched.answerCard(c, 3)\n    # should be due at least an hour in the future\n    assert c.due - int_time() > 60 * 60\n\n    # emptying the deck preserves learning state\n    col.sched.empty_filtered_deck(did)\n    c.load()\n    assert c.type == CARD_TYPE_LRN and c.queue == QUEUE_TYPE_LRN\n    assert c.left % 1000 == 1\n    assert c.due - int_time() > 60 * 60\n\n\ndef test_preview():\n    # add cards\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    note2 = col.newNote()\n    note2[\"Front\"] = \"two\"\n    col.addNote(note2)\n    # cram deck\n    did = col.decks.new_filtered(\"Cram\")\n    cram = col.decks.get(did)\n    cram[\"resched\"] = False\n    col.decks.save(cram)\n    col.sched.rebuild_filtered_deck(did)\n    # grab the first card\n    c = col.sched.getCard()\n\n    passing_grade = 4\n    assert col.sched.answerButtons(c) == passing_grade\n    assert col.sched.nextIvl(c, 1) == 60\n    assert col.sched.nextIvl(c, passing_grade) == 0\n\n    # failing it will push its due time back\n    due = c.due\n    col.sched.answerCard(c, 1)\n    assert c.due != due\n\n    # the other card should come next\n    c2 = col.sched.getCard()\n    assert c2.id != c.id\n\n    # passing it will remove it\n    col.sched.answerCard(c2, passing_grade)\n    assert c2.queue == QUEUE_TYPE_NEW\n    assert c2.reps == 0\n    assert c2.type == CARD_TYPE_NEW\n\n    # emptying the filtered deck should restore card\n    col.sched.empty_filtered_deck(did)\n    c.load()\n    assert c.queue == QUEUE_TYPE_NEW\n    assert c.reps == 0\n    assert c.type == CARD_TYPE_NEW\n\n\ndef test_ordcycle():\n    col = getEmptyCol()\n    # add two more templates and set second active\n    m = col.models.current()\n    mm = col.models\n    t = mm.new_template(\"Reverse\")\n    t[\"qfmt\"] = \"{{Back}}\"\n    t[\"afmt\"] = \"{{Front}}\"\n    mm.add_template(m, t)\n    t = mm.new_template(\"f2\")\n    t[\"qfmt\"] = \"{{Front}}2\"\n    t[\"afmt\"] = \"{{Back}}\"\n    mm.add_template(m, t)\n    mm.save(m)\n    # create a new note; it should have 3 cards\n    note = col.newNote()\n    note[\"Front\"] = \"1\"\n    note[\"Back\"] = \"1\"\n    col.addNote(note)\n    assert col.card_count() == 3\n\n    conf = col.decks.get_config(1)\n    conf[\"new\"][\"bury\"] = False\n    col.decks.save(conf)\n\n    # ordinals should arrive in order\n    for i in range(3):\n        c = col.sched.getCard()\n        assert c.ord == i\n        col.sched.answerCard(c, 4)\n\n\ndef test_counts_idx_new():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    assert col.sched.counts() == (2, 0, 0)\n    c = col.sched.getCard()\n    # getCard does not decrement counts\n    assert col.sched.counts() == (2, 0, 0)\n    assert col.sched.countIdx(c) == 0\n    # answer to move to learn queue\n    col.sched.answerCard(c, 1)\n    assert col.sched.counts() == (1, 1, 0)\n    assert col.sched.countIdx(c) == 1\n    # fetching next will not decrement the count\n    c = col.sched.getCard()\n    assert col.sched.counts() == (1, 1, 0)\n    assert col.sched.countIdx(c) == 0\n\n\ndef test_repCounts():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    col.addNote(note)\n    # lrnReps should be accurate on pass/fail\n    assert col.sched.counts() == (2, 0, 0)\n    col.sched.answerCard(col.sched.getCard(), 1)\n    assert col.sched.counts() == (1, 1, 0)\n    col.sched.answerCard(col.sched.getCard(), 1)\n    assert col.sched.counts() == (0, 2, 0)\n    col.sched.answerCard(col.sched.getCard(), 3)\n    assert col.sched.counts() == (0, 2, 0)\n    col.sched.answerCard(col.sched.getCard(), 1)\n    assert col.sched.counts() == (0, 2, 0)\n    col.sched.answerCard(col.sched.getCard(), 3)\n    assert col.sched.counts() == (0, 1, 0)\n    col.sched.answerCard(col.sched.getCard(), 4)\n    assert col.sched.counts() == (0, 0, 0)\n    note = col.newNote()\n    note[\"Front\"] = \"three\"\n    col.addNote(note)\n    note = col.newNote()\n    note[\"Front\"] = \"four\"\n    col.addNote(note)\n    # initial pass and immediate graduate should be correct too\n    assert col.sched.counts() == (2, 0, 0)\n    col.sched.answerCard(col.sched.getCard(), 3)\n    assert col.sched.counts() == (1, 1, 0)\n    col.sched.answerCard(col.sched.getCard(), 4)\n    assert col.sched.counts() == (0, 1, 0)\n    col.sched.answerCard(col.sched.getCard(), 4)\n    assert col.sched.counts() == (0, 0, 0)\n    # and failing a review should too\n    note = col.newNote()\n    note[\"Front\"] = \"five\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.due = col.sched.today\n    c.flush()\n    note = col.newNote()\n    note[\"Front\"] = \"six\"\n    col.addNote(note)\n    assert col.sched.counts() == (1, 0, 1)\n    col.sched.answerCard(col.sched.getCard(), 1)\n    assert col.sched.counts() == (1, 1, 0)\n\n\ndef test_timing():\n    col = getEmptyCol()\n    # add a few review cards, due today\n    for i in range(5):\n        note = col.newNote()\n        note[\"Front\"] = f\"num{str(i)}\"\n        col.addNote(note)\n        c = note.cards()[0]\n        c.type = CARD_TYPE_REV\n        c.queue = QUEUE_TYPE_REV\n        c.due = 0\n        c.flush()\n    # fail the first one\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    # the next card should be another review\n    c2 = col.sched.getCard()\n    assert c2.queue == QUEUE_TYPE_REV\n    # if the failed card becomes due, it should show first\n    c.due = int_time() - 1\n    c.flush()\n    c = col.sched.getCard()\n    assert c.queue == QUEUE_TYPE_LRN\n\n\ndef test_collapse():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    # and another, so we don't get the same twice in a row\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    col.addNote(note)\n    # first note\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    # second note\n    c2 = col.sched.getCard()\n    assert c2.nid != c.nid\n    col.sched.answerCard(c2, 1)\n    # first should become available again, despite it being due in the future\n    c3 = col.sched.getCard()\n    assert c3.due > int_time()\n    col.sched.answerCard(c3, 4)\n    # answer other\n    c4 = col.sched.getCard()\n    col.sched.answerCard(c4, 4)\n    assert not col.sched.getCard()\n\n\ndef test_deckDue():\n    col = getEmptyCol()\n    # add a note with default deck\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    # and one that's a child\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    note_type = note.note_type()\n    default1 = note_type[\"did\"] = col.decks.id(\"Default::1\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    # make it a review card\n    c = note.cards()[0]\n    c.queue = QUEUE_TYPE_REV\n    c.due = 0\n    c.flush()\n    # add one more with a new deck\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    note_type = note.note_type()\n    note_type[\"did\"] = col.decks.id(\"foo::bar\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    # and one that's a sibling\n    note = col.newNote()\n    note[\"Front\"] = \"three\"\n    note_type = note.note_type()\n    note_type[\"did\"] = col.decks.id(\"foo::baz\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    assert len(col.decks.all_names_and_ids()) == 5\n    tree = col.sched.deck_due_tree().children\n    assert tree[0].name == \"Default\"\n    # sum of child and parent\n    assert tree[0].deck_id == 1\n    assert tree[0].review_count == 1\n    assert tree[0].new_count == 1\n    # child count is just review\n    child = tree[0].children[0]\n    assert child.name == \"1\"\n    assert child.deck_id == default1\n    assert child.review_count == 1\n    assert child.new_count == 0\n    # code should not fail if a card has an invalid deck\n    c.did = 12345\n    c.flush()\n    col.sched.deck_due_tree()\n\n\ndef test_deckTree():\n    col = getEmptyCol()\n    col.decks.id(\"new::b::c\")\n    col.decks.id(\"new2\")\n    # new should not appear twice in tree\n    names = [x.name for x in col.sched.deck_due_tree().children]\n    names.remove(\"new\")\n    assert \"new\" not in names\n\n\ndef test_deckFlow():\n    col = getEmptyCol()\n    # add a note with default deck\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    # and one that's a child\n    note = col.newNote()\n    note[\"Front\"] = \"two\"\n    note_type = note.note_type()\n    note_type[\"did\"] = col.decks.id(\"Default::2\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    # and another that's higher up\n    note = col.newNote()\n    note[\"Front\"] = \"three\"\n    note_type = note.note_type()\n    default1 = note_type[\"did\"] = col.decks.id(\"Default::1\")\n    col.models.update_dict(note_type)\n    col.addNote(note)\n    assert col.sched.counts() == (3, 0, 0)\n    # should get top level one first, then ::1, then ::2\n    for i in \"one\", \"three\", \"two\":\n        c = col.sched.getCard()\n        assert c.note()[\"Front\"] == i\n        col.sched.answerCard(c, 3)\n\n\ndef test_reorder():\n    col = getEmptyCol()\n    # add a note with default deck\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    note2 = col.newNote()\n    note2[\"Front\"] = \"two\"\n    col.addNote(note2)\n    assert note2.cards()[0].due == 2\n    found = False\n    # 50/50 chance of being reordered\n    for i in range(20):\n        col.sched.randomize_cards(1)\n        if note.cards()[0].due != note.id:\n            found = True\n            break\n    assert found\n    col.sched.order_cards(1)\n    assert note.cards()[0].due == 1\n    # shifting\n    note3 = col.newNote()\n    note3[\"Front\"] = \"three\"\n    col.addNote(note3)\n    note4 = col.newNote()\n    note4[\"Front\"] = \"four\"\n    col.addNote(note4)\n    assert note.cards()[0].due == 1\n    assert note2.cards()[0].due == 2\n    assert note3.cards()[0].due == 3\n    assert note4.cards()[0].due == 4\n    col.sched.reposition_new_cards(\n        [note3.cards()[0].id, note4.cards()[0].id],\n        starting_from=1,\n        shift_existing=True,\n        step_size=1,\n        randomize=False,\n    )\n    assert note.cards()[0].due == 3\n    assert note2.cards()[0].due == 4\n    assert note3.cards()[0].due == 1\n    assert note4.cards()[0].due == 2\n\n\ndef test_forget():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.queue = QUEUE_TYPE_REV\n    c.type = CARD_TYPE_REV\n    c.ivl = 100\n    c.due = 0\n    c.flush()\n    assert col.sched.counts() == (0, 0, 1)\n    col.sched.forgetCards([c.id])\n    assert col.sched.counts() == (1, 0, 0)\n\n\ndef test_resched():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    col.sched.set_due_date([c.id], \"0\")\n    c.load()\n    assert c.due == col.sched.today\n    assert c.ivl == 1\n    assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV\n    # make it due tomorrow\n    col.sched.set_due_date([c.id], \"1\")\n    c.load()\n    assert c.due == col.sched.today + 1\n    assert c.ivl == 1\n\n\ndef test_norelearn():\n    col = getEmptyCol()\n    # add a note\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.due = 0\n    c.factor = STARTING_FACTOR\n    c.reps = 3\n    c.lapses = 1\n    c.ivl = 100\n    c.start_timer()\n    c.flush()\n    col.sched.answerCard(c, 1)\n    col.sched._cardConf(c)[\"lapse\"][\"delays\"] = []\n    col.sched.answerCard(c, 1)\n\n\ndef test_failmult():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.type = CARD_TYPE_REV\n    c.queue = QUEUE_TYPE_REV\n    c.ivl = 100\n    c.due = col.sched.today - c.ivl\n    c.factor = STARTING_FACTOR\n    c.reps = 3\n    c.lapses = 1\n    c.start_timer()\n    c.flush()\n    conf = col.sched._cardConf(c)\n    conf[\"lapse\"][\"mult\"] = 0.5\n    col.decks.save(conf)\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 1)\n    assert c.ivl == 50\n    col.sched.answerCard(c, 1)\n    assert c.ivl == 25\n\n\n# cards with a due date earlier than the collection should retain\n# their due date when removed\ndef test_negativeDueFilter():\n    col = getEmptyCol()\n\n    # card due prior to collection date\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n    c = note.cards()[0]\n    c.due = -5\n    c.queue = QUEUE_TYPE_REV\n    c.ivl = 5\n    c.flush()\n\n    # into and out of filtered deck\n    did = col.decks.new_filtered(\"Cram\")\n    col.sched.rebuild_filtered_deck(did)\n    col.sched.empty_filtered_deck(did)\n\n    c.load()\n    assert c.due == -5\n\n\n# hard on the first step should be the average of again and good,\n# and it should be logged properly\ndef test_initial_repeat():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"one\"\n    note[\"Back\"] = \"two\"\n    col.addNote(note)\n\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 2)\n    # should be due in ~ 5.5 mins\n    expected = time.time() + 5.5 * 60\n    assert expected - 10 < c.due < expected * 1.25\n\n    ivl = col.db.scalar(\"select ivl from revlog\")\n    assert ivl == -5.5 * 60\n"
  },
  {
    "path": "pylib/tests/test_stats.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport tempfile\n\nfrom anki.collection import CardStats\nfrom tests.shared import getEmptyCol\n\n\ndef test_stats():\n    col = getEmptyCol()\n    note = col.newNote()\n    note[\"Front\"] = \"foo\"\n    col.addNote(note)\n    c = note.cards()[0]\n    # card stats\n    card_stats = col.card_stats_data(c.id)\n    assert card_stats.note_id == note.id\n    c = col.sched.getCard()\n    col.sched.answerCard(c, 3)\n    col.sched.answerCard(c, 2)\n    card_stats = col.card_stats_data(c.id)\n    assert len(card_stats.revlog) == 2\n\n\ndef test_graphs_empty():\n    col = getEmptyCol()\n    assert col.stats().report()\n\n\ndef test_graphs():\n    dir = tempfile.gettempdir()\n    col = getEmptyCol()\n    g = col.stats()\n    rep = g.report()\n    with open(os.path.join(dir, \"test.html\"), \"w\", encoding=\"UTF-8\") as note:\n        note.write(rep)\n    return\n"
  },
  {
    "path": "pylib/tests/test_template.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom tests.shared import getEmptyCol\n\n\ndef test_deferred_frontside():\n    col = getEmptyCol()\n    m = col.models.current()\n    m[\"tmpls\"][0][\"qfmt\"] = \"{{custom:Front}}\"\n    col.models.save(m)\n\n    note = col.newNote()\n    note[\"Front\"] = \"xxtest\"\n    note[\"Back\"] = \"\"\n    col.addNote(note)\n\n    assert \"xxtest\" in note.cards()[0].answer()\n"
  },
  {
    "path": "pylib/tests/test_utils.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom anki.utils import int_version_to_str\n\n\ndef test_int_version_to_str():\n    assert int_version_to_str(23) == \"2.1.23\"\n    assert int_version_to_str(230900) == \"23.09\"\n    assert int_version_to_str(230901) == \"23.09.1\"\n"
  },
  {
    "path": "pylib/tools/genbuildinfo.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport sys\n\nversion_file = sys.argv[1]\nbuildhash_file = sys.argv[2]\noutpath = sys.argv[3]\n\nwith open(version_file, \"r\", encoding=\"utf8\") as f:\n    version = f.read().strip()\n\nwith open(buildhash_file, \"r\", encoding=\"utf8\") as f:\n    buildhash = f.read().strip()\n\nwith open(outpath, \"w\", encoding=\"utf8\") as f:\n    # if we switch to uppercase we'll need to add legacy aliases\n    f.write(f\"version = '{version}'\\n\")\n    f.write(f\"buildhash = '{buildhash}'\\n\")\n"
  },
  {
    "path": "pylib/tools/genhooks.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nGenerate code for hook handling, and insert it into anki/hooks.py.\n\nTo add a new hook, update the hooks list below, then send a pull request\nthat includes the changes to this file.\n\nIn most cases, hooks are better placed in genhooks_gui.py.\n\"\"\"\n\nimport sys\n\nfrom hookslib import Hook, write_file\n\n# Hook/filter list\n######################################################################\n\nhooks = [\n    Hook(name=\"card_odue_was_invalid\"),\n    Hook(name=\"schema_will_change\", args=[\"proceed: bool\"], return_type=\"bool\"),\n    Hook(\n        name=\"notes_will_be_deleted\",\n        args=[\"col: anki.collection.Collection\", \"ids: Sequence[anki.notes.NoteId]\"],\n        legacy_hook=\"remNotes\",\n    ),\n    Hook(\n        name=\"note_will_be_added\",\n        args=[\n            \"col: anki.collection.Collection\",\n            \"note: anki.notes.Note\",\n            \"deck_id: anki.decks.DeckId\",\n        ],\n        doc=\"\"\"Allows modifying a note before it's added to the collection.\n\n        This hook may be called both when users use the Add screen, and when\n        add-ons like AnkiConnect add notes. It is not called when importing. If\n        you wish to alter the Add screen, use gui_hooks.add_cards_will_add_note\n        instead.\"\"\",\n    ),\n    Hook(\n        name=\"media_files_did_export\",\n        args=[\"count: int\"],\n        doc=\"Only used by legacy .apkg exporter. Will be deprecated in the future.\",\n    ),\n    Hook(\n        name=\"legacy_export_progress\",\n        args=[\"progress: str\"],\n        doc=\"Temporary hook used in transition to new import/export code.\",\n    ),\n    Hook(\n        name=\"exporters_list_created\",\n        args=[\"exporters: list[tuple[str, Any]]\"],\n        legacy_hook=\"exportersList\",\n    ),\n    Hook(\n        name=\"media_file_filter\",\n        args=[\"txt: str\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows manipulating the file path that media will be read from\"\"\",\n    ),\n    Hook(\n        name=\"field_filter\",\n        args=[\n            \"field_text: str\",\n            \"field_name: str\",\n            \"filter_name: str\",\n            \"ctx: anki.template.TemplateRenderContext\",\n        ],\n        return_type=\"str\",\n        doc=\"\"\"Allows you to define custom {{filters:..}}\n        \n        Your add-on can check filter_name to decide whether it should modify\n        field_text or not before returning it.\"\"\",\n    ),\n    Hook(\n        name=\"note_will_flush\",\n        args=[\"note: Note\"],\n        doc=\"Allow to change a note before it is added/updated in the database.\",\n    ),\n    Hook(\n        name=\"card_will_flush\",\n        args=[\"card: Card\"],\n        doc=\"Allow to change a card before it is added/updated in the database.\",\n    ),\n    Hook(\n        name=\"card_did_render\",\n        args=[\n            \"output: anki.template.TemplateRenderOutput\",\n            \"ctx: anki.template.TemplateRenderContext\",\n        ],\n        doc=\"Can modify the resulting text after rendering completes.\",\n    ),\n    Hook(\n        name=\"importing_importers\",\n        args=[\"importers: list[tuple[str, Any]]\"],\n        doc=\"\"\"Allows updating the list of importers.\n        The resulting list is not saved and should be changed each time the\n        filter is called.\n        \n        NOTE: Updates to the import/export code are expected in the coming \n        months, and this hook may be replaced with another solution at that \n        time. Tracked on https://github.com/ankitects/anki/issues/1018\"\"\",\n    ),\n    # obsolete\n    Hook(\n        name=\"deck_added\",\n        args=[\"deck: anki.decks.DeckDict\"],\n        doc=\"Obsolete, do not use.\",\n    ),\n    Hook(\n        name=\"note_type_added\",\n        args=[\"notetype: anki.models.NotetypeDict\"],\n        doc=\"Obsolete, do not use.\",\n    ),\n    Hook(\n        name=\"sync_stage_did_change\",\n        args=[\"stage: str\"],\n        doc=\"Obsolete, do not use.\",\n    ),\n    Hook(\n        name=\"sync_progress_did_change\",\n        args=[\"msg: str\"],\n        doc=\"Obsolete, do not use.\",\n    ),\n]\n\nprefix = \"\"\"\\\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# This file is automatically generated; edit tools/genhooks.py instead.\n# Please import from anki.hooks instead of this file.\n\n# ruff: noqa: F401\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Sequence\nfrom typing import Any\n\nimport anki\nimport anki.hooks\nfrom anki.cards import Card\nfrom anki.notes import Note\n\"\"\"\n\nsuffix = \"\"\n\nif __name__ == \"__main__\":\n    path = sys.argv[1]\n    write_file(path, hooks, prefix, suffix)\n"
  },
  {
    "path": "pylib/tools/hookslib.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nCode for generating hooks.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport subprocess\nimport sys\nfrom dataclasses import dataclass\nfrom operator import attrgetter\n\nsys.path.append(\"pylib/anki/_vendor\")\n\nimport stringcase\n\n\n@dataclass\nclass Hook:\n    # the name of the hook. _filter or _hook is appending automatically.\n    name: str\n    # string of the typed arguments passed to the callback, eg\n    # [\"kind: str\", \"val: int\"]\n    args: list[str] | None = None\n    # string of the return type. if set, hook is a filter.\n    return_type: str | None = None\n    # if add-ons may be relying on the legacy hook name, add it here\n    legacy_hook: str | None = None\n    # if legacy hook takes no arguments but the new hook does, set this\n    legacy_no_args: bool = False\n    # if the hook replaces a deprecated one, add its name here\n    replaces: str | None = None\n    # arguments that the hook being replaced took\n    replaced_hook_args: list[str] | None = None\n    # docstring to add to hook class\n    doc: str | None = None\n\n    def callable(self) -> str:\n        \"Convert args into a Callable.\"\n        types = []\n        for arg in self.args or []:\n            (name, type) = arg.split(\":\")\n            type = f'\"{type.strip()}\"'\n            types.append(type)\n        types_str = \", \".join(types)\n        return f\"Callable[[{types_str}], {self.return_type or 'None'}]\"\n\n    def arg_names(self, args: list[str] | None) -> list[str]:\n        names = []\n        for arg in args or []:\n            if not arg:\n                continue\n            (name, type) = arg.split(\":\")\n            names.append(name.strip())\n        return names\n\n    def full_name(self) -> str:\n        return f\"{self.name}_{self.kind()}\"\n\n    def kind(self) -> str:\n        if self.return_type is not None:\n            return \"filter\"\n        else:\n            return \"hook\"\n\n    def classname(self) -> str:\n        return f\"_{stringcase.pascalcase(self.full_name())}\"\n\n    def list_code(self) -> str:\n        return f\"\"\"\\\n    _hooks: list[{self.callable()}] = []\n\"\"\"\n\n    def code(self) -> str:\n        appenddoc = f\"({', '.join(self.args or [])})\"\n        if self.doc:\n            classdoc = f\"    '''{self.doc}'''\\n\"\n        else:\n            classdoc = \"\"\n        code = f\"\"\"\\\nclass {self.classname()}:\n{classdoc}{self.list_code()}\n    \n    def append(self, callback: {self.callable()}) -> None:\n        '''{appenddoc}'''\n        self._hooks.append(callback)\n\n    def remove(self, callback: {self.callable()}) -> None:\n        if callback in self._hooks:\n            self._hooks.remove(callback)\n\n    def count(self) -> int:\n        return len(self._hooks)\n\n{self.fire_code()}\n{self.name} = {self.classname()}()\n\"\"\"\n        return code\n\n    def fire_code(self) -> str:\n        if self.return_type is not None:\n            # filter\n            return self.filter_fire_code()\n        else:\n            # hook\n            return self.hook_fire_code()\n\n    def legacy_args(self) -> str:\n        if self.legacy_no_args:\n            # hook name only\n            return f'\"{self.legacy_hook}\"'\n        else:\n            return \", \".join([f'\"{self.legacy_hook}\"'] + self.arg_names(self.args))\n\n    def replaced_args(self) -> str:\n        args = \", \".join(self.arg_names(self.replaced_hook_args))\n        return f\"{self.replaces}({args})\"\n\n    def hook_fire_code(self) -> str:\n        arg_names = self.arg_names(self.args)\n        args_including_self = [\"self\"] + (self.args or [])\n        out = f\"\"\"\\\n    def __call__({\", \".join(args_including_self)}) -> None:\n        for hook in self._hooks:\n            try:\n                hook({\", \".join(arg_names)})\n            except Exception:\n                # if the hook fails, remove it\n                self._hooks.remove(hook)\n                raise\n\"\"\"\n        if self.replaces and self.legacy_hook:\n            raise Exception(\n                f\"Hook {self.name} replaces {self.replaces} and \"\n                \"must therefore not define a legacy hook.\"\n            )\n        elif self.replaces:\n            out += f\"\"\"\\\n        if {self.replaces}.count() > 0:\n            print(\n                \"The hook {self.replaces} is deprecated.\\\\n\"\n                \"Use {self.name} instead.\"\n            )\n        {self.replaced_args()}\n\"\"\"\n        elif self.legacy_hook:\n            # don't run legacy hook if replaced hook exists\n            # otherwise the legacy hook will be run twice\n            out += f\"\"\"\\\n        # legacy support\n        anki.hooks.runHook({self.legacy_args()})\n\"\"\"\n        return f\"{out}\\n\\n\"\n\n    def filter_fire_code(self) -> str:\n        arg_names = self.arg_names(self.args)\n        args_including_self = [\"self\"] + (self.args or [])\n        out = f\"\"\"\\\n    def __call__({\", \".join(args_including_self)}) -> {self.return_type}:\n        for filter in self._hooks:\n            try:\n                {arg_names[0]} = filter({\", \".join(arg_names)})\n            except Exception:\n                # if the hook fails, remove it\n                self._hooks.remove(filter)\n                raise\n\"\"\"\n        if self.replaces and self.legacy_hook:\n            raise Exception(\n                f\"Hook {self.name} replaces {self.replaces} and \"\n                \"must therefore not define a legacy hook.\"\n            )\n        elif self.replaces:\n            out += f\"\"\"\\\n        if {self.replaces}.count() > 0:\n            print(\n                \"The hook {self.replaces} is deprecated.\\\\n\"\n                \"Use {self.name} instead.\"\n            )\n        {arg_names[0]} = {self.replaced_args()}\n\"\"\"\n        elif self.legacy_hook:\n            # don't run legacy hook if replaced hook exists\n            # otherwise the legacy hook will be run twice\n            out += f\"\"\"\\\n        # legacy support\n        {arg_names[0]} = anki.hooks.runFilter({self.legacy_args()})\n\"\"\"\n\n        out += f\"\"\"\\\n        return {arg_names[0]}\n\"\"\"\n        return f\"{out}\\n\\n\"\n\n\ndef write_file(path: str, hooks: list[Hook], prefix: str, suffix: str):\n    hooks.sort(key=attrgetter(\"name\"))\n    code = f\"{prefix}\\n\"\n    for hook in hooks:\n        code += hook.code()\n\n    code += f\"\\n{suffix}\"\n\n    with open(path, \"wb\") as file:\n        file.write(code.encode(\"utf8\"))\n    subprocess.run([sys.executable, \"-m\", \"ruff\", \"format\", \"-q\", path], check=True)\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"anki-dev\"\nversion = \"0.0.0\"\ndescription = \"Local-only environment\"\nrequires-python = \">=3.9\"\nclassifiers = [\"Private :: Do Not Upload\"]\n\n[dependency-groups]\ndev = [\n  \"mypy\",\n  \"mypy-protobuf\",\n  \"ruff\",\n  \"pytest\",\n  \"PyChromeDevTools\",\n  \"wheel\",\n  \"hatchling\", # for type checking hatch_build.py files\n  \"mock\",\n  \"types-protobuf\",\n  \"types-requests\",\n  \"types-orjson\",\n  \"types-decorator\",\n  \"types-flask\",\n  \"types-flask-cors\",\n  \"types-markdown\",\n  \"types-waitress\",\n  \"types-pywin32\",\n]\n\n[project.optional-dependencies]\nsphinx = [\n  \"sphinx\",\n  \"sphinx_rtd_theme\",\n  \"sphinx-autoapi\",\n]\n\n[tool.uv.workspace]\nmembers = [\"pylib\", \"qt\"]\n\n[[tool.uv.index]]\nname = \"testpypi\"\nurl = \"https://test.pypi.org/simple/\"\npublish-url = \"https://test.pypi.org/legacy/\"\nexplicit = true\n"
  },
  {
    "path": "python/mkempty.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport sys\n\nopen(sys.argv[1], \"w\", encoding=\"utf8\").close()\n"
  },
  {
    "path": "python/sphinx/build.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport os\nimport subprocess\n\nos.environ[\"REPO_ROOT\"] = os.path.abspath(\".\")\nsubprocess.run([\"out/pyenv/bin/sphinx-apidoc\", \"-o\", \"out/python/sphinx\", \"pylib\", \"qt\"], check=True)\nsubprocess.run([\"out/pyenv/bin/sphinx-build\", \"out/python/sphinx\", \"out/python/sphinx/html\"], check=True)\n"
  },
  {
    "path": "python/sphinx/conf.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n# Configuration file for the Sphinx documentation builder.\n#\n# For the full list of built-in configuration values, see the documentation:\n# https://www.sphinx-doc.org/en/master/usage/configuration.html\n\nimport os\n\nproject = 'Anki'\ncopyright = '2023, Ankitects Pty Ltd and contributors'\nauthor = 'Ankitects Pty Ltd and contributors'\n\nREPO_ROOT = os.environ[\"REPO_ROOT\"]\n\nextensions = ['sphinx_rtd_theme', 'autoapi.extension']\nhtml_theme = 'sphinx_rtd_theme'\nautoapi_python_use_implicit_namespaces = True\nautoapi_dirs = [os.path.join(REPO_ROOT, x) for x in [\"pylib/anki\", \"out/pylib/anki\", \"qt/aqt\", \"out/qt/_aqt\"]]\n"
  },
  {
    "path": "python/sphinx/index.rst",
    "content": ".. Anki documentation master file, created by\n   sphinx-quickstart on Tue Sep 26 09:41:18 2023.\n   You can adapt this file completely to your liking, but it should at least\n   contain the root `toctree` directive.\n\nWelcome to Anki's documentation!\n================================\n\n.. toctree::\n   :maxdepth: 2\n   :caption: Contents:\n\n\n\nIndices and tables\n==================\n\n* :ref:`genindex`\n* :ref:`modindex`\n* :ref:`search`\n"
  },
  {
    "path": "python/version.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"Version helper for wheel builds.\"\"\"\n\nimport pathlib\n\n# Read version from .version file in project root\n_version_file = pathlib.Path(__file__).parent.parent / \".version\"\n__version__ = _version_file.read_text().strip()\n"
  },
  {
    "path": "qt/README.md",
    "content": "Python's Qt GUI is in aqt/\n"
  },
  {
    "path": "qt/aqt/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\n# ruff: noqa: F401\nimport atexit\nimport logging\nimport os\nimport sys\nfrom collections.abc import Callable\nfrom typing import TYPE_CHECKING, Any, Union, cast\n\nif \"ANKI_FIRST_RUN\" in os.environ:\n    from .package import first_run_setup\n\n    first_run_setup()\n\ntry:\n    import pip_system_certs.wrapt_requests\nexcept ModuleNotFoundError:\n    print(\n        \"Python module pip_system_certs is not installed. System certificate store and custom SSL certificates may not work. See: https://github.com/ankitects/anki/issues/3016\"\n    )\n\nif sys.version_info[0] < 3 or sys.version_info[1] < 9:\n    raise Exception(\"Anki requires Python 3.9+\")\n\n# ensure unicode filenames are supported\ntry:\n    \"テスト\".encode(sys.getfilesystemencoding())\nexcept UnicodeEncodeError:\n    print(\"Anki requires a UTF-8 locale.\")\n    print(\"Please Google 'how to change locale on [your Linux distro]'\")\n    sys.exit(1)\n\n# if sync server enabled, bypass the rest of the startup\nif \"--syncserver\" in sys.argv:\n    from anki.syncserver import run_sync_server\n    from anki.utils import is_mac\n\n    # does not return\n    run_sync_server()\n\nif sys.platform == \"win32\":\n    from win32com.shell import shell\n\n    shell.SetCurrentProcessExplicitAppUserModelID(\"Ankitects.Anki\")\n\nimport argparse\nimport builtins\nimport cProfile\nimport getpass\nimport locale\nimport tempfile\nimport traceback\nfrom pathlib import Path\n\nimport anki.lang\nfrom anki._backend import RustBackend\nfrom anki.buildinfo import version as _version\nfrom anki.collection import Collection\nfrom anki.consts import HELP_SITE\nfrom anki.utils import checksum, is_gnome, is_lin, is_mac\nfrom aqt import gui_hooks\nfrom aqt.log import setup_logging\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.utils import TR, tr\n\nif TYPE_CHECKING:\n    import aqt.profiles\n\n# compat aliases\nanki.version = _version  # type: ignore\nanki.Collection = Collection  # type: ignore\n\n# we want to be able to print unicode debug info to console without\n# fear of a traceback on systems with the console set to ASCII\ntry:\n    sys.stdout.reconfigure(encoding=\"utf-8\")  # type: ignore\n    sys.stderr.reconfigure(encoding=\"utf-8\")  # type: ignore\nexcept AttributeError:\n    if is_win:\n        # On Windows without console; add a mock writer. The stderr\n        # writer will be overwritten when ErrorHandler is initialized.\n        sys.stderr = sys.stdout = open(os.devnull, \"w\", encoding=\"utf8\")\n\nappVersion = _version\nappWebsite = \"https://apps.ankiweb.net/\"\nappWebsiteDownloadSection = \"https://apps.ankiweb.net/#download\"\nappDonate = \"https://docs.ankiweb.net/contrib.html\"\nappShared = \"https://ankiweb.net/shared/\"\nappUpdate = \"https://ankiweb.net/update/desktop\"\nappHelpSite = HELP_SITE\n\nfrom aqt.main import AnkiQt  # isort:skip\nfrom aqt.profiles import ProfileManager, VideoDriver  # isort:skip\n\nprofiler: cProfile.Profile | None = None\nmw: AnkiQt = None  # type: ignore # set on init\n\nimport aqt.forms\n\n# Dialog manager\n##########################################################################\n# ensures only one copy of the window is open at once, and provides\n# a way for dialogs to clean up asynchronously when collection closes\n\n# to integrate a new window:\n# - add it to _dialogs\n# - define close behaviour, by either:\n# -- setting silentlyClose=True to have it close immediately\n# -- define a closeWithCallback() method\n# - have the window opened via aqt.dialogs.open(<name>, self)\n# - have a method reopen(*args), called if the user ask to open the window a second time. Arguments passed are the same than for original opening.\n\n# - make preferences modal? cmd+q does wrong thing\n\n\nfrom aqt import addcards, addons, browser, editcurrent, filtered_deck  # isort:skip\nfrom aqt import stats, about, preferences, mediasync  # isort:skip\n\n\nclass DialogManager:\n    _dialogs: dict[str, list] = {\n        \"AddCards\": [addcards.AddCards, None],\n        \"AddonsDialog\": [addons.AddonsDialog, None],\n        \"Browser\": [browser.Browser, None],\n        \"EditCurrent\": [editcurrent.EditCurrent, None],\n        \"FilteredDeckConfigDialog\": [filtered_deck.FilteredDeckConfigDialog, None],\n        \"DeckStats\": [stats.DeckStats, None],\n        \"NewDeckStats\": [stats.NewDeckStats, None],\n        \"About\": [about.show, None],\n        \"Preferences\": [preferences.Preferences, None],\n        \"sync_log\": [mediasync.MediaSyncDialog, None],\n    }\n\n    def open(self, name: str, *args: Any, **kwargs: Any) -> Any:\n        (creator, instance) = self._dialogs[name]\n        if instance:\n            if instance.windowState() & Qt.WindowState.WindowMinimized:\n                instance.setWindowState(\n                    instance.windowState() & ~Qt.WindowState.WindowMinimized\n                )\n            instance.activateWindow()\n            instance.raise_()\n            if hasattr(instance, \"reopen\"):\n                instance.reopen(*args, **kwargs)\n        else:\n            instance = creator(*args, **kwargs)\n            self._dialogs[name][1] = instance\n        gui_hooks.dialog_manager_did_open_dialog(self, name, instance)\n        return instance\n\n    def markClosed(self, name: str) -> None:\n        self._dialogs[name] = [self._dialogs[name][0], None]\n\n    def allClosed(self) -> bool:\n        return not any(x[1] for x in self._dialogs.values())\n\n    def closeAll(self, onsuccess: Callable[[], None]) -> bool | None:\n        # can we close immediately?\n        if self.allClosed():\n            onsuccess()\n            return None\n\n        # ask all windows to close and await a reply\n        for name, (creator, instance) in self._dialogs.items():\n            if not instance:\n                continue\n\n            def callback() -> None:\n                if self.allClosed():\n                    onsuccess()\n                else:\n                    # still waiting for others to close\n                    pass\n\n            if getattr(instance, \"silentlyClose\", False):\n                instance.close()\n                callback()\n            else:\n                instance.closeWithCallback(callback)\n\n        return True\n\n    def register_dialog(\n        self, name: str, creator: Callable | type, instance: Any | None = None\n    ) -> None:\n        \"\"\"Allows add-ons to register a custom dialog to be managed by Anki's dialog\n        manager, which ensures that only one copy of the window is open at once,\n        and that the dialog cleans up asynchronously when the collection closes\n\n        Please note that dialogs added in this manner need to define a close behavior\n        by either:\n\n            - setting `dialog.silentlyClose = True` to have it close immediately\n            - define a `dialog.closeWithCallback()` method that is called when closed\n              by the dialog manager\n\n        TODO?: Implement more restrictive type check to ensure these requirements\n        are met\n\n        Arguments:\n            name {str} -- Name/identifier of the dialog in question\n            creator {Union[Callable, type]} -- A class or function to create new\n                                               dialog instances with\n\n        Keyword Arguments:\n            instance {Optional[Any]} -- An optional existing instance of the dialog\n                                        (default: {None})\n        \"\"\"\n        self._dialogs[name] = [creator, instance]\n\n\ndialogs = DialogManager()\n\n# Language handling\n##########################################################################\n# Qt requires its translator to be installed before any GUI widgets are\n# loaded, and we need the Qt language to match the i18n language or\n# translated shortcuts will not work.\n\n# A reference to the Qt translator needs to be held to prevent it from\n# being immediately deallocated.\n_qtrans: QTranslator | None = None\n\n\ndef setupLangAndBackend(\n    pm: ProfileManager,\n    app: QApplication,\n    force: str | None = None,\n    firstTime: bool = False,\n) -> RustBackend:\n    global _qtrans\n    try:\n        locale.setlocale(locale.LC_ALL, \"\")\n    except Exception:\n        pass\n\n    # add _ and ngettext globals used by legacy code\n    def fn__(arg) -> None:  # type: ignore\n        print(\"\".join(traceback.format_stack()[-2]))\n        print(\"_ global will break in the future; please see anki/lang.py\")\n        return arg\n\n    def fn_ngettext(a, b, c) -> None:  # type: ignore\n        print(\"\".join(traceback.format_stack()[-2]))\n        print(\"ngettext global will break in the future; please see anki/lang.py\")\n        return b\n\n    builtins.__dict__[\"_\"] = fn__\n    builtins.__dict__[\"ngettext\"] = fn_ngettext\n\n    # get lang and normalize into ja/zh-CN form\n    if firstTime:\n        lang = pm.meta[\"defaultLang\"]\n    else:\n        lang = force or pm.meta[\"defaultLang\"]\n    lang = anki.lang.lang_to_disk_lang(lang)\n\n    # set active language\n    anki.lang.set_lang(lang)\n\n    # switch direction for RTL languages\n    if anki.lang.is_rtl(lang):\n        app.setLayoutDirection(Qt.LayoutDirection.RightToLeft)\n    else:\n        app.setLayoutDirection(Qt.LayoutDirection.LeftToRight)\n\n    # load qt translations\n    _qtrans = QTranslator()\n\n    qt_dir = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)\n    qt_lang = lang.replace(\"-\", \"_\")\n    if _qtrans.load(f\"qtbase_{qt_lang}\", qt_dir):\n        app.installTranslator(_qtrans)\n\n    backend = anki.lang.current_i18n\n    assert backend is not None\n\n    return backend\n\n\n# App initialisation\n##########################################################################\n\n\nclass NativeEventFilter(QAbstractNativeEventFilter):\n    def nativeEventFilter(\n        self, eventType: Any, message: Any\n    ) -> tuple[bool, Any | None]:\n        if eventType == \"windows_generic_MSG\":\n            import ctypes.wintypes\n\n            msg = ctypes.wintypes.MSG.from_address(int(message))\n            if msg.message == 17:  # WM_QUERYENDSESSION\n                assert mw is not None\n                if mw.can_auto_sync():\n                    mw.app._set_windows_shutdown_block_reason(tr.sync_syncing())\n                    mw.progress.single_shot(100, mw.unloadProfileAndExit)\n                    return (True, 0)\n        return (False, 0)\n\n\nclass AnkiApp(QApplication):\n    # Single instance support on Win32/Linux\n    ##################################################\n\n    appMsg = pyqtSignal(str)\n\n    KEY = f\"anki{checksum(getpass.getuser())}\"\n    TMOUT = 30000\n\n    def __init__(self, argv: list[str]) -> None:\n        QApplication.__init__(self, argv)\n        self.installEventFilter(self)\n        self._argv = argv\n        self._native_event_filter = NativeEventFilter()\n        if is_win:\n            self.installNativeEventFilter(self._native_event_filter)\n\n    def _set_windows_shutdown_block_reason(self, reason: str) -> None:\n        if is_win:\n            import ctypes\n            from ctypes import windll, wintypes  # type: ignore\n\n            assert mw is not None\n            windll.user32.ShutdownBlockReasonCreate(\n                wintypes.HWND.from_param(int(mw.effectiveWinId())),\n                ctypes.c_wchar_p(reason),\n            )\n\n    def _unset_windows_shutdown_block_reason(self) -> None:\n        if is_win:\n            from ctypes import windll, wintypes  # type: ignore\n\n            assert mw is not None\n            windll.user32.ShutdownBlockReasonDestroy(\n                wintypes.HWND.from_param(int(mw.effectiveWinId())),\n            )\n\n    def secondInstance(self) -> bool:\n        # we accept only one command line argument. if it's missing, send\n        # a blank screen to just raise the existing window\n        opts, args = parseArgs(self._argv)\n        buf = \"raise\"\n        if args and args[0]:\n            buf = os.path.abspath(args[0])\n        if self.sendMsg(buf):\n            print(\"Already running; reusing existing instance.\")\n            return True\n        else:\n            # send failed, so we're the first instance or the\n            # previous instance died\n            QLocalServer.removeServer(self.KEY)\n            self._srv = QLocalServer(self)\n            qconnect(self._srv.newConnection, self.onRecv)\n            self._srv.listen(self.KEY)\n            return False\n\n    def sendMsg(self, txt: str) -> bool:\n        sock = QLocalSocket(self)\n        sock.connectToServer(self.KEY, QIODevice.OpenModeFlag.WriteOnly)\n        if not sock.waitForConnected(self.TMOUT):\n            # first instance or previous instance dead\n            return False\n        sock.write(txt.encode(\"utf8\"))\n        if not sock.waitForBytesWritten(self.TMOUT):\n            # existing instance running but hung\n            QMessageBox.warning(\n                None,\n                tr.qt_misc_anki_is_running(),\n                tr.qt_misc_if_instance_is_not_responding(),\n            )\n\n            sys.exit(1)\n        sock.disconnectFromServer()\n        return True\n\n    def onRecv(self) -> None:\n        sock = self._srv.nextPendingConnection()\n        if sock is None:\n            return\n        if not sock.waitForReadyRead(self.TMOUT):\n            sys.stderr.write(sock.errorString())\n            return\n        path = bytes(cast(bytes, sock.readAll())).decode(\"utf8\")\n        self.appMsg.emit(path)  # type: ignore\n        sock.disconnectFromServer()\n\n    # OS X file/url handler\n    ##################################################\n\n    def event(self, evt: QEvent | None) -> bool:\n        assert evt is not None\n\n        if evt.type() == QEvent.Type.FileOpen:\n            self.appMsg.emit(evt.file() or \"raise\")  # type: ignore\n            return True\n        return QApplication.event(self, evt)\n\n    # Global cursor: pointer for Qt buttons\n    ##################################################\n\n    def eventFilter(self, src: Any, evt: QEvent | None) -> bool:\n        assert evt is not None\n\n        # Handle Close shortcut here because modal dialogs disable main-window shortcuts\n        if (is_mac or is_lin) and evt.type() == QEvent.Type.KeyPress:\n            key_event = cast(QKeyEvent, evt)\n            if not key_event.isAutoRepeat():\n                mods = cast(int, key_event.modifiers().value)\n                seq = QKeySequence(mods | key_event.key())\n                if any(\n                    seq == binding\n                    for binding in QKeySequence.keyBindings(\n                        QKeySequence.StandardKey.Close\n                    )\n                ):\n                    if mw is not None:\n                        mw._close_active_window()\n                    return True\n\n        pointer_classes = (\n            QPushButton,\n            QCheckBox,\n            QRadioButton,\n            QMenu,\n            QSlider,\n            QToolButton,\n            QTabBar,\n        )\n        if evt.type() in [QEvent.Type.Enter, QEvent.Type.HoverEnter]:\n            if (isinstance(src, pointer_classes) and src.isEnabled()) or (\n                isinstance(src, QComboBox) and not src.isEditable()\n            ):\n                self.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor))\n            else:\n                self.restoreOverrideCursor()\n            return False\n\n        elif evt.type() in [QEvent.Type.HoverLeave, QEvent.Type.Leave] or isinstance(\n            evt, QCloseEvent\n        ):\n            self.restoreOverrideCursor()\n            return False\n\n        return False\n\n\ndef parseArgs(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:\n    \"Returns (opts, args).\"\n    # py2app fails to strip this in some instances, then anki dies\n    # as there's no such profile\n    if is_mac and len(argv) > 1 and argv[1].startswith(\"-psn\"):\n        argv = [argv[0]]\n    parser = argparse.ArgumentParser(description=f\"Anki {appVersion}\")\n    parser.usage = \"%(prog)s [OPTIONS] [file to import/add-on to install]\"\n    parser.add_argument(\"-b\", \"--base\", help=\"path to base folder\", default=\"\")\n    parser.add_argument(\"-p\", \"--profile\", help=\"profile name to load\", default=\"\")\n    parser.add_argument(\"-l\", \"--lang\", help=\"interface language (en, de, etc)\")\n    parser.add_argument(\n        \"-v\", \"--version\", help=\"print the Anki version and exit\", action=\"store_true\"\n    )\n    parser.add_argument(\n        \"--safemode\", help=\"disable add-ons and automatic syncing\", action=\"store_true\"\n    )\n    parser.add_argument(\n        \"--syncserver\",\n        help=\"skip GUI and start a local sync server\",\n        action=\"store_true\",\n    )\n    return parser.parse_known_args(argv[1:])\n\n\ndef setupGL(pm: aqt.profiles.ProfileManager) -> None:\n    driver = pm.video_driver()\n    # RHI errors are emitted multiple times so make sure we only handle them once\n    driver_failed = False\n\n    # work around pyqt loading wrong GL library\n    if is_lin and not sys.platform.startswith(\"freebsd\"):\n        import ctypes\n\n        ctypes.CDLL(\"libGL.so.1\", ctypes.RTLD_GLOBAL)\n\n    # catch opengl errors\n    def msgHandler(category: Any, ctx: Any, msg: Any) -> None:\n        if category == QtMsgType.QtDebugMsg:\n            category = \"debug\"\n        elif category == QtMsgType.QtInfoMsg:\n            category = \"info\"\n        elif category == QtMsgType.QtWarningMsg:\n            category = \"warning\"\n        elif category == QtMsgType.QtCriticalMsg:\n            category = \"critical\"\n        elif category == QtMsgType.QtDebugMsg:\n            category = \"debug\"\n        elif category == QtMsgType.QtFatalMsg:\n            category = \"fatal\"\n        else:\n            category = \"unknown\"\n        context = \"\"\n        if ctx.file:\n            context += f\"{ctx.file}:\"\n        if ctx.line:\n            context += f\"{ctx.line},\"\n        if ctx.function:\n            context += f\"{ctx.function}\"\n        if context:\n            context = f\"'{context}'\"\n\n        nonlocal driver_failed\n        if not driver_failed and (\n            \"Failed to create OpenGL context\" in msg\n            # Based on the message Qt6 shows to the user; have not tested whether\n            # we can actually capture this or not.\n            or \"Failed to initialize graphics backend\" in msg\n            # RHI backend\n            or \"Failed to create QRhi\" in msg\n            or \"Failed to get a QRhi\" in msg\n        ):\n            QMessageBox.critical(\n                None,\n                tr.qt_misc_error(),\n                tr.qt_misc_error_loading_graphics_driver(\n                    mode=driver.value,\n                    context=context,\n                ),\n            )\n            pm.set_video_driver(driver.next())\n            driver_failed = True\n            return\n        else:\n            print(f\"Qt {category}: {msg} {context}\")\n\n    qInstallMessageHandler(msgHandler)\n    atexit.register(qInstallMessageHandler, None)\n\n    if driver == VideoDriver.OpenGL:\n        # Leaving QT_OPENGL unset appears to sometimes produce different results\n        # to explicitly setting it to 'auto'; the former seems to be more compatible.\n        if qtmajor > 5:\n            QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.OpenGL)\n    elif driver in (VideoDriver.Software, VideoDriver.ANGLE):\n        if is_win:\n            # on Windows, this appears to be sufficient\n            # On Qt6, ANGLE is excluded by the enum.\n            os.environ[\"QT_OPENGL\"] = driver.value\n        elif is_mac:\n            QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL)\n        elif is_lin:\n            if \"QTWEBENGINE_CHROMIUM_FLAGS\" not in os.environ:\n                os.environ[\"QTWEBENGINE_CHROMIUM_FLAGS\"] = \"--disable-gpu\"\n        if qtmajor > 5:\n            QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Software)\n    elif driver == VideoDriver.Metal:\n        QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Metal)\n    elif driver == VideoDriver.Vulkan:\n        QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Vulkan)\n    elif driver == VideoDriver.Direct3D:\n        QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Direct3D11)\n\n\nPROFILE_CODE = os.environ.get(\"ANKI_PROFILE_CODE\")\n\n\ndef write_profile_results() -> None:\n    assert profiler is not None\n\n    profiler.disable()\n    profile = \"out/anki.prof\"\n    profiler.dump_stats(profile)\n\n\ndef run() -> None:\n    print(f\"Starting Anki {_version}...\")\n    try:\n        _run()\n    except Exception:\n        traceback.print_exc()\n        QMessageBox.critical(\n            None,\n            \"Startup Error\",\n            f\"Please notify support of this error:\\n\\n{traceback.format_exc()}\",\n        )\n\n\ndef _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None:\n    \"\"\"Start AnkiQt application or reuse an existing instance if one exists.\n\n    If the function is invoked with exec=False, the AnkiQt will not enter\n    the main event loop - instead the application object will be returned.\n\n    The 'exec' and 'argv' arguments will be useful for testing purposes.\n\n    If no 'argv' is supplied then 'sys.argv' will be used.\n    \"\"\"\n    global mw\n    global profiler\n\n    if argv is None:\n        argv = sys.argv\n\n    # parse args\n    opts, args = parseArgs(argv)\n\n    if opts.version:\n        print(f\"Anki {appVersion}\")\n        return None\n\n    if PROFILE_CODE:\n        profiler = cProfile.Profile()\n        profiler.enable()\n\n    x11_available = os.getenv(\"DISPLAY\")\n    wayland_configured = qtmajor > 5 and (\n        os.getenv(\"QT_QPA_PLATFORM\") == \"wayland\" or os.getenv(\"WAYLAND_DISPLAY\")\n    )\n    wayland_forced = os.getenv(\"ANKI_WAYLAND\")\n\n    if is_gnome and wayland_configured:\n        if wayland_forced or not x11_available:\n            # Work around broken fractional scaling in Wayland\n            # https://bugreports.qt.io/browse/QTBUG-113574\n            os.environ[\"QT_SCALE_FACTOR_ROUNDING_POLICY\"] = \"RoundPreferFloor\"\n            if not x11_available:\n                print(\n                    \"Trying to use X11, but it is not available. Falling back to Wayland, which has some bugs:\"\n                )\n                print(\"https://github.com/ankitects/anki/issues/1767\")\n        else:\n            # users need to opt in to wayland support, given the issues it has\n            print(\"Wayland support is disabled by default due to bugs:\")\n            print(\"https://github.com/ankitects/anki/issues/1767\")\n            print(\"You can force it on with an env var: ANKI_WAYLAND=1\")\n            os.environ[\"QT_QPA_PLATFORM\"] = \"xcb\"\n\n    # profile manager\n    i18n_setup = False\n    pm = None\n    try:\n        base_folder = ProfileManager.get_created_base_folder(opts.base)\n\n        # default to specified/system language before getting user's preference so that we can localize some more strings\n        lang = anki.lang.get_def_lang(opts.lang)\n        anki.lang.set_lang(lang[1])\n        i18n_setup = True\n\n        pm = ProfileManager(base_folder)\n        pmLoadResult = pm.setupMeta()\n\n        Collection.initialize_backend_logging()\n    except Exception:\n        # will handle below\n        traceback.print_exc()\n        pm = None\n\n    if pm:\n        # gl workarounds\n        setupGL(pm)\n        # apply user-provided scale factor\n        os.environ[\"QT_SCALE_FACTOR\"] = str(pm.uiScale())\n\n    # Opt-in to full HiDPI support?\n    if not os.environ.get(\"ANKI_NOHIGHDPI\") and qtmajor == 5:\n        QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling)  # type: ignore\n        QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)  # type: ignore\n        os.environ[\"QT_ENABLE_HIGHDPI_SCALING\"] = \"1\"\n        os.environ[\"QT_SCALE_FACTOR_ROUNDING_POLICY\"] = \"PassThrough\"\n\n    # Opt-in to software rendering?\n    if os.environ.get(\"ANKI_SOFTWAREOPENGL\"):\n        QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL)\n\n    # Fix an issue on Windows, where Ctrl+Alt shortcuts are triggered by AltGr,\n    # preventing users from typing things like \"@\" through AltGr+Q on a German\n    # keyboard.\n    if is_win and \"QT_QPA_PLATFORM\" not in os.environ:\n        os.environ[\"QT_QPA_PLATFORM\"] = \"windows:altgr\"\n\n    # create the app\n    QCoreApplication.setApplicationName(\"Anki\")\n    QGuiApplication.setDesktopFileName(\"anki\")\n    app = AnkiApp(argv)\n    if app.secondInstance():\n        # we've signaled the primary instance, so we should close\n        return None\n\n    if not pm:\n        if i18n_setup:\n            QMessageBox.critical(\n                None,\n                tr.qt_misc_error(),\n                tr.profiles_could_not_create_data_folder(),\n            )\n        else:\n            QMessageBox.critical(None, \"Startup Failed\", \"Unable to create data folder\")\n        return None\n\n    setup_logging(\n        pm.addon_logs(),\n        level=logging.DEBUG if int(os.getenv(\"ANKIDEV\", \"0\")) else logging.INFO,\n    )\n\n    # disable icons on mac; this must be done before window created\n    if is_mac:\n        app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus)\n\n    # disable help button in title bar on qt versions that support it\n    if is_win and qtmajor == 5 and qtminor >= 10:\n        QApplication.setAttribute(Qt.AA_DisableWindowContextHelpButton)  # type: ignore\n\n    # proxy configured?\n    from urllib.request import getproxies, proxy_bypass\n\n    disable_proxies = False\n    try:\n        if \"http\" in getproxies():\n            # if it's not set up to bypass localhost, we'll\n            # need to disable proxies in the webviews\n            if not proxy_bypass(\"127.0.0.1\"):\n                disable_proxies = True\n    except UnicodeDecodeError:\n        # proxy_bypass can't handle unicode in hostnames; assume we need\n        # to disable proxies\n        disable_proxies = True\n\n    if disable_proxies:\n        print(\"webview proxy use disabled\")\n        proxy = QNetworkProxy()\n        proxy.setType(QNetworkProxy.ProxyType.NoProxy)\n        QNetworkProxy.setApplicationProxy(proxy)\n\n    # we must have a usable temp dir\n    try:\n        tempfile.gettempdir()\n    except Exception:\n        QMessageBox.critical(\n            None,\n            tr.qt_misc_error(),\n            tr.qt_misc_no_temp_folder(),\n        )\n        return None\n\n    # make image resources available\n    from aqt.utils import aqt_data_folder\n\n    QDir.addSearchPath(\"icons\", os.path.join(aqt_data_folder(), \"qt\", \"icons\"))\n\n    if pmLoadResult.firstTime:\n        pm.setDefaultLang(lang[0])\n\n    if pmLoadResult.loadError:\n        QMessageBox.warning(\n            None,\n            tr.profiles_prefs_corrupt_title(),\n            tr.profiles_prefs_file_is_corrupt(),\n        )\n\n    if opts.profile:\n        pm.openProfile(opts.profile)\n\n    # i18n & backend\n    backend = setupLangAndBackend(pm, app, opts.lang, pmLoadResult.firstTime)\n\n    driver = pm.video_driver()\n    if is_lin and driver == VideoDriver.OpenGL:\n        from aqt.utils import gfxDriverIsBroken\n\n        if gfxDriverIsBroken():\n            pm.set_video_driver(driver.next())\n            QMessageBox.critical(\n                None,\n                tr.qt_misc_error(),\n                tr.qt_misc_incompatible_video_driver(),\n            )\n            sys.exit(1)\n\n    # load the main window\n    import aqt.main\n\n    mw = aqt.main.AnkiQt(app, pm, backend, opts, args)\n    if exec:\n        print(\"Starting main loop...\")\n        app.exec()\n    else:\n        return app\n\n    if PROFILE_CODE:\n        write_profile_results()\n\n    return None\n"
  },
  {
    "path": "qt/aqt/_macos_helper.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport sys\n\nif sys.platform == \"darwin\":\n    from anki_mac_helper import macos_helper\nelse:\n    macos_helper = None\n"
  },
  {
    "path": "qt/aqt/about.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport platform\nfrom collections.abc import Callable\n\nimport aqt.forms\nfrom anki.lang import without_unicode_isolation\nfrom anki.utils import version_with_build\nfrom aqt.errors import addon_debug_info\nfrom aqt.qt import *\nfrom aqt.utils import disable_help_button, supportText, tooltip, tr\n\n\nclass ClosableQDialog(QDialog):\n    def reject(self) -> None:\n        aqt.dialogs.markClosed(\"About\")\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        aqt.dialogs.markClosed(\"About\")\n        QDialog.accept(self)\n\n    def closeWithCallback(self, callback: Callable[[], None]) -> None:\n        self.reject()\n        callback()\n\n\ndef show(mw: aqt.AnkiQt) -> QDialog:\n    dialog = ClosableQDialog(mw)\n    disable_help_button(dialog)\n    mw.garbage_collect_on_dialog_finish(dialog)\n    abt = aqt.forms.about.Ui_About()\n    abt.setupUi(dialog)\n\n    def on_copy() -> None:\n        txt = supportText()\n        if mw.addonManager.dirty:\n            txt += \"\\n\" + addon_debug_info()\n        clipboard = QApplication.clipboard()\n        assert clipboard is not None\n        clipboard.setText(txt)\n        tooltip(tr.about_copied_to_clipboard(), parent=dialog)\n\n    btn = QPushButton(tr.about_copy_debug_info())\n    qconnect(btn.clicked, on_copy)\n    abt.buttonBox.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)\n\n    ok_button = abt.buttonBox.button(QDialogButtonBox.StandardButton.Ok)\n    assert ok_button is not None\n    ok_button.setFocus()\n\n    btnLayout = abt.buttonBox.layout()\n    assert btnLayout is not None\n    btnLayout.setContentsMargins(12, 12, 12, 12)\n\n    # WebView cleanup\n    ######################################################################\n\n    def on_dialog_destroyed() -> None:\n        abt.label.cleanup()\n        abt.label = None  # type: ignore\n\n    qconnect(dialog.destroyed, on_dialog_destroyed)\n\n    # WebView contents\n    ######################################################################\n    abouttext = \"<center><img src='/_anki/imgs/anki-logo-thin.png'></center>\"\n    lede = tr.about_anki_is_a_friendly_intelligent_spaced().replace(\"Anki\", \"Anki®\")\n    abouttext += f\"<p>{lede}\"\n    abouttext += f\"<p>{tr.about_anki_is_licensed_under_the_agpl3()}\"\n    abouttext += f\"<p>{tr.about_version(val=version_with_build())}<br>\"\n    abouttext += (\"Python %s Qt %s Chromium %s<br>\") % (\n        platform.python_version(),\n        qVersion(),\n        (qWebEngineChromiumVersion() or \"\").split(\".\")[0],\n    )\n    abouttext += (\n        without_unicode_isolation(tr.about_visit_website(val=aqt.appWebsite))\n        + \"</span>\"\n    )\n\n    # Automatically sorted; add new lines at the end.\n    # This is a list of users who want to appear in the dialog, and includes people who have\n    # contributed in non-code ways, like providing support on the forums, so it cannot be\n    # generated from the CONTRIBUTORS file.\n    allusers = sorted(\n        (\n            \"Aaron Harsh\",\n            \"Alex Fraser\",\n            \"Andreas Klauer\",\n            \"Andrew Wright\",\n            \"Aristotelis P.\",\n            \"Ben Nguyen\",\n            \"Bernhard Ibertsberger\",\n            \"C. van Rooyen\",\n            \"Cenaris Mori\",\n            \"Charlene Barina\",\n            \"Christian Krause\",\n            \"Christian Rusche\",\n            \"Dave Druelinger\",\n            \"David Culley\",\n            \"David Smith\",\n            \"Dmitry Mikheev\",\n            \"Dotan Cohen\",\n            \"Emilio Wuerges\",\n            \"Emmanuel Jarri\",\n            \"Frank Harper\",\n            \"Gregor Skumavc\",\n            \"Guillem Palau Salvà\",\n            \"H. Mijail\",\n            \"Henrik Enggaard Hansen\",\n            \"Houssam Salem\",\n            \"Ian Lewis\",\n            \"Immanuel Asmus\",\n            \"Iroiro\",\n            \"Jarvik7\",\n            \"Jin Eun-Deok\",\n            \"Jo Nakashima\",\n            \"Johanna Lindh\",\n            \"Joseph Lorimer\",\n            \"Julien Baley\",\n            \"Jussi Määttä\",\n            \"Kieran Clancy\",\n            \"LaC\",\n            \"Laurent Steffan\",\n            \"Luca Ban\",\n            \"Luciano Esposito\",\n            \"Marco Giancotti\",\n            \"Marcus Rubeus\",\n            \"Mari Egami\",\n            \"Mark Wilbur\",\n            \"Matthew Duggan\",\n            \"Matthew Holtz\",\n            \"Meelis Vasser\",\n            \"Michael Jürges\",\n            \"Michael Keppler\",\n            \"Michael Montague\",\n            \"Michael Penkov\",\n            \"Michal Čadil\",\n            \"Morteza Salehi\",\n            \"Nathanael Law\",\n            \"Nguyễn Hào Khôi\",\n            \"Nick Cook\",\n            \"Niklas Laxström\",\n            \"Norbert Nagold\",\n            \"Ole Guldberg\",\n            \"Pcsl88\",\n            \"Petr Michalec\",\n            \"Piotr Kubowicz\",\n            \"Richard Colley\",\n            \"Roland Sieker\",\n            \"Samson Melamed\",\n            \"Silja Ijas\",\n            \"Snezana Lukic\",\n            \"Soren Bjornstad\",\n            \"Stefaan De Pooter\",\n            \"Susanna Björverud\",\n            \"Sylvain Durand\",\n            \"Tacutu\",\n            \"Taylor Obyen\",\n            \"Timm Preetz\",\n            \"Timo Paulssen\",\n            \"Ursus\",\n            \"Victor Suba\",\n            \"Volker Jansen\",\n            \"Volodymyr Goncharenko\",\n            \"Xtru\",\n            \"Ádám Szegi\",\n            \"赵金鹏\",\n            \"黃文龍\",\n            \"Valerie Enfys\",\n            \"Arman High\",\n            \"Arthur Milchior\",\n            \"Rai (Michael Pokorny)\",\n            \"AMBOSS MD Inc.\",\n            \"Erez Volk\",\n            \"Tobias Predel\",\n            \"Thomas Kahn\",\n            \"zjosua\",\n            \"Ijgnd\",\n            \"Evandro Coan\",\n            \"Alan Du\",\n            \"Abdo\",\n            \"Junseo Park\",\n            \"Gustavo Costa\",\n            \"余时行\",\n            \"叶峻峣\",\n            \"RumovZ\",\n            \"学习骇客\",\n            \"ready-research\",\n            \"Henrik Giesel\",\n            \"Yoonchae Lee\",\n            \"Hikaru Yoshiga\",\n            \"Matthias Metelka\",\n            \"Sergio Quintero\",\n            \"Nicholas Flint\",\n            \"Daniel Vieira Memoria10X\",\n            \"Luka Warren\",\n            \"Christos Longros\",\n            \"hafatsat anki\",\n            \"Carlos Duarte\",\n            \"Edgar Benavent Català\",\n            \"Kieran Black\",\n            \"Mateusz Wojewoda\",\n            \"Jarrett Ye\",\n            \"Gustavo Sales\",\n            \"Akash Reddy\",\n            \"Marko Sisovic\",\n            \"Lucas Scharenbroch\",\n            \"Antoine Q.\",\n            \"Ian Samir Yep Manzano\",\n            \"Asuka Minato\",\n            \"Eros Cardoso\",\n            \"Gregory Abrasaldo\",\n            \"Danika_Dakika\",\n            \"Marcelo Vasconcelos\",\n            \"Mumtaz Hajjo Alrifai\",\n            \"Luc Mcgrady\",\n            \"Brayan Oliveira\",\n            \"Market345\",\n            \"Yuki\",\n            \"🦙 (siid)\",\n            \"Mukunda Madhav Dey\",\n            \"Adnane Taghi\",\n            \"Anon_0000\",\n            \"Bilolbek Normuminov\",\n            \"Sagiv Marzini\",\n            \"Zhanibek Rassululy\",\n        )\n    )\n\n    allusers = [user.replace(\" \", \"&nbsp;\") for user in allusers]\n    abouttext += \"<p>\" + tr.about_written_by_damien_elmes_with_patches(\n        cont=\", \".join(allusers) + f\", {tr.about_and_others()}\"\n    )\n    abouttext += f\"<p>{tr.about_if_you_have_contributed_and_are()}\"\n    abouttext += f\"<p>{tr.about_a_big_thanks_to_all_the()}\"\n    abt.label.setMinimumWidth(800)\n    abt.label.setMinimumHeight(600)\n    dialog.show()\n    abt.label.stdHtml(abouttext, js=[])\n    return dialog\n"
  },
  {
    "path": "qt/aqt/addcards.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nimport aqt.editor\nimport aqt.forms\nfrom anki._legacy import deprecated\nfrom anki.collection import OpChanges, OpChangesWithCount, SearchNode\nfrom anki.decks import DeckId\nfrom anki.models import NotetypeId\nfrom anki.notes import Note, NoteFieldsCheckResult, NoteId\nfrom anki.utils import html_to_text_line, is_mac\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.deckchooser import DeckChooser\nfrom aqt.notetypechooser import NotetypeChooser\nfrom aqt.operations.note import add_note\nfrom aqt.qt import *\nfrom aqt.sound import av_player\nfrom aqt.utils import (\n    HelpPage,\n    ask_user_dialog,\n    askUser,\n    downArrow,\n    openHelp,\n    restoreGeom,\n    saveGeom,\n    shortcut,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass AddCards(QMainWindow):\n    def __init__(self, mw: AnkiQt) -> None:\n        super().__init__(None, Qt.WindowType.Window)\n        self._close_event_has_cleaned_up = False\n        self.mw = mw\n        self.col = mw.col\n        form = aqt.forms.addcards.Ui_Dialog()\n        form.setupUi(self)\n        self.form = form\n        self.setWindowTitle(tr.actions_add())\n        self.setMinimumHeight(300)\n        self.setMinimumWidth(400)\n        self.setup_choosers()\n        self.setupEditor()\n        self._load_new_note()\n        self.setupButtons()\n        self.history: list[NoteId] = []\n        self._last_added_note: Note | None = None\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n        restoreGeom(self, \"add\")\n        gui_hooks.add_cards_did_init(self)\n        if not is_mac:\n            self.setMenuBar(None)\n        self.show()\n\n    def set_deck(self, deck_id: DeckId) -> None:\n        self.deck_chooser.selected_deck_id = deck_id\n\n    def set_note_type(self, note_type_id: NotetypeId) -> None:\n        self.notetype_chooser.selected_notetype_id = note_type_id\n\n    def set_note(self, note: Note, deck_id: DeckId | None = None) -> None:\n        \"\"\"Set tags, field contents and notetype according to `note`. Deck is set\n        to `deck_id` or the deck last used with the notetype.\n        \"\"\"\n        self.notetype_chooser.selected_notetype_id = note.mid\n        if deck_id or (deck_id := self.col.default_deck_for_notetype(note.mid)):\n            self.deck_chooser.selected_deck_id = deck_id\n\n        new_note = self._new_note()\n        new_note.fields = note.fields[:]\n        new_note.tags = note.tags[:]\n\n        self.editor.orig_note_id = note.id\n        self.setAndFocusNote(new_note)\n\n    def setupEditor(self) -> None:\n        self.editor = aqt.editor.Editor(\n            self.mw,\n            self.form.fieldsArea,\n            self,\n            editor_mode=aqt.editor.EditorMode.ADD_CARDS,\n        )\n\n    def setup_choosers(self) -> None:\n        defaults = self.col.defaults_for_adding(\n            current_review_card=self.mw.reviewer.card\n        )\n\n        self.notetype_chooser = NotetypeChooser(\n            mw=self.mw,\n            widget=self.form.modelArea,\n            starting_notetype_id=NotetypeId(defaults.notetype_id),\n            on_button_activated=self.show_notetype_selector,\n            on_notetype_changed=self.on_notetype_change,\n        )\n        self.deck_chooser = DeckChooser(\n            self.mw,\n            self.form.deckArea,\n            starting_deck_id=DeckId(defaults.deck_id),\n            on_deck_changed=self.on_deck_changed,\n        )\n\n    def reopen(self, mw: AnkiQt) -> None:\n        if not self.editor.fieldsAreBlank():\n            return\n\n        defaults = self.col.defaults_for_adding(\n            current_review_card=self.mw.reviewer.card\n        )\n        self.set_note_type(NotetypeId(defaults.notetype_id))\n        self.set_deck(DeckId(defaults.deck_id))\n\n    def helpRequested(self) -> None:\n        openHelp(HelpPage.ADDING_CARD_AND_NOTE)\n\n    def setupButtons(self) -> None:\n        bb = self.form.buttonBox\n        ar = QDialogButtonBox.ButtonRole.ActionRole\n        # add\n        self.addButton = bb.addButton(tr.actions_add(), ar)\n        qconnect(self.addButton.clicked, self.add_current_note)\n        self.addButton.setShortcut(QKeySequence(\"Ctrl+Return\"))\n        # qt5.14+ doesn't handle numpad enter on Windows\n        self.compat_add_shorcut = QShortcut(QKeySequence(\"Ctrl+Enter\"), self)\n        qconnect(self.compat_add_shorcut.activated, self.addButton.click)\n        self.addButton.setToolTip(shortcut(tr.adding_add_shortcut_ctrlandenter()))\n\n        # close\n        self.closeButton = QPushButton(tr.actions_close())\n        self.closeButton.setAutoDefault(False)\n        bb.addButton(self.closeButton, QDialogButtonBox.ButtonRole.RejectRole)\n        qconnect(self.closeButton.clicked, self.close)\n        # help\n        self.helpButton = QPushButton(tr.actions_help(), clicked=self.helpRequested)  # type: ignore\n        self.helpButton.setAutoDefault(False)\n        bb.addButton(self.helpButton, QDialogButtonBox.ButtonRole.HelpRole)\n        # history\n        b = bb.addButton(f\"{tr.adding_history()} {downArrow()}\", ar)\n        if is_mac:\n            sc = \"Ctrl+Shift+H\"\n        else:\n            sc = \"Ctrl+H\"\n        b.setShortcut(QKeySequence(sc))\n        b.setToolTip(tr.adding_shortcut(val=shortcut(sc)))\n        qconnect(b.clicked, self.onHistory)\n        b.setEnabled(False)\n        self.historyButton = b\n\n    def setAndFocusNote(self, note: Note) -> None:\n        self.editor.set_note(note, focusTo=0)\n\n    def show_notetype_selector(self) -> None:\n        self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype)\n\n    def on_deck_changed(self, deck_id: int) -> None:\n        gui_hooks.add_cards_did_change_deck(deck_id)\n\n    def on_notetype_change(\n        self, notetype_id: NotetypeId, update_deck: bool = True\n    ) -> None:\n        # need to adjust current deck?\n        if update_deck:\n            if deck_id := self.col.default_deck_for_notetype(notetype_id):\n                self.deck_chooser.selected_deck_id = deck_id\n\n        # only used for detecting changed sticky fields on close\n        self._last_added_note = None\n\n        # copy fields into new note with the new notetype\n        old_note = self.editor.note\n        new_note = self._new_note()\n        if old_note:\n            old_field_names = list(old_note.keys())\n            new_field_names = list(new_note.keys())\n            copied_field_names = set()\n            for f in new_note.note_type()[\"flds\"]:\n                field_name = f[\"name\"]\n                # copy identical non-empty fields\n                if field_name in old_field_names and old_note[field_name]:\n                    new_note[field_name] = old_note[field_name]\n                    copied_field_names.add(field_name)\n            new_idx = 0\n            for old_idx, old_field_value in enumerate(old_field_names):\n                # skip previously copied identical fields in new note\n                while (\n                    new_idx < len(new_field_names)\n                    and new_field_names[new_idx] in copied_field_names\n                ):\n                    new_idx += 1\n                if new_idx >= len(new_field_names):\n                    break\n                # copy non-empty old fields\n                if (\n                    old_field_value not in copied_field_names\n                    and old_note.fields[old_idx]\n                ):\n                    new_note.fields[new_idx] = old_note.fields[old_idx]\n                    new_idx += 1\n\n            new_note.tags = old_note.tags\n\n        # and update editor state\n        self.editor.note = new_note\n        self.editor.loadNote(\n            focusTo=min(self.editor.last_field_index or 0, len(new_note.fields) - 1)\n        )\n        gui_hooks.addcards_did_change_note_type(\n            self, old_note.note_type(), new_note.note_type()\n        )\n\n    def _load_new_note(self, sticky_fields_from: Note | None = None) -> None:\n        note = self._new_note()\n        if old_note := sticky_fields_from:\n            flds = note.note_type()[\"flds\"]\n            # copy fields from old note\n            if old_note:\n                for n in range(min(len(note.fields), len(old_note.fields))):\n                    if flds[n][\"sticky\"]:\n                        note.fields[n] = old_note.fields[n]\n            # and tags\n            note.tags = old_note.tags\n        self.setAndFocusNote(note)\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        if (changes.notetype or changes.deck) and handler is not self.editor:\n            self.on_notetype_change(\n                NotetypeId(\n                    self.col.defaults_for_adding(\n                        current_review_card=self.mw.reviewer.card\n                    ).notetype_id\n                ),\n                update_deck=False,\n            )\n\n    def _new_note(self) -> Note:\n        return self.col.new_note(\n            self.col.models.get(self.notetype_chooser.selected_notetype_id)\n        )\n\n    def addHistory(self, note: Note) -> None:\n        self.history.insert(0, note.id)\n        self.history = self.history[:15]\n        self.historyButton.setEnabled(True)\n\n    def onHistory(self) -> None:\n        m = QMenu(self)\n        for nid in self.history:\n            if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):\n                note = self.col.get_note(nid)\n                fields = note.fields\n                txt = html_to_text_line(\", \".join(fields))\n                if len(txt) > 30:\n                    txt = f\"{txt[:30]}...\"\n                line = tr.adding_edit(val=txt)\n                line = gui_hooks.addcards_will_add_history_entry(line, note)\n                line = line.replace(\"&\", \"&&\")\n                # In qt action \"&i\" means \"underline i, trigger this line when i is pressed\".\n                # except for \"&&\" which is replaced by a single \"&\"\n                a = m.addAction(line)\n                qconnect(a.triggered, lambda b, nid=nid: self.editHistory(nid))\n            else:\n                a = m.addAction(tr.adding_note_deleted())\n                a.setEnabled(False)\n        gui_hooks.add_cards_will_show_history_menu(self, m)\n        m.exec(self.historyButton.mapToGlobal(QPoint(0, 0)))\n\n    def editHistory(self, nid: NoteId) -> None:\n        aqt.dialogs.open(\"Browser\", self.mw, search=(SearchNode(nid=nid),))\n\n    def add_current_note(self) -> None:\n        if self.editor.current_notetype_is_image_occlusion():\n            self.editor.update_occlusions_field()\n            self.editor.call_after_note_saved(self._add_current_note)\n            self.editor.reset_image_occlusion()\n        else:\n            self.editor.call_after_note_saved(self._add_current_note)\n\n    def _add_current_note(self) -> None:\n        note = self.editor.note\n\n        # Prevent adding a note that has already been added (e.g., from double-clicking)\n        if note.id != 0:\n            return\n\n        if not self._note_can_be_added(note):\n            return\n\n        target_deck_id = self.deck_chooser.selected_deck_id\n\n        def on_success(changes: OpChangesWithCount) -> None:\n            # only used for detecting changed sticky fields on close\n            self._last_added_note = note\n\n            self.addHistory(note)\n\n            tooltip(tr.importing_cards_added(count=changes.count), period=500)\n            av_player.stop_and_clear_queue()\n            self._load_new_note(sticky_fields_from=note)\n            gui_hooks.add_cards_did_add_note(note)\n\n        add_note(parent=self, note=note, target_deck_id=target_deck_id).success(\n            on_success\n        ).run_in_background()\n\n    def _note_can_be_added(self, note: Note) -> bool:\n        result = note.fields_check()\n        # no problem, duplicate, and confirmed cloze cases\n        problem = None\n        if result == NoteFieldsCheckResult.EMPTY:\n            if self.editor.current_notetype_is_image_occlusion():\n                problem = tr.notetypes_no_occlusion_created2()\n            else:\n                problem = tr.adding_the_first_field_is_empty()\n        elif result == NoteFieldsCheckResult.MISSING_CLOZE:\n            if not askUser(tr.adding_you_have_a_cloze_deletion_note()):\n                return False\n        elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:\n            problem = tr.adding_cloze_outside_cloze_notetype()\n        elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:\n            problem = tr.adding_cloze_outside_cloze_field()\n\n        # filter problem through add-ons\n        problem = gui_hooks.add_cards_will_add_note(problem, note)\n        if problem is not None:\n            showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)\n            return False\n\n        optional_problems: list[str] = []\n        gui_hooks.add_cards_might_add_note(optional_problems, note)\n        if not all(askUser(op) for op in optional_problems):\n            return False\n\n        return True\n\n    def keyPressEvent(self, evt: QKeyEvent) -> None:\n        if evt.key() == Qt.Key.Key_Escape:\n            self.close()\n        else:\n            super().keyPressEvent(evt)\n\n    def closeEvent(self, evt: QCloseEvent) -> None:\n        if self._close_event_has_cleaned_up:\n            evt.accept()\n            return\n        self.ifCanClose(self._close)\n        evt.ignore()\n\n    def _close(self) -> None:\n        self.editor.cleanup()\n        self.notetype_chooser.cleanup()\n        self.deck_chooser.cleanup()\n        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)\n        self.mw.maybeReset()\n        saveGeom(self, \"add\")\n        aqt.dialogs.markClosed(\"AddCards\")\n        self._close_event_has_cleaned_up = True\n        self.mw.deferred_delete_and_garbage_collect(self)\n        self.close()\n\n    def ifCanClose(self, onOk: Callable) -> None:\n        def callback(choice: int) -> None:\n            if choice == 0:\n                onOk()\n\n        def afterSave() -> None:\n            if self.editor.fieldsAreBlank(self._last_added_note):\n                return onOk()\n\n            ask_user_dialog(\n                tr.adding_discard_current_input(),\n                callback=callback,\n                buttons=[\n                    QMessageBox.StandardButton.Discard,\n                    (tr.adding_keep_editing(), QMessageBox.ButtonRole.RejectRole),\n                ],\n            )\n\n        self.editor.call_after_note_saved(afterSave)\n\n    def closeWithCallback(self, cb: Callable[[], None]) -> None:\n        def doClose() -> None:\n            self._close()\n            cb()\n\n        self.ifCanClose(doClose)\n\n    # legacy aliases\n\n    @property\n    def deckChooser(self) -> DeckChooser:\n        if getattr(self, \"form\", None):\n            # show this warning only after Qt form has been initialized,\n            # or PyQt's introspection triggers it\n            print(\"deckChooser is deprecated; use deck_chooser instead\")\n        return self.deck_chooser\n\n    addCards = add_current_note\n    _addCards = _add_current_note\n    onModelChange = on_notetype_change\n\n    @deprecated(info=\"obsolete\")\n    def addNote(self, note: Note) -> None:\n        pass\n\n    @deprecated(info=\"does nothing; will go away\")\n    def removeTempNote(self, note: Note) -> None:\n        pass\n"
  },
  {
    "path": "qt/aqt/addons.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport html\nimport io\nimport json\nimport logging\nimport os\nimport re\nimport sys\nimport traceback\nimport zipfile\nfrom collections import defaultdict\nfrom collections.abc import Callable, Iterable, Sequence\nfrom concurrent.futures import Future\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import IO, Any, Union\nfrom urllib.parse import parse_qs, urlparse\nfrom zipfile import ZipFile\n\nimport jsonschema\nimport markdown\nfrom jsonschema.exceptions import ValidationError\nfrom markdown.extensions import md_in_html\n\nimport anki\nimport anki.utils\nimport aqt\nimport aqt.forms\nimport aqt.main\nfrom anki.collection import AddonInfo\nfrom anki.httpclient import HttpClient\nfrom anki.lang import without_unicode_isolation\nfrom anki.utils import int_version_to_str\nfrom aqt import gui_hooks\nfrom aqt.log import ADDON_LOGGER_PREFIX, find_addon_logger, get_addon_logs_folder\nfrom aqt.qt import *\nfrom aqt.utils import (\n    askUser,\n    disable_help_button,\n    getFile,\n    openFolder,\n    openLink,\n    restoreGeom,\n    restoreSplitter,\n    saveGeom,\n    saveSplitter,\n    send_to_trash,\n    show_info,\n    showInfo,\n    showText,\n    showWarning,\n    supportText,\n    tooltip,\n    tr,\n)\n\n\nclass AbortAddonImport(Exception):\n    \"\"\"If raised during add-on import, Anki will silently ignore this exception.\n    This allows you to terminate loading without an error being shown.\"\"\"\n\n\n@dataclass\nclass InstallOk:\n    name: str\n    conflicts: set[str]\n    compatible: bool\n\n\n@dataclass\nclass InstallError:\n    errmsg: str\n\n\n@dataclass\nclass DownloadOk:\n    data: bytes\n    filename: str\n    mod_time: int\n    min_point_version: int\n    max_point_version: int\n    branch_index: int\n\n\n@dataclass\nclass DownloadError:\n    # set if result was not 200\n    status_code: int | None = None\n    # set if an exception occurred\n    exception: Exception | None = None\n\n\n# first arg is add-on id\nDownloadLogEntry = tuple[int, Union[DownloadError, InstallError, InstallOk]]\n\n\nANKIWEB_ID_RE = re.compile(r\"^\\d+$\")\n\n_current_version = anki.utils.int_version()\n\n\n@dataclass\nclass AddonMeta:\n    dir_name: str\n    provided_name: str | None\n    enabled: bool\n    installed_at: int\n    conflicts: list[str]\n    min_version: int\n    max_version: int\n    branch_index: int\n    human_version: str | None\n    update_enabled: bool\n    homepage: str | None\n\n    def human_name(self) -> str:\n        return self.provided_name or self.dir_name\n\n    def ankiweb_id(self) -> int | None:\n        m = ANKIWEB_ID_RE.match(self.dir_name)\n        if m:\n            return int(m.group(0))\n        else:\n            return None\n\n    def compatible(self) -> bool:\n        min = self.min_version\n        if min is not None and _current_version < min:\n            return False\n        max = self.max_version\n        if max is not None and max < 0 and _current_version > abs(max):\n            return False\n        return True\n\n    def is_latest(self, server_update_time: int) -> bool:\n        return self.installed_at >= server_update_time\n\n    def page(self) -> str | None:\n        if self.ankiweb_id():\n            return f\"{aqt.appShared}info/{self.dir_name}\"\n        return self.homepage\n\n    @staticmethod\n    def from_json_meta(dir_name: str, json_meta: dict[str, Any]) -> AddonMeta:\n        return AddonMeta(\n            dir_name=dir_name,\n            provided_name=json_meta.get(\"name\"),\n            enabled=not json_meta.get(\"disabled\"),\n            installed_at=json_meta.get(\"mod\", 0),\n            conflicts=json_meta.get(\"conflicts\", []),\n            min_version=json_meta.get(\"min_point_version\", 0) or 0,\n            max_version=json_meta.get(\"max_point_version\", 0) or 0,\n            branch_index=json_meta.get(\"branch_index\", 0) or 0,\n            human_version=json_meta.get(\"human_version\"),\n            update_enabled=json_meta.get(\"update_enabled\", True),\n            homepage=json_meta.get(\"homepage\"),\n        )\n\n\ndef package_name_valid(name: str) -> bool:\n    # embedded /?\n    base = os.path.basename(name)\n    if base != name:\n        return False\n    # tries to escape to parent?\n    root = os.getcwd()\n    subfolder = os.path.abspath(os.path.join(root, name))\n    if root.startswith(subfolder):\n        return False\n    return True\n\n\n# fixme: this class should not have any GUI code in it\nclass AddonManager:\n    exts: list[str] = [\".ankiaddon\", \".zip\"]\n    _manifest_schema: dict = {\n        \"type\": \"object\",\n        \"properties\": {\n            # the name of the folder\n            \"package\": {\"type\": \"string\", \"minLength\": 1, \"meta\": False},\n            # the displayed name to the user\n            \"name\": {\"type\": \"string\", \"meta\": True},\n            # the time the add-on was last modified\n            \"mod\": {\"type\": \"number\", \"meta\": True},\n            # a list of other packages that conflict\n            \"conflicts\": {\"type\": \"array\", \"items\": {\"type\": \"string\"}, \"meta\": True},\n            # x for anki 2.1.x; int_version() for more recent releases\n            \"min_point_version\": {\"type\": \"number\", \"meta\": True},\n            # x for anki 2.1.x; int_version() for more recent releases\n            # if negative, abs(n) is the maximum version this add-on supports\n            # if positive, indicates version tested on, and is ignored\n            \"max_point_version\": {\"type\": \"number\", \"meta\": True},\n            # AnkiWeb sends this to indicate which branch the user downloaded.\n            \"branch_index\": {\"type\": \"number\", \"meta\": True},\n            # version string set by the add-on creator\n            \"human_version\": {\"type\": \"string\", \"meta\": True},\n            # add-on page on AnkiWeb or some other webpage\n            \"homepage\": {\"type\": \"string\", \"meta\": True},\n        },\n        \"required\": [\"package\", \"name\"],\n    }\n\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        self.mw = mw\n        self.dirty = False\n        f = self.mw.form\n        qconnect(f.actionAdd_ons.triggered, self.onAddonsDialog)\n        sys.path.insert(0, self.addonsFolder())\n\n    # in new code, you may want all_addon_meta() instead\n    def allAddons(self) -> list[str]:\n        l = []\n        for d in os.listdir(self.addonsFolder()):\n            path = self.addonsFolder(d)\n            if not os.path.exists(os.path.join(path, \"__init__.py\")):\n                continue\n            l.append(d)\n        l.sort()\n        if os.getenv(\"ANKIREVADDONS\", \"\"):\n            l = list(reversed(l))\n        return l\n\n    def all_addon_meta(self) -> Iterable[AddonMeta]:\n        return map(self.addon_meta, self.allAddons())\n\n    def addonsFolder(self, module: str | None = None) -> str:\n        root = self.mw.pm.addonFolder()\n        if module is None:\n            return root\n        return os.path.join(root, module)\n\n    def loadAddons(self) -> None:\n        from aqt import mw\n\n        broken: list[str] = []\n        error_text = \"\"\n        for addon in self.all_addon_meta():\n            if not addon.enabled:\n                continue\n            if not addon.compatible():\n                continue\n            self.dirty = True\n            try:\n                __import__(addon.dir_name)\n            except AbortAddonImport:\n                pass\n            except Exception:\n                name = html.escape(addon.human_name())\n                page = addon.page()\n                if page:\n                    broken.append(f\"<a href={page}>{name}</a>\")\n                else:\n                    broken.append(name)\n                tb = traceback.format_exc()\n                print(tb)\n                error_text += f\"When loading {name}:\\n{tb}\\n\"\n\n        if broken:\n            addons = \"\\n\\n- \" + \"\\n- \".join(broken)\n            error = tr.addons_failed_to_load2(\n                addons=addons,\n            )\n            txt = f\"# {tr.addons_startup_failed()}\\n{error}\"\n            html2 = markdown.markdown(txt)\n            box: QDialogButtonBox\n            (diag, box) = showText(\n                html2,\n                type=\"html\",\n                run=False,\n            )\n\n            def on_check() -> None:\n                tooltip(tr.addons_checking())\n\n                def on_done(log: list[DownloadLogEntry]) -> None:\n                    if not log:\n                        tooltip(tr.addons_no_updates_available())\n\n                mw.check_for_addon_updates(by_user=True, on_done=on_done)\n\n            def on_copy() -> None:\n                txt = supportText() + \"\\n\" + error_text\n                QApplication.clipboard().setText(txt)\n                tooltip(tr.about_copied_to_clipboard(), parent=diag)\n\n            check = box.addButton(\n                tr.addons_check_for_updates(), QDialogButtonBox.ButtonRole.ActionRole\n            )\n            check.clicked.connect(on_check)\n\n            copy = box.addButton(\n                tr.about_copy_debug_info(), QDialogButtonBox.ButtonRole.ActionRole\n            )\n            copy.clicked.connect(on_copy)\n\n            # calling show immediately appears to crash\n            mw.progress.single_shot(1000, diag.show)\n\n    def onAddonsDialog(self) -> None:\n        aqt.dialogs.open(\"AddonsDialog\", self)\n\n    # Metadata\n    ######################################################################\n\n    def addon_meta(self, dir_name: str) -> AddonMeta:\n        \"\"\"Get info about an installed add-on.\"\"\"\n        json_obj = self.addonMeta(dir_name)\n        return AddonMeta.from_json_meta(dir_name, json_obj)\n\n    def write_addon_meta(self, addon: AddonMeta) -> None:\n        # preserve any unknown attributes\n        json_obj = self.addonMeta(addon.dir_name)\n\n        if addon.provided_name is not None:\n            json_obj[\"name\"] = addon.provided_name\n        json_obj[\"disabled\"] = not addon.enabled\n        json_obj[\"mod\"] = addon.installed_at\n        json_obj[\"conflicts\"] = addon.conflicts\n        json_obj[\"max_point_version\"] = addon.max_version\n        json_obj[\"min_point_version\"] = addon.min_version\n        json_obj[\"branch_index\"] = addon.branch_index\n        if addon.human_version is not None:\n            json_obj[\"human_version\"] = addon.human_version\n        json_obj[\"update_enabled\"] = addon.update_enabled\n\n        self.writeAddonMeta(addon.dir_name, json_obj)\n\n    def _addonMetaPath(self, module: str) -> str:\n        return os.path.join(self.addonsFolder(module), \"meta.json\")\n\n    # in new code, use self.addon_meta() instead\n    def addonMeta(self, module: str) -> dict[str, Any]:\n        path = self._addonMetaPath(module)\n        try:\n            with open(path, encoding=\"utf8\") as f:\n                return json.load(f)\n        except json.JSONDecodeError as e:\n            print(f\"json error in add-on {module}:\\n{e}\")\n            return dict()\n        except Exception:\n            # missing meta file, etc\n            return dict()\n\n    # in new code, use write_addon_meta() instead\n    def writeAddonMeta(self, module: str, meta: dict[str, Any]) -> None:\n        path = self._addonMetaPath(module)\n        with open(path, \"w\", encoding=\"utf8\") as f:\n            json.dump(meta, f)\n\n    def toggleEnabled(self, module: str, enable: bool | None = None) -> None:\n        addon = self.addon_meta(module)\n        should_enable = enable if enable is not None else not addon.enabled\n        if should_enable is True:\n            conflicting = self._disableConflicting(module)\n            if conflicting:\n                addons = \", \".join(self.addonName(f) for f in conflicting)\n                showInfo(\n                    tr.addons_the_following_addons_are_incompatible_with(\n                        name=addon.human_name(),\n                        found=addons,\n                    ),\n                    textFormat=\"plain\",\n                )\n\n        addon.enabled = should_enable\n        self.write_addon_meta(addon)\n\n    def ankiweb_addons(self) -> list[int]:\n        ids = []\n        for meta in self.all_addon_meta():\n            if meta.ankiweb_id() is not None:\n                ids.append(meta.ankiweb_id())\n        return ids\n\n    # Legacy helpers\n    ######################################################################\n\n    def isEnabled(self, module: str) -> bool:\n        return self.addon_meta(module).enabled\n\n    def addonName(self, module: str) -> str:\n        return self.addon_meta(module).human_name()\n\n    def addonConflicts(self, module: str) -> list[str]:\n        return self.addon_meta(module).conflicts\n\n    def annotatedName(self, module: str) -> str:\n        meta = self.addon_meta(module)\n        name = meta.human_name()\n        if not meta.enabled:\n            name += f\" {tr.addons_disabled()}\"\n        return name\n\n    # Conflict resolution\n    ######################################################################\n\n    def allAddonConflicts(self) -> dict[str, list[str]]:\n        all_conflicts: dict[str, list[str]] = defaultdict(list)\n        for addon in self.all_addon_meta():\n            if not addon.enabled:\n                continue\n            for other_dir in addon.conflicts:\n                all_conflicts[other_dir].append(addon.dir_name)\n        return all_conflicts\n\n    def _disableConflicting(\n        self, module: str, conflicts: list[str] | None = None\n    ) -> set[str]:\n        if not self.isEnabled(module):\n            # disabled add-ons should not trigger conflict handling\n            return set()\n\n        conflicts = conflicts or self.addonConflicts(module)\n\n        installed = self.allAddons()\n        found = {d for d in conflicts if d in installed and self.isEnabled(d)}\n        found.update(self.allAddonConflicts().get(module, []))\n\n        for package in found:\n            self.toggleEnabled(package, enable=False)\n\n        return found\n\n    # Installing and deleting add-ons\n    ######################################################################\n\n    def readManifestFile(self, zfile: ZipFile) -> dict[Any, Any]:\n        try:\n            with zfile.open(\"manifest.json\") as f:\n                data = json.loads(f.read())\n            jsonschema.validate(data, self._manifest_schema)\n            # build new manifest from recognized keys\n            schema = self._manifest_schema[\"properties\"]\n            manifest = {key: data[key] for key in data.keys() & schema.keys()}\n        except (KeyError, json.decoder.JSONDecodeError, ValidationError):\n            # raised for missing manifest, invalid json, missing/invalid keys\n            return {}\n        return manifest\n\n    def install(\n        self,\n        file: IO | str,\n        manifest: dict[str, Any] | None = None,\n        force_enable: bool = False,\n    ) -> InstallOk | InstallError:\n        \"\"\"Install add-on from path or file-like object. Metadata is read\n        from the manifest file, with keys overridden by supplying a 'manifest'\n        dictionary\"\"\"\n        try:\n            zfile = ZipFile(file)\n        except zipfile.BadZipfile:\n            return InstallError(errmsg=\"zip\")\n\n        with zfile:\n            file_manifest = self.readManifestFile(zfile)\n            if manifest:\n                file_manifest.update(manifest)\n            manifest = file_manifest\n            if not manifest:\n                return InstallError(errmsg=\"manifest\")\n            package = manifest[\"package\"]\n            if not package_name_valid(package):\n                return InstallError(errmsg=\"invalid package\")\n            conflicts = manifest.get(\"conflicts\", [])\n            found_conflicts = self._disableConflicting(package, conflicts)\n            meta = self.addonMeta(package)\n            gui_hooks.addon_manager_will_install_addon(self, package)\n            self._install(package, zfile)\n            gui_hooks.addon_manager_did_install_addon(self, package)\n\n        schema = self._manifest_schema[\"properties\"]\n        manifest_meta = {\n            k: v for k, v in manifest.items() if k in schema and schema[k][\"meta\"]\n        }\n        meta.update(manifest_meta)\n\n        if force_enable:\n            meta[\"disabled\"] = False\n\n        self.writeAddonMeta(package, meta)\n\n        meta2 = self.addon_meta(package)\n\n        return InstallOk(\n            name=meta[\"name\"], conflicts=found_conflicts, compatible=meta2.compatible()\n        )\n\n    def _install(self, module: str, zfile: ZipFile) -> None:\n        # previously installed?\n        base = self.addonsFolder(module)\n        if os.path.exists(base):\n            self.backupUserFiles(module)\n            try:\n                self.deleteAddon(module)\n            except Exception:\n                self.restoreUserFiles(module)\n                raise\n        os.mkdir(base)\n        self.restoreUserFiles(module)\n\n        # extract\n        for n in zfile.namelist():\n            if n.endswith(\"/\"):\n                # folder; ignore\n                continue\n\n            path = os.path.join(base, n)\n            # skip existing user files\n            if os.path.exists(path) and n.startswith(\"user_files/\"):\n                continue\n            zfile.extract(n, base)\n\n    def deleteAddon(self, module: str) -> None:\n        send_to_trash(Path(self.addonsFolder(module)))\n\n    # Processing local add-on files\n    ######################################################################\n\n    def processPackages(\n        self,\n        paths: list[str],\n        parent: QWidget | None = None,\n        force_enable: bool = False,\n    ) -> tuple[list[str], list[str]]:\n        log = []\n        errs = []\n\n        self.mw.progress.start(parent=parent)\n        try:\n            for path in paths:\n                base = os.path.basename(path)\n                result = self.install(path, force_enable=force_enable)\n\n                if isinstance(result, InstallError):\n                    errs.extend(\n                        self._installationErrorReport(result, base, mode=\"local\")\n                    )\n                else:\n                    log.extend(\n                        self._installationSuccessReport(result, base, mode=\"local\")\n                    )\n        finally:\n            self.mw.progress.finish()\n\n        return log, errs\n\n    # Installation messaging\n    ######################################################################\n\n    def _installationErrorReport(\n        self, result: InstallError, base: str, mode: str = \"download\"\n    ) -> list[str]:\n        messages = {\n            \"zip\": tr.addons_corrupt_addon_file(),\n            \"manifest\": tr.addons_invalid_addon_manifest(),\n        }\n\n        msg = messages.get(result.errmsg, tr.addons_unknown_error(val=result.errmsg))\n\n        if mode == \"download\":\n            template = tr.addons_error_downloading_ids_errors(id=base, error=msg)\n        else:\n            template = tr.addons_error_installing_bases_errors(base=base, error=msg)\n\n        return [template]\n\n    def _installationSuccessReport(\n        self, result: InstallOk, base: str, mode: str = \"download\"\n    ) -> list[str]:\n        name = result.name or base\n        if mode == \"download\":\n            template = tr.addons_downloaded_fnames(fname=name)\n        else:\n            template = tr.addons_installed_names(name=name)\n\n        strings = [template]\n\n        if result.conflicts:\n            strings.append(\n                tr.addons_the_following_conflicting_addons_were_disabled()\n                + \" \"\n                + \", \".join(self.addonName(f) for f in result.conflicts)\n            )\n\n        if not result.compatible:\n            strings.append(tr.addons_this_addon_is_not_compatible_with())\n\n        return strings\n\n    # Updating\n    ######################################################################\n\n    def update_supported_versions(self, items: list[AddonInfo]) -> None:\n        \"\"\"Adjust the supported version range after an update check.\n\n        AnkiWeb will not have sent us any add-ons that don't support our\n        version, so this cannot disable add-ons that users are using. It\n        does allow the add-on author to mark an add-on as not supporting\n        a future release, causing the add-on to be disabled when the user\n        upgrades.\n        \"\"\"\n\n        for item in items:\n            addon = self.addon_meta(str(item.id))\n            updated = False\n\n            if addon.max_version != item.max_version:\n                addon.max_version = item.max_version\n                updated = True\n            if addon.min_version != item.min_version:\n                addon.min_version = item.min_version\n                updated = True\n\n            if updated:\n                self.write_addon_meta(addon)\n\n    def get_updated_addons(self, items: list[AddonInfo]) -> list[AddonInfo]:\n        \"\"\"Return ids of add-ons requiring an update.\"\"\"\n        need_update = []\n        for item in items:\n            addon = self.addon_meta(str(item.id))\n            # update if server mtime is newer\n            if not addon.is_latest(item.modified):\n                need_update.append(item)\n            elif not addon.compatible():\n                # Addon is currently disabled, and a suitable branch was found on the\n                # server. Ignore our stored mtime (which may have been set incorrectly\n                # in the past) and require an update.\n                need_update.append(item)\n\n        return need_update\n\n    # Add-on Config\n    ######################################################################\n\n    _configButtonActions: dict[str, Callable[[], bool | None]] = {}\n    _configUpdatedActions: dict[str, Callable[[Any], None]] = {}\n    _config_help_actions: dict[str, Callable[[], str]] = {}\n\n    def addonConfigDefaults(self, module: str) -> dict[str, Any] | None:\n        path = os.path.join(self.addonsFolder(module), \"config.json\")\n        try:\n            with open(path, encoding=\"utf8\") as f:\n                return json.load(f)\n        except Exception:\n            return None\n\n    def set_config_help_action(self, module: str, action: Callable[[], str]) -> None:\n        \"Set a callback used to produce config help.\"\n        addon = self.addonFromModule(module)\n        self._config_help_actions[addon] = action\n\n    def addonConfigHelp(self, module: str) -> str:\n        if action := self._config_help_actions.get(module, None):\n            contents = action()\n        else:\n            path = os.path.join(self.addonsFolder(module), \"config.md\")\n            if os.path.exists(path):\n                with open(path, encoding=\"utf-8\") as f:\n                    contents = f.read()\n            else:\n                return \"\"\n\n        return markdown.markdown(contents, extensions=[md_in_html.makeExtension()])\n\n    def addonFromModule(self, module: str) -> str:  # softly deprecated\n        return module.split(\".\")[0]\n\n    @staticmethod\n    def addon_from_module(module: str) -> str:\n        return module.split(\".\")[0]\n\n    def configAction(self, module: str) -> Callable[[], bool | None]:\n        return self._configButtonActions.get(module)\n\n    def configUpdatedAction(self, module: str) -> Callable[[Any], None]:\n        return self._configUpdatedActions.get(module)\n\n    # Schema\n    ######################################################################\n\n    def _addon_schema_path(self, module: str) -> str:\n        return os.path.join(self.addonsFolder(module), \"config.schema.json\")\n\n    def _addon_schema(self, module: str) -> Any:\n        path = self._addon_schema_path(module)\n        try:\n            if not os.path.exists(path):\n                # True is a schema accepting everything\n                return True\n            with open(path, encoding=\"utf-8\") as f:\n                return json.load(f)\n        except json.decoder.JSONDecodeError as e:\n            print(\"The schema is not valid:\")\n            print(e)\n\n    # Add-on Config API\n    ######################################################################\n\n    def getConfig(self, module: str) -> dict[str, Any] | None:\n        addon = self.addonFromModule(module)\n        # get default config\n        config = self.addonConfigDefaults(addon)\n        if config is None:\n            return None\n        # merge in user's keys\n        meta = self.addonMeta(addon)\n        userConf = meta.get(\"config\", {})\n        config.update(userConf)\n        return config\n\n    def setConfigAction(self, module: str, fn: Callable[[], bool | None]) -> None:\n        addon = self.addonFromModule(module)\n        self._configButtonActions[addon] = fn\n\n    def setConfigUpdatedAction(self, module: str, fn: Callable[[Any], None]) -> None:\n        addon = self.addonFromModule(module)\n        self._configUpdatedActions[addon] = fn\n\n    def writeConfig(self, module: str, conf: dict) -> None:\n        addon = self.addonFromModule(module)\n        meta = self.addonMeta(addon)\n        meta[\"config\"] = conf\n        self.writeAddonMeta(addon, meta)\n\n    # user_files\n    ######################################################################\n\n    def _userFilesPath(self, sid: str) -> str:\n        return os.path.join(self.addonsFolder(sid), \"user_files\")\n\n    def _userFilesBackupPath(self) -> str:\n        return os.path.join(self.addonsFolder(), \"files_backup\")\n\n    def backupUserFiles(self, module: str) -> None:\n        p = self._userFilesPath(module)\n\n        if os.path.exists(p):\n            os.rename(p, self._userFilesBackupPath())\n\n    def restoreUserFiles(self, sid: str) -> None:\n        p = self._userFilesPath(sid)\n        bp = self._userFilesBackupPath()\n        # did we back up userFiles?\n        if not os.path.exists(bp):\n            return\n        os.rename(bp, p)\n\n    # Web Exports\n    ######################################################################\n\n    _webExports: dict[str, str] = {}\n\n    def setWebExports(self, module: str, pattern: str) -> None:\n        addon = self.addonFromModule(module)\n        self._webExports[addon] = pattern\n\n    def getWebExports(self, module: str) -> str:\n        return self._webExports.get(module)\n\n    # Logging\n    ######################################################################\n\n    @classmethod\n    def get_logger(cls, module: str) -> logging.Logger:\n        \"\"\"Return a logger for the given add-on module.\n\n        NOTE: This method is static to allow it to be called outside of a\n        running Anki instance, e.g. in add-on unit tests.\n        \"\"\"\n        return logging.getLogger(\n            f\"{ADDON_LOGGER_PREFIX}{cls.addon_from_module(module)}\"\n        )\n\n    def has_logger(self, module: str) -> bool:\n        return find_addon_logger(self.addon_from_module(module)) is not None\n\n    def is_debug_logging_enabled(self, module: str) -> bool:\n        if not (logger := find_addon_logger(self.addon_from_module(module))):\n            return False\n        return logger.isEnabledFor(logging.DEBUG)\n\n    def toggle_debug_logging(self, module: str, enable: bool) -> None:\n        if not (logger := find_addon_logger(self.addon_from_module(module))):\n            return\n        logger.setLevel(logging.DEBUG if enable else logging.INFO)\n\n    def logs_folder(self, module: str) -> Path:\n        return get_addon_logs_folder(\n            self.mw.pm.addon_logs(), self.addon_from_module(module)\n        )\n\n\n# Add-ons Dialog\n######################################################################\n\n\nclass AddonsDialog(QDialog):\n    def __init__(self, addonsManager: AddonManager) -> None:\n        self.mgr = addonsManager\n        self.mw = addonsManager.mw\n        self._require_restart = False\n\n        super().__init__(self.mw)\n\n        f = self.form = aqt.forms.addons.Ui_Dialog()\n        f.setupUi(self)\n        qconnect(f.getAddons.clicked, self.onGetAddons)\n        qconnect(f.installFromFile.clicked, self.onInstallFiles)\n        qconnect(f.checkForUpdates.clicked, self.check_for_updates)\n        qconnect(f.toggleEnabled.clicked, self.onToggleEnabled)\n        qconnect(f.viewPage.clicked, self.onViewPage)\n        qconnect(f.viewFiles.clicked, self.onViewFiles)\n        qconnect(f.delete_2.clicked, self.onDelete)\n        qconnect(f.config.clicked, self.onConfig)\n        qconnect(self.form.addonList.itemDoubleClicked, self.onConfig)\n        qconnect(self.form.addonList.currentRowChanged, self._onAddonItemSelected)\n        qconnect(\n            self.form.addonList.itemSelectionChanged, self._onAddonSelectionChanged\n        )\n        self.setWindowTitle(tr.addons_window_title())\n        disable_help_button(self)\n        self.setAcceptDrops(True)\n        self.redrawAddons()\n        restoreGeom(self, \"addons\")\n        gui_hooks.addons_dialog_will_show(self)\n        self._onAddonSelectionChanged()\n        self.show()\n\n    def dragEnterEvent(self, event: QDragEnterEvent) -> None:\n        mime = event.mimeData()\n        if not mime.hasUrls():\n            return None\n        urls = mime.urls()\n        exts = self.mgr.exts\n        if all(any(url.toLocalFile().endswith(ext) for ext in exts) for url in urls):\n            event.acceptProposedAction()\n\n    def dropEvent(self, event: QDropEvent) -> None:\n        mime = event.mimeData()\n        paths = []\n        for url in mime.urls():\n            path = url.toLocalFile()\n            if os.path.exists(path):\n                paths.append(path)\n        self.onInstallFiles(paths)\n\n    def reject(self) -> None:\n        if self._require_restart:\n            tooltip(tr.addons_changes_will_take_effect_when_anki(), parent=self.mw)\n        saveGeom(self, \"addons\")\n        aqt.dialogs.markClosed(\"AddonsDialog\")\n\n        return QDialog.reject(self)\n\n    silentlyClose = True\n\n    def name_for_addon_list(self, addon: AddonMeta) -> str:\n        name = addon.human_name()\n\n        if not addon.enabled:\n            return f\"{name} {tr.addons_disabled2()}\"\n        elif not addon.compatible():\n            return f\"{name} {tr.addons_requires(val=self.compatible_string(addon))}\"\n\n        return name\n\n    def compatible_string(self, addon: AddonMeta) -> str:\n        min = addon.min_version\n        if min is not None and min > _current_version:\n            ver = int_version_to_str(min)\n            return f\"Anki >= {ver}\"\n        else:\n            max = abs(addon.max_version)\n            ver = int_version_to_str(max)\n            return f\"Anki <= {ver}\"\n\n    def should_grey(self, addon: AddonMeta) -> bool:\n        return not addon.enabled or not addon.compatible()\n\n    def redrawAddons(\n        self,\n    ) -> None:\n        addonList = self.form.addonList\n        mgr = self.mgr\n\n        self.addons = list(mgr.all_addon_meta())\n        self.addons.sort(key=lambda a: a.human_name().lower())\n        self.addons.sort(key=self.should_grey)\n\n        selected = set(self.selectedAddons())\n        addonList.clear()\n        for addon in self.addons:\n            name = self.name_for_addon_list(addon)\n            item = QListWidgetItem(name, addonList)\n            if self.should_grey(addon):\n                item.setForeground(Qt.GlobalColor.gray)\n            if addon.dir_name in selected:\n                item.setSelected(True)\n\n        addonList.reset()\n\n    def _onAddonSelectionChanged(self) -> None:\n        self.form.viewFiles.setEnabled(False)\n        self.form.viewPage.setEnabled(False)\n        self.form.config.setEnabled(False)\n\n        selected_count = len(self.selectedAddons())\n        if selected_count == 0:\n            # View Files button shows top-level add-ons directory when nothing is selected\n            self.form.viewFiles.setEnabled(True)\n        elif selected_count == 1:\n            addon = self.addons[self.form.addonList.currentRow()]\n\n            self.form.viewFiles.setEnabled(True)\n            self.form.viewPage.setEnabled(addon.page() is not None)\n            self.form.config.setEnabled(\n                bool(\n                    self.mgr.getConfig(addon.dir_name)\n                    or self.mgr.configAction(addon.dir_name)\n                )\n            )\n\n    def _onAddonItemSelected(self, row_int: int) -> None:\n        try:\n            addon = self.addons[row_int]\n        except IndexError:\n            return\n        gui_hooks.addons_dialog_did_change_selected_addon(self, addon)\n        return\n\n    def selectedAddons(self) -> list[str]:\n        idxs = [x.row() for x in self.form.addonList.selectedIndexes()]\n        return [self.addons[idx].dir_name for idx in idxs]\n\n    def onlyOneSelected(self) -> str | None:\n        dirs = self.selectedAddons()\n        if len(dirs) != 1:\n            show_info(tr.addons_please_select_a_single_addon_first())\n            return None\n        return dirs[0]\n\n    def selected_addon_meta(self) -> AddonMeta | None:\n        idxs = [x.row() for x in self.form.addonList.selectedIndexes()]\n        if len(idxs) != 1:\n            show_info(tr.addons_please_select_a_single_addon_first())\n            return None\n        return self.addons[idxs[0]]\n\n    def onToggleEnabled(self) -> None:\n        for module in self.selectedAddons():\n            self.mgr.toggleEnabled(module)\n        self._require_restart = True\n        self.redrawAddons()\n\n    def onViewPage(self) -> None:\n        addon = self.selected_addon_meta()\n        if not addon:\n            return\n        if page := addon.page():\n            openLink(page)\n\n    def onViewFiles(self) -> None:\n        # if nothing selected, open top-level folder\n        selected = self.selectedAddons()\n        if not selected:\n            openFolder(self.mgr.addonsFolder())\n            return\n\n        # otherwise require a single selection\n        addon = self.onlyOneSelected()\n        if not addon:\n            return\n        path = self.mgr.addonsFolder(addon)\n        openFolder(path)\n\n    def onDelete(self) -> None:\n        selected = self.selectedAddons()\n        if not selected:\n            return\n        if not askUser(tr.addons_delete_the_numd_selected_addon(count=len(selected))):\n            return\n        gui_hooks.addons_dialog_will_delete_addons(self, selected)\n        try:\n            for module in selected:\n                # doing this before deleting, as `enabled` is always True afterwards\n                if self.mgr.addon_meta(module).enabled:\n                    self._require_restart = True\n                self.mgr.deleteAddon(module)\n        except OSError as e:\n            showWarning(\n                tr.addons_unable_to_update_or_delete_addon(val=str(e)),\n                textFormat=\"plain\",\n            )\n        self.form.addonList.clearSelection()\n        self.redrawAddons()\n\n    def onGetAddons(self) -> None:\n        obj = GetAddons(self)\n        if obj.ids:\n            download_addons(\n                self, self.mgr, obj.ids, self.after_downloading, force_enable=True\n            )\n\n    def after_downloading(self, log: list[DownloadLogEntry]) -> None:\n        self.redrawAddons()\n        if log:\n            show_log_to_user(self, log)\n        else:\n            tooltip(tr.addons_no_updates_available())\n\n    def onInstallFiles(self, paths: list[str] | None = None) -> bool | None:\n        if not paths:\n            filter = f\"{tr.addons_packaged_anki_addon()} \" + \"({})\".format(\n                \" \".join(f\"*{ext}\" for ext in self.mgr.exts)\n            )\n            paths_ = getFile(\n                self, tr.addons_install_addons(), None, filter, key=\"addons\", multi=True\n            )\n            paths = paths_  # type: ignore\n            if not paths:\n                return False\n\n        installAddonPackages(self.mgr, paths, parent=self, force_enable=True)\n\n        self.redrawAddons()\n        return None\n\n    def check_for_updates(self) -> None:\n        tooltip(tr.addons_checking())\n        check_and_prompt_for_updates(self, self.mgr, self.after_downloading)\n\n    def onConfig(self) -> None:\n        addon = self.onlyOneSelected()\n        if not addon:\n            return\n\n        # does add-on manage its own config?\n        act = self.mgr.configAction(addon)\n        if act:\n            ret = act()\n            if ret is not False:\n                return\n\n        conf = self.mgr.getConfig(addon)\n        if conf is None:\n            showInfo(tr.addons_addon_has_no_configuration())\n            return\n\n        ConfigEditor(self, addon, conf)\n\n\n# Fetching Add-ons\n######################################################################\n\n\nclass GetAddons(QDialog):\n    def __init__(self, dlg: AddonsDialog) -> None:\n        QDialog.__init__(self, dlg)\n        self.addonsDlg = dlg\n        self.mgr = dlg.mgr\n        self.mw = self.mgr.mw\n        self.ids: list[int] = []\n        self.form = aqt.forms.getaddons.Ui_Dialog()\n        self.form.setupUi(self)\n        b = self.form.buttonBox.addButton(\n            tr.addons_browse_addons(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n        qconnect(b.clicked, self.onBrowse)\n        disable_help_button(self)\n        restoreGeom(self, \"getaddons\", adjustSize=True)\n        self.exec()\n        saveGeom(self, \"getaddons\")\n\n    def onBrowse(self) -> None:\n        openLink(f\"{aqt.appShared}addons/2.1\")\n\n    def accept(self) -> None:\n        # get codes\n        try:\n            sids = self.form.code.text().split()\n            sids = [\n                re.sub(r\"^https://ankiweb.net/shared/info/(\\d+)$\", r\"\\1\", id_)\n                for id_ in sids\n            ]\n            ids = [int(id_) for id_ in sids]\n        except ValueError:\n            showWarning(tr.addons_invalid_code())\n            return\n\n        self.ids = ids\n        QDialog.accept(self)\n\n\n# Downloading\n######################################################################\n\n\ndef download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError:\n    \"Fetch a single add-on from AnkiWeb.\"\n    try:\n        resp = client.get(f\"{aqt.appShared}download/{id}?v=2.1&p={_current_version}\")\n        if resp.status_code != 200:\n            return DownloadError(status_code=resp.status_code)\n\n        data = client.stream_content(resp)\n\n        match = re.match(\n            \"attachment; filename=(.+)\", resp.headers[\"content-disposition\"]\n        )\n        assert match is not None\n        fname = match.group(1)\n\n        meta = extract_meta_from_download_url(resp.url)\n\n        return DownloadOk(\n            data=data,\n            filename=fname,\n            mod_time=meta.mod_time,\n            min_point_version=meta.min_point_version,\n            max_point_version=meta.max_point_version,\n            branch_index=meta.branch_index,\n        )\n    except Exception as e:\n        return DownloadError(exception=e)\n\n\n@dataclass\nclass ExtractedDownloadMeta:\n    mod_time: int\n    min_point_version: int\n    max_point_version: int\n    branch_index: int\n\n\ndef extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta:\n    urlobj = urlparse(url)\n    query = parse_qs(urlobj.query)\n\n    def get_first_element(elements: list[str]) -> int:\n        return int(elements[0])\n\n    meta = ExtractedDownloadMeta(\n        mod_time=get_first_element(query[\"t\"]),\n        min_point_version=get_first_element(query[\"minpt\"]),\n        max_point_version=get_first_element(query[\"maxpt\"]),\n        branch_index=get_first_element(query[\"bidx\"]),\n    )\n\n    return meta\n\n\ndef download_log_to_html(log: list[DownloadLogEntry]) -> str:\n    return \"<br>\".join(map(describe_log_entry, log))\n\n\ndef describe_log_entry(id_and_entry: DownloadLogEntry) -> str:\n    (id, entry) = id_and_entry\n    buf = f\"{id}: \"\n\n    if isinstance(entry, DownloadError):\n        if entry.status_code is not None:\n            if entry.status_code in (403, 404):\n                buf += tr.addons_invalid_code_or_addon_not_available()\n            else:\n                buf += tr.qt_misc_unexpected_response_code(val=entry.status_code)\n        else:\n            buf += (\n                tr.addons_please_check_your_internet_connection()\n                + \"\\n\\n\"\n                + str(entry.exception)\n            )\n    elif isinstance(entry, InstallError):\n        buf += entry.errmsg\n    else:\n        buf += tr.addons_installed_successfully()\n\n    return buf\n\n\ndef download_encountered_problem(log: list[DownloadLogEntry]) -> bool:\n    return any(not isinstance(e[1], InstallOk) for e in log)\n\n\ndef download_and_install_addon(\n    mgr: AddonManager, client: HttpClient, id: int, force_enable: bool = False\n) -> DownloadLogEntry:\n    \"Download and install a single add-on.\"\n    result = download_addon(client, id)\n    if isinstance(result, DownloadError):\n        return (id, result)\n\n    fname = result.filename.replace(\"_\", \" \")\n    name = os.path.splitext(fname)[0].strip()\n    if not name:\n        name = str(id)\n\n    manifest = dict(\n        package=str(id),\n        name=name,\n        mod=result.mod_time,\n        min_point_version=result.min_point_version,\n        max_point_version=result.max_point_version,\n        branch_index=result.branch_index,\n    )\n\n    result2 = mgr.install(\n        io.BytesIO(result.data), manifest=manifest, force_enable=force_enable\n    )\n\n    return (id, result2)\n\n\nclass DownloaderInstaller(QObject):\n    progressSignal = pyqtSignal(int, int)\n\n    def __init__(self, parent: QWidget, mgr: AddonManager, client: HttpClient) -> None:\n        QObject.__init__(self, parent)\n        self.mgr = mgr\n        self.client = client\n        qconnect(self.progressSignal, self._progress_callback)\n\n        def bg_thread_progress(up: int, down: int) -> None:\n            self.progressSignal.emit(up, down)  # type: ignore\n\n        self.client.progress_hook = bg_thread_progress\n\n    def download(\n        self,\n        ids: list[int],\n        on_done: Callable[[list[DownloadLogEntry]], None],\n        force_enable: bool = False,\n    ) -> None:\n        self.ids = ids\n        self.log: list[DownloadLogEntry] = []\n\n        self.dl_bytes = 0\n        self.last_tooltip = 0\n\n        self.on_done = on_done\n\n        parent = self.parent()\n        assert isinstance(parent, QWidget)\n        self.mgr.mw.progress.start(immediate=True, parent=parent)\n        self.mgr.mw.taskman.run_in_background(\n            lambda: self._download_all(force_enable), self._download_done\n        )\n\n    def _progress_callback(self, up: int, down: int) -> None:\n        self.dl_bytes += down\n        self.mgr.mw.progress.update(\n            label=tr.addons_downloading_adbd_kb02fkb(\n                part=len(self.log) + 1,\n                total=len(self.ids),\n                kilobytes=self.dl_bytes // 1024,\n            )\n        )\n\n    def _download_all(self, force_enable: bool = False) -> None:\n        for id in self.ids:\n            self.log.append(\n                download_and_install_addon(\n                    self.mgr, self.client, id, force_enable=force_enable\n                )\n            )\n\n    def _download_done(self, future: Future) -> None:\n        self.mgr.mw.progress.finish()\n        future.result()\n        # qt gets confused if on_done() opens new windows while the progress\n        # modal is still cleaning up\n        self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log))\n\n\ndef show_log_to_user(\n    parent: QWidget, log: list[DownloadLogEntry], title: str = \"Anki\"\n) -> None:\n    have_problem = download_encountered_problem(log)\n\n    if have_problem:\n        text = tr.addons_one_or_more_errors_occurred()\n    else:\n        text = tr.addons_download_complete_please_restart_anki_to()\n    text += f\"<br><br>{download_log_to_html(log)}\"\n\n    if have_problem:\n        showWarning(text, textFormat=\"rich\", parent=parent, title=title)\n    else:\n        showInfo(text, parent=parent, title=title)\n\n\ndef download_addons(\n    parent: QWidget,\n    mgr: AddonManager,\n    ids: list[int],\n    on_done: Callable[[list[DownloadLogEntry]], None],\n    client: HttpClient | None = None,\n    force_enable: bool = False,\n) -> None:\n    if client is None:\n        client = HttpClient()\n    downloader = DownloaderInstaller(parent, mgr, client)\n    downloader.download(ids, on_done=on_done, force_enable=force_enable)\n\n\n# Update checking\n######################################################################\n\n\nclass ChooseAddonsToUpdateList(QListWidget):\n    ADDON_ID_ROLE = 101\n\n    def __init__(\n        self,\n        parent: QWidget,\n        mgr: AddonManager,\n        updated_addons: list[AddonInfo],\n    ) -> None:\n        QListWidget.__init__(self, parent)\n        self.mgr = mgr\n        self.updated_addons = sorted(updated_addons, key=lambda addon: addon.modified)\n        self.ignore_check_evt = False\n        self.setup()\n        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        qconnect(self.itemClicked, self.on_click)\n        qconnect(self.itemChanged, self.on_check)\n        qconnect(self.itemDoubleClicked, self.on_double_click)\n        qconnect(self.customContextMenuRequested, self.on_context_menu)\n\n    def setup(self) -> None:\n        header_item = QListWidgetItem(tr.addons_choose_update_update_all(), self)\n        header_item.setFlags(\n            Qt.ItemFlag(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)\n        )\n        self.header_item = header_item\n        for update_info in self.updated_addons:\n            addon_id = update_info.id\n            addon_meta = self.mgr.addon_meta(str(addon_id))\n            update_enabled = addon_meta.update_enabled\n            addon_name = addon_meta.human_name()\n            update_timestamp = update_info.modified\n            update_time = datetime.fromtimestamp(update_timestamp)\n\n            addon_label = f\"{update_time:%Y-%m-%d}   {addon_name}\"\n            item = QListWidgetItem(addon_label, self)\n            # Not user checkable because it overlaps with itemClicked signal\n            item.setFlags(Qt.ItemFlag(Qt.ItemFlag.ItemIsEnabled))\n            if update_enabled:\n                item.setCheckState(Qt.CheckState.Checked)\n            else:\n                item.setCheckState(Qt.CheckState.Unchecked)\n            item.setData(self.ADDON_ID_ROLE, addon_id)\n        self.refresh_header_check_state()\n\n    def bool_to_check(self, check_bool: bool) -> Qt.CheckState:\n        if check_bool:\n            return Qt.CheckState.Checked\n        else:\n            return Qt.CheckState.Unchecked\n\n    def checked(self, item: QListWidgetItem) -> bool:\n        return item.checkState() == Qt.CheckState.Checked\n\n    def on_click(self, item: QListWidgetItem) -> None:\n        if item == self.header_item:\n            return\n        checked = self.checked(item)\n        self.check_item(item, self.bool_to_check(not checked))\n        self.refresh_header_check_state()\n\n    def on_check(self, item: QListWidgetItem) -> None:\n        if self.ignore_check_evt:\n            return\n        if item == self.header_item:\n            self.header_checked(item.checkState())\n\n    def on_double_click(self, item: QListWidgetItem) -> None:\n        if item == self.header_item:\n            checked = self.checked(item)\n            self.check_item(self.header_item, self.bool_to_check(not checked))\n            self.header_checked(self.bool_to_check(not checked))\n\n    def on_context_menu(self, point: QPoint) -> None:\n        if not (item := self.itemAt(point)):\n            return\n        addon_id = item.data(self.ADDON_ID_ROLE)\n        m = QMenu()\n        a = m.addAction(tr.addons_view_addon_page())\n        qconnect(a.triggered, lambda _: openLink(f\"{aqt.appShared}info/{addon_id}\"))\n        m.exec(QCursor.pos())\n\n    def check_item(self, item: QListWidgetItem, check: Qt.CheckState) -> None:\n        \"call item.setCheckState without triggering on_check\"\n        self.ignore_check_evt = True\n        item.setCheckState(check)\n        self.ignore_check_evt = False\n\n    def header_checked(self, check: Qt.CheckState) -> None:\n        for i in range(1, self.count()):\n            self.check_item(self.item(i), check)\n\n    def refresh_header_check_state(self) -> None:\n        for i in range(1, self.count()):\n            item = self.item(i)\n            if not self.checked(item):\n                self.check_item(self.header_item, Qt.CheckState.Unchecked)\n                return\n        self.check_item(self.header_item, Qt.CheckState.Checked)\n\n    def get_selected_addon_ids(self) -> list[int]:\n        addon_ids = []\n        for i in range(1, self.count()):\n            item = self.item(i)\n            if self.checked(item):\n                addon_id = item.data(self.ADDON_ID_ROLE)\n                addon_ids.append(addon_id)\n        return addon_ids\n\n    def save_check_state(self) -> None:\n        for i in range(1, self.count()):\n            item = self.item(i)\n            addon_id = item.data(self.ADDON_ID_ROLE)\n            addon_meta = self.mgr.addon_meta(str(addon_id))\n            addon_meta.update_enabled = self.checked(item)\n            self.mgr.write_addon_meta(addon_meta)\n\n\nclass ChooseAddonsToUpdateDialog(QDialog):\n    _on_done: Callable[[list[int]], None]\n\n    def __init__(\n        self, parent: QWidget, mgr: AddonManager, updated_addons: list[AddonInfo]\n    ) -> None:\n        QDialog.__init__(self, parent)\n        self.setWindowTitle(tr.addons_choose_update_window_title())\n        self.setWindowModality(Qt.WindowModality.NonModal)\n        self.mgr = mgr\n        self.updated_addons = updated_addons\n        self.setup()\n        restoreGeom(self, \"addonsChooseUpdate\")\n\n    def setup(self) -> None:\n        layout = QVBoxLayout()\n        label = QLabel(tr.addons_the_following_addons_have_updates_available())\n        layout.addWidget(label)\n        addons_list_widget = ChooseAddonsToUpdateList(\n            self, self.mgr, self.updated_addons\n        )\n        layout.addWidget(addons_list_widget)\n        self.addons_list_widget = addons_list_widget\n\n        button_box = QDialogButtonBox(\n            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel\n        )  # type: ignore\n        qconnect(\n            button_box.button(QDialogButtonBox.StandardButton.Ok).clicked, self.accept\n        )\n        qconnect(\n            button_box.button(QDialogButtonBox.StandardButton.Cancel).clicked,\n            self.reject,\n        )\n        layout.addWidget(button_box)\n        self.setLayout(layout)\n\n    def ask(self, on_done: Callable[[list[int]], None]) -> None:\n        self._on_done = on_done\n        self.show()\n\n    def accept(self) -> None:\n        saveGeom(self, \"addonsChooseUpdate\")\n        self.addons_list_widget.save_check_state()\n        self._on_done(self.addons_list_widget.get_selected_addon_ids())\n        QDialog.accept(self)\n\n\ndef fetch_update_info(ids: list[int]) -> list[AddonInfo]:\n    \"\"\"Fetch update info from AnkiWeb in one or more batches.\"\"\"\n    all_info: list[AddonInfo] = []\n\n    while ids:\n        # get another chunk\n        chunk = ids[:25]\n        del ids[:25]\n\n        batch_results = _fetch_update_info_batch(chunk)\n        all_info.extend(batch_results)\n\n    return all_info\n\n\ndef _fetch_update_info_batch(chunk: Iterable[int]) -> Sequence[AddonInfo]:\n    return aqt.mw.backend.get_addon_info(\n        client_version=_current_version, addon_ids=chunk\n    )\n\n\ndef check_and_prompt_for_updates(\n    parent: QWidget,\n    mgr: AddonManager,\n    on_done: Callable[[list[DownloadLogEntry]], None],\n    requested_by_user: bool = True,\n) -> None:\n    def on_updates_received(items: list[AddonInfo]) -> None:\n        handle_update_info(parent, mgr, items, on_done, requested_by_user)\n\n    check_for_updates(mgr, on_updates_received)\n\n\ndef check_for_updates(\n    mgr: AddonManager, on_done: Callable[[list[AddonInfo]], None]\n) -> None:\n    def check() -> list[AddonInfo]:\n        return fetch_update_info(mgr.ankiweb_addons())\n\n    def update_info_received(future: Future) -> None:\n        # if syncing/in profile screen, defer message delivery\n        if not mgr.mw.col:\n            mgr.mw.progress.single_shot(\n                1000,\n                lambda: update_info_received(future),\n                False,\n            )\n            return\n\n        if future.exception():\n            # swallow network errors\n            print(str(future.exception()))\n            result = []\n        else:\n            result = future.result()\n\n        on_done(result)\n\n    mgr.mw.taskman.run_in_background(check, update_info_received)\n\n\ndef handle_update_info(\n    parent: QWidget,\n    mgr: AddonManager,\n    items: list[AddonInfo],\n    on_done: Callable[[list[DownloadLogEntry]], None],\n    requested_by_user: bool = True,\n) -> None:\n    mgr.update_supported_versions(items)\n    updated_addons = mgr.get_updated_addons(items)\n\n    if not updated_addons:\n        on_done([])\n        return\n\n    prompt_to_update(parent, mgr, updated_addons, on_done, requested_by_user)\n\n\ndef prompt_to_update(\n    parent: QWidget,\n    mgr: AddonManager,\n    updated_addons: list[AddonInfo],\n    on_done: Callable[[list[DownloadLogEntry]], None],\n    requested_by_user: bool = True,\n) -> None:\n    client = HttpClient()\n    if not requested_by_user:\n        prompt_update = False\n        for addon in updated_addons:\n            if mgr.addon_meta(str(addon.id)).update_enabled:\n                prompt_update = True\n        if not prompt_update:\n            return\n\n    def after_choosing(ids: list[int]) -> None:\n        if ids:\n            download_addons(parent, mgr, ids, on_done, client)\n\n    ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask(after_choosing)\n\n\ndef install_or_update_addon(\n    parent: QWidget,\n    mgr: AddonManager,\n    addon_id: int,\n    on_done: Callable[[list[DownloadLogEntry]], None],\n) -> None:\n    def check() -> list[AddonInfo]:\n        return fetch_update_info([addon_id])\n\n    def update_info_received(future: Future) -> None:\n        try:\n            items = future.result()\n            updated_addons = mgr.get_updated_addons(items)\n            if not updated_addons:\n                on_done([])\n                return\n            client = HttpClient()\n            download_addons(\n                parent, mgr, [addon.id for addon in updated_addons], on_done, client\n            )\n        except Exception as exc:\n            on_done([(addon_id, DownloadError(exception=exc))])\n\n    mgr.mw.taskman.run_in_background(check, update_info_received)\n\n\n# Editing config\n######################################################################\n\n\nclass ConfigEditor(QDialog):\n    def __init__(self, dlg: AddonsDialog, addon: str, conf: dict) -> None:\n        super().__init__(dlg)\n        self.addon = addon\n        self.conf = conf\n        self.mgr = dlg.mgr\n        self.form = aqt.forms.addonconf.Ui_Dialog()\n        self.form.setupUi(self)\n        restore = self.form.buttonBox.button(\n            QDialogButtonBox.StandardButton.RestoreDefaults\n        )\n        qconnect(restore.clicked, self.onRestoreDefaults)\n        ok = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Ok)\n        ok.setShortcut(QKeySequence(\"Ctrl+Return\"))\n        self.setupFonts()\n        self.updateHelp()\n        self.updateText(self.conf)\n        restoreGeom(self, \"addonconf\")\n        self.form.splitter.setSizes([2 * self.width() // 3, self.width() // 3])\n        restoreSplitter(self.form.splitter, \"addonconf\")\n        self.setWindowTitle(\n            without_unicode_isolation(\n                tr.addons_config_window_title(\n                    name=self.mgr.addon_meta(addon).human_name(),\n                )\n            )\n        )\n        disable_help_button(self)\n        self.show()\n\n    def onRestoreDefaults(self) -> None:\n        default_conf = self.mgr.addonConfigDefaults(self.addon)\n        self.updateText(default_conf)\n        tooltip(tr.addons_restored_defaults(), parent=self)\n\n    def setupFonts(self) -> None:\n        font_mono = QFont(\"Consolas\")\n        if not font_mono.exactMatch():\n            font_mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)\n        font_mono.setPointSize(font_mono.pointSize())\n        self.form.editor.setFont(font_mono)\n\n    def updateHelp(self) -> None:\n        txt = self.mgr.addonConfigHelp(self.addon)\n        if txt:\n            self.form.help.stdHtml(txt, js=[], css=[\"css/addonconf.css\"], context=self)\n        else:\n            self.form.help.setVisible(False)\n\n    def updateText(self, conf: dict[str, Any]) -> None:\n        text = json.dumps(\n            conf,\n            ensure_ascii=False,\n            sort_keys=True,\n            indent=4,\n            separators=(\",\", \": \"),\n        )\n        text = gui_hooks.addon_config_editor_will_display_json(text)\n        self.form.editor.setPlainText(text)\n        if is_mac:\n            self.form.editor.repaint()\n\n    def onClose(self) -> None:\n        saveGeom(self, \"addonconf\")\n        saveSplitter(self.form.splitter, \"addonconf\")\n\n    def reject(self) -> None:\n        self.onClose()\n        super().reject()\n\n    def accept(self) -> None:\n        txt = self.form.editor.toPlainText()\n        txt = gui_hooks.addon_config_editor_will_update_json(txt, self.addon)\n        try:\n            new_conf = json.loads(txt)\n            jsonschema.validate(new_conf, self.mgr._addon_schema(self.addon))\n        except ValidationError as e:\n            # The user did edit the configuration and entered a value\n            # which can not be interpreted.\n            schema = e.schema\n            erroneous_conf = new_conf\n            for link in e.path:\n                erroneous_conf = erroneous_conf[link]\n            path = \"/\".join(str(path) for path in e.path)\n            if \"error_msg\" in schema:\n                msg = schema[\"error_msg\"].format(\n                    problem=e.message,\n                    path=path,\n                    schema=str(schema),\n                    erroneous_conf=erroneous_conf,\n                )\n            else:\n                msg = tr.addons_config_validation_error(\n                    problem=e.message,\n                    path=path,\n                    schema=str(schema),\n                )\n            showInfo(msg)\n            return\n        except Exception as e:\n            showInfo(f\"{tr.addons_invalid_configuration()} {repr(e)}\")\n            return\n\n        if not isinstance(new_conf, dict):\n            showInfo(tr.addons_invalid_configuration_top_level_object_must())\n            return\n\n        if new_conf != self.conf:\n            self.mgr.writeConfig(self.addon, new_conf)\n            # does the add-on define an action to be fired?\n            act = self.mgr.configUpdatedAction(self.addon)\n            if act:\n                act(new_conf)\n\n        self.onClose()\n        super().accept()\n\n\n# .ankiaddon installation wizard\n######################################################################\n\n\ndef installAddonPackages(\n    addonsManager: AddonManager,\n    paths: list[str],\n    parent: QWidget | None = None,\n    warn: bool = False,\n    strictly_modal: bool = False,\n    advise_restart: bool = False,\n    force_enable: bool = False,\n) -> bool:\n    if warn:\n        names = \",<br>\".join(f\"<b>{os.path.basename(p)}</b>\" for p in paths)\n        q = tr.addons_important_as_addons_are_programs_downloaded() % dict(names=names)\n        if (\n            not showInfo(\n                q,\n                parent=parent,\n                title=tr.addons_install_anki_addon(),\n                type=\"warning\",\n                customBtns=[\n                    QMessageBox.StandardButton.No,\n                    QMessageBox.StandardButton.Yes,\n                ],\n            )\n            == QMessageBox.StandardButton.Yes\n        ):\n            return False\n\n    log, errs = addonsManager.processPackages(\n        paths, parent=parent, force_enable=force_enable\n    )\n\n    if log:\n        log_html = \"<br>\".join(log)\n        if advise_restart:\n            log_html += f\"<br><br>{tr.addons_please_restart_anki_to_complete_the()}\"\n        if len(log) == 1 and not strictly_modal:\n            tooltip(log_html, parent=parent)\n        else:\n            showInfo(\n                log_html,\n                parent=parent,\n                textFormat=\"rich\",\n                title=tr.addons_installation_complete(),\n            )\n    if errs:\n        msg = tr.addons_please_report_this_to_the_respective()\n        showWarning(\n            \"<br><br>\".join(errs + [msg]),\n            parent=parent,\n            textFormat=\"rich\",\n            title=tr.addons_addon_installation_error(),\n        )\n\n    return not errs\n"
  },
  {
    "path": "qt/aqt/ankihub.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport functools\nfrom concurrent.futures import Future\nfrom typing import Callable\n\nimport aqt\nimport aqt.main\nfrom aqt.addons import (\n    AddonManager,\n    DownloadLogEntry,\n    install_or_update_addon,\n    show_log_to_user,\n)\nfrom aqt.qt import (\n    QDialog,\n    QDialogButtonBox,\n    QGridLayout,\n    QLabel,\n    QLineEdit,\n    QPushButton,\n    Qt,\n    QVBoxLayout,\n    QWidget,\n    qconnect,\n)\nfrom aqt.utils import disable_help_button, showWarning, tr\n\n\ndef ankihub_login(\n    mw: aqt.main.AnkiQt,\n    on_success: Callable[[], None],\n    username: str = \"\",\n    password: str = \"\",\n) -> None:\n    def on_future_done(fut: Future[str], username: str, password: str) -> None:\n        try:\n            token = fut.result()\n        except Exception as exc:\n            showWarning(str(exc))\n            return\n\n        if not token:\n            showWarning(tr.sync_ankihub_login_failed(), parent=mw)\n            ankihub_login(mw, on_success, username, password)\n            return\n        mw.pm.set_ankihub_token(token)\n        mw.pm.set_ankihub_username(username)\n        install_ankihub_addon(mw, mw.addonManager)\n        on_success()\n\n    def callback(username: str, password: str) -> None:\n        if not username and not password:\n            return\n        if username and password:\n            mw.taskman.with_progress(\n                lambda: mw.col.ankihub_login(id=username, password=password),\n                functools.partial(on_future_done, username=username, password=password),\n                parent=mw,\n            )\n        else:\n            ankihub_login(mw, on_success, username, password)\n\n    get_id_and_pass_from_user(mw, callback, username, password)\n\n\ndef ankihub_logout(\n    mw: aqt.main.AnkiQt,\n    on_success: Callable[[], None],\n    token: str,\n) -> None:\n    def logout() -> None:\n        mw.pm.set_ankihub_username(None)\n        mw.pm.set_ankihub_token(None)\n        mw.col.ankihub_logout(token=token)\n\n    mw.taskman.with_progress(\n        logout,\n        # We don't need to wait for the response\n        lambda _: on_success(),\n        parent=mw,\n    )\n\n\ndef get_id_and_pass_from_user(\n    mw: aqt.main.AnkiQt,\n    callback: Callable[[str, str], None],\n    username: str = \"\",\n    password: str = \"\",\n) -> None:\n    diag = QDialog(mw)\n    diag.setWindowTitle(\"Anki\")\n    disable_help_button(diag)\n    diag.setWindowModality(Qt.WindowModality.WindowModal)\n    diag.setMinimumWidth(600)\n    vbox = QVBoxLayout()\n    info_label = QLabel(f\"<h1>{tr.sync_ankihub_dialog_heading()}</h1>\")\n    info_label.setOpenExternalLinks(True)\n    info_label.setWordWrap(True)\n    vbox.addWidget(info_label)\n    vbox.addSpacing(20)\n    g = QGridLayout()\n    l1 = QLabel(tr.sync_ankihub_username_label())\n    g.addWidget(l1, 0, 0)\n    user = QLineEdit()\n    user.setText(username)\n    g.addWidget(user, 0, 1)\n    l2 = QLabel(tr.sync_password_label())\n    g.addWidget(l2, 1, 0)\n    passwd = QLineEdit()\n    passwd.setText(password)\n    passwd.setEchoMode(QLineEdit.EchoMode.Password)\n    g.addWidget(passwd, 1, 1)\n    vbox.addLayout(g)\n\n    vbox.addSpacing(20)\n    bb = QDialogButtonBox()  # type: ignore\n    sign_in_button = QPushButton(tr.sync_sign_in())\n    sign_in_button.setAutoDefault(True)\n    bb.addButton(\n        QPushButton(tr.actions_cancel()),\n        QDialogButtonBox.ButtonRole.RejectRole,\n    )\n    bb.addButton(\n        sign_in_button,\n        QDialogButtonBox.ButtonRole.AcceptRole,\n    )\n    qconnect(bb.accepted, diag.accept)\n    qconnect(bb.rejected, diag.reject)\n    vbox.addWidget(bb)\n\n    diag.setLayout(vbox)\n    diag.adjustSize()\n    diag.show()\n    user.setFocus()\n\n    def on_finished(result: int) -> None:\n        if result == QDialog.DialogCode.Rejected:\n            callback(\"\", \"\")\n        else:\n            callback(user.text().strip(), passwd.text())\n\n    qconnect(diag.finished, on_finished)\n    diag.open()\n\n\ndef install_ankihub_addon(parent: QWidget, mgr: AddonManager) -> None:\n    def on_done(log: list[DownloadLogEntry]) -> None:\n        if log:\n            show_log_to_user(parent, log, title=tr.sync_ankihub_addon_installation())\n\n    install_or_update_addon(parent, mgr, 1322529746, on_done)\n"
  },
  {
    "path": "qt/aqt/browser/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\n# ruff: noqa: F401\nimport sys\n\nimport aqt\n\nfrom .browser import Browser, PreviewDialog\n\n# aliases for legacy pathnames\nfrom .sidebar import (\n    SidebarItem,\n    SidebarItemType,\n    SidebarModel,\n    SidebarSearchBar,\n    SidebarStage,\n    SidebarTool,\n    SidebarToolbar,\n    SidebarTreeView,\n)\nfrom .table import (\n    CardState,\n    Cell,\n    CellRow,\n    Column,\n    Columns,\n    DataModel,\n    ItemId,\n    ItemList,\n    ItemState,\n    NoteState,\n    SearchContext,\n    StatusDelegate,\n    Table,\n)\n\nsys.modules[\"aqt.sidebar\"] = sys.modules[\"aqt.browser.sidebar\"]\naqt.sidebar = sys.modules[\"aqt.browser.sidebar\"]  # type: ignore\nsys.modules[\"aqt.previewer\"] = sys.modules[\"aqt.browser.previewer\"]\naqt.previewer = sys.modules[\"aqt.browser.previewer\"]  # type: ignore\n"
  },
  {
    "path": "qt/aqt/browser/browser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport functools\nimport json\nimport math\nimport re\nfrom collections.abc import Callable, Sequence\nfrom typing import Any, cast\n\nfrom markdown import markdown\n\nimport aqt\nimport aqt.browser\nimport aqt.editor\nimport aqt.forms\nimport aqt.operations\nfrom anki._legacy import deprecated\nfrom anki.cards import Card, CardId\nfrom anki.collection import Collection, Config, OpChanges, SearchNode\nfrom anki.consts import *\nfrom anki.decks import DeckId\nfrom anki.errors import NotFoundError, SearchError\nfrom anki.lang import without_unicode_isolation\nfrom anki.models import NotetypeId\nfrom anki.notes import NoteId\nfrom anki.scheduler.base import ScheduleCardsAsNew\nfrom anki.tags import MARKED_TAG\nfrom anki.utils import is_mac\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.editor import Editor, EditorWebView\nfrom aqt.errors import show_exception\nfrom aqt.exporting import ExportDialog as LegacyExportDialog\nfrom aqt.import_export.exporting import ExportDialog\nfrom aqt.operations.card import set_card_deck, set_card_flag\nfrom aqt.operations.collection import redo, undo\nfrom aqt.operations.note import remove_notes\nfrom aqt.operations.scheduling import (\n    bury_cards,\n    forget_cards,\n    grade_now,\n    reposition_new_cards_dialog,\n    set_due_date_dialog,\n    suspend_cards,\n    unbury_cards,\n    unsuspend_cards,\n)\nfrom aqt.operations.tag import (\n    add_tags_to_notes,\n    clear_unused_tags,\n    remove_tags_from_notes,\n)\nfrom aqt.qt import *\nfrom aqt.sound import av_player\nfrom aqt.switch import Switch\nfrom aqt.theme import WidgetStyle\nfrom aqt.undo import UndoActionsInfo\nfrom aqt.utils import (\n    HelpPage,\n    KeyboardModifiersPressed,\n    add_ellipsis_to_action_label,\n    current_window,\n    ensure_editor_saved,\n    getTag,\n    no_arg_trigger,\n    openHelp,\n    qtMenuShortcutWorkaround,\n    restoreGeom,\n    restoreSplitter,\n    restoreState,\n    saveGeom,\n    saveSplitter,\n    saveState,\n    showWarning,\n    skip_if_selection_is_empty,\n    tooltip,\n    tr,\n)\n\nfrom ..addcards import AddCards\nfrom ..changenotetype import change_notetype_dialog\nfrom .card_info import BrowserCardInfo\nfrom .find_and_replace import FindAndReplaceDialog\nfrom .layout import BrowserLayout, QSplitterHandleEventFilter\nfrom .previewer import BrowserPreviewer as PreviewDialog\nfrom .previewer import Previewer\nfrom .sidebar import SidebarTreeView\nfrom .table import Table\n\n\nclass MockModel:\n    \"\"\"This class only exists to support some legacy aliases.\"\"\"\n\n    def __init__(self, browser: aqt.browser.Browser) -> None:\n        self.browser = browser\n\n    @deprecated(replaced_by=aqt.operations.CollectionOp)\n    def beginReset(self) -> None:\n        self.browser.begin_reset()\n\n    @deprecated(replaced_by=aqt.operations.CollectionOp)\n    def endReset(self) -> None:\n        self.browser.end_reset()\n\n    @deprecated(replaced_by=aqt.operations.CollectionOp)\n    def reset(self) -> None:\n        self.browser.begin_reset()\n        self.browser.end_reset()\n\n\nclass Browser(QMainWindow):\n    mw: AnkiQt\n    col: Collection\n    editor: Editor | None\n    table: Table\n\n    def __init__(\n        self,\n        mw: AnkiQt,\n        card: Card | None = None,\n        search: tuple[str | SearchNode] | None = None,\n    ) -> None:\n        \"\"\"\n        card -- try to select the provided card after executing \"search\" or\n                \"deck:current\" (if \"search\" was None)\n        search -- set and perform search; caller must ensure validity\n        \"\"\"\n\n        QMainWindow.__init__(self, None, Qt.WindowType.Window)\n        self.mw = mw\n        self.col = self.mw.col\n        self.lastFilter = \"\"\n        self.focusTo: int | None = None\n        self._previewer: Previewer | None = None\n        self._card_info = BrowserCardInfo(self.mw)\n        self._closeEventHasCleanedUp = False\n        self.auto_layout = True\n        self.aspect_ratio = 0.0\n        self.form = aqt.forms.browser.Ui_Dialog()\n        self.form.setupUi(self)\n        self.form.splitter.setChildrenCollapsible(False)\n        splitter_handle_event_filter = QSplitterHandleEventFilter(self.form.splitter)\n\n        splitter_handle = self.form.splitter.handle(1)\n        assert splitter_handle is not None\n\n        splitter_handle.installEventFilter(splitter_handle_event_filter)\n        # set if exactly 1 row is selected; used by the previewer\n        self.card: Card | None = None\n        self.current_card: Card | None = None\n        self.setupSidebar()\n        self.setup_table()\n        self.setupMenus()\n        self.setupHooks()\n        self.setupEditor()\n        gui_hooks.browser_will_show(self)\n\n        # restoreXXX() should be called after all child widgets have been created\n        # and attached to QMainWindow\n        self._editor_state_key = (\n            \"editorRTL\"\n            if self.layoutDirection() == Qt.LayoutDirection.RightToLeft\n            else \"editor\"\n        )\n        restoreGeom(self, self._editor_state_key)\n        restoreSplitter(self.form.splitter, \"editor3\")\n        restoreState(self, self._editor_state_key)\n\n        # responsive layout\n        if self.height() != 0:\n            self.aspect_ratio = self.width() / self.height()\n        self.set_layout(self.mw.pm.browser_layout(), True)\n        self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden())\n        # disable undo/redo\n        self.on_undo_state_change(mw.undo_actions_info())\n        # legacy alias\n        self.model = MockModel(self)\n        self.setupSearch(card, search)\n        self.show()\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        focused = current_window() == self\n        self.table.op_executed(changes, handler, focused)\n        self.sidebar.op_executed(changes, handler, focused)\n        if changes.note_text:\n            if handler is not self.editor:\n                # fixme: this will leave the splitter shown, but with no current\n                # note being edited\n                assert self.editor is not None\n\n                note = self.editor.note\n                if note:\n                    try:\n                        note.load()\n                    except NotFoundError:\n                        self.editor.set_note(None)\n                        return\n                    self.editor.set_note(note)\n\n        if changes.browser_table and changes.card:\n            self.card = self.table.get_single_selected_card()\n            self.current_card = self.table.get_current_card()\n            self._update_card_info()\n            self._update_current_actions()\n\n        # changes.card is required for updating flag icon\n        if changes.note_text or changes.card:\n            self._renderPreview()\n\n    def on_focus_change(self, new: QWidget | None, old: QWidget | None) -> None:\n        if current_window() == self:\n            self.setUpdatesEnabled(True)\n            self.table.redraw_cells()\n            self.sidebar.refresh_if_needed()\n\n    def set_layout(self, mode: BrowserLayout, init: bool = False) -> None:\n        self.mw.pm.set_browser_layout(mode)\n\n        if mode == BrowserLayout.AUTO:\n            self.auto_layout = True\n            self.maybe_update_layout(self.aspect_ratio, True)\n            self.form.actionLayoutAuto.setChecked(True)\n            self.form.actionLayoutVertical.setChecked(False)\n            self.form.actionLayoutHorizontal.setChecked(False)\n            if not init:\n                tooltip(tr.qt_misc_layout_auto_enabled())\n        else:\n            self.auto_layout = False\n            self.form.actionLayoutAuto.setChecked(False)\n\n            if mode == BrowserLayout.VERTICAL:\n                self.form.splitter.setOrientation(Qt.Orientation.Vertical)\n                self.form.actionLayoutVertical.setChecked(True)\n                self.form.actionLayoutHorizontal.setChecked(False)\n                if not init:\n                    tooltip(tr.qt_misc_layout_vertical_enabled())\n\n            elif mode == BrowserLayout.HORIZONTAL:\n                self.form.splitter.setOrientation(Qt.Orientation.Horizontal)\n                self.form.actionLayoutHorizontal.setChecked(True)\n                self.form.actionLayoutVertical.setChecked(False)\n                if not init:\n                    tooltip(tr.qt_misc_layout_horizontal_enabled())\n\n    def maybe_update_layout(self, aspect_ratio: float, force: bool = False) -> None:\n        if force or math.floor(aspect_ratio) != math.floor(self.aspect_ratio):\n            if aspect_ratio < 1:\n                self.form.splitter.setOrientation(Qt.Orientation.Vertical)\n            else:\n                self.form.splitter.setOrientation(Qt.Orientation.Horizontal)\n\n    def resizeEvent(self, event: QResizeEvent | None) -> None:\n        assert event is not None\n\n        if self.height() != 0:\n            aspect_ratio = self.width() / self.height()\n\n            if self.auto_layout:\n                self.maybe_update_layout(aspect_ratio)\n\n            self.aspect_ratio = aspect_ratio\n\n        QMainWindow.resizeEvent(self, event)\n\n    def get_active_note_type_id(self) -> NotetypeId | None:\n        \"\"\"\n        If multiple cards are selected the note type will be derived\n        from the final card selected\n        \"\"\"\n        if current_note := self.table.get_current_note():\n            return current_note.mid\n\n        return None\n\n    def add_card(self, deck_id: DeckId):\n        add_cards = cast(AddCards, aqt.dialogs.open(\"AddCards\", self.mw))\n        add_cards.set_deck(deck_id)\n\n        if note_type_id := self.get_active_note_type_id():\n            add_cards.set_note_type(note_type_id)\n\n    # If in the Browser we open Preview and press Ctrl+W there,\n    # both Preview and Browser windows get closed by Qt out of the box.\n    # We circumvent that behavior by only closing the currently active window\n    def _handle_close(self):\n        active_window = QApplication.activeWindow()\n        if active_window and active_window != self:\n            if isinstance(active_window, QDialog):\n                active_window.reject()\n            else:\n                active_window.close()\n        else:\n            self.close()\n\n    def setupMenus(self) -> None:\n        # actions\n        f = self.form\n\n        # edit\n        qconnect(f.actionUndo.triggered, self.undo)\n        qconnect(f.actionRedo.triggered, self.redo)\n        qconnect(f.actionInvertSelection.triggered, self.table.invert_selection)\n        qconnect(f.actionSelectNotes.triggered, self.selectNotes)\n        if not is_mac:\n            f.actionClose.setVisible(False)\n        qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck)\n        f.actionCreateFilteredDeck.setShortcuts([\"Ctrl+G\", \"Ctrl+Alt+G\"])\n\n        # view\n        qconnect(f.actionFullScreen.triggered, self.mw.on_toggle_full_screen)\n        qconnect(\n            f.actionZoomIn.triggered,\n            lambda: self._editor_web_view().setZoomFactor(\n                self._editor_web_view().zoomFactor() + 0.1\n            ),\n        )\n        qconnect(\n            f.actionZoomOut.triggered,\n            lambda: self._editor_web_view().setZoomFactor(\n                self._editor_web_view().zoomFactor() - 0.1\n            ),\n        )\n        qconnect(\n            f.actionResetZoom.triggered,\n            lambda: self._editor_web_view().setZoomFactor(1),\n        )\n        qconnect(\n            self.form.actionLayoutAuto.triggered,\n            lambda: self.set_layout(BrowserLayout.AUTO),\n        )\n        qconnect(\n            self.form.actionLayoutVertical.triggered,\n            lambda: self.set_layout(BrowserLayout.VERTICAL),\n        )\n        qconnect(\n            self.form.actionLayoutHorizontal.triggered,\n            lambda: self.set_layout(BrowserLayout.HORIZONTAL),\n        )\n\n        # notes\n        qconnect(f.actionAdd.triggered, self.mw.onAddCard)\n        qconnect(f.actionCopy.triggered, self.on_create_copy)\n        qconnect(f.actionAdd_Tags.triggered, self.add_tags_to_selected_notes)\n        qconnect(f.actionRemove_Tags.triggered, self.remove_tags_from_selected_notes)\n        qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags)\n        qconnect(f.actionToggle_Mark.triggered, self.toggle_mark_of_selected_notes)\n        qconnect(f.actionChangeModel.triggered, self.onChangeModel)\n        qconnect(f.actionFindDuplicates.triggered, self.onFindDupes)\n        qconnect(f.actionFindReplace.triggered, self.onFindReplace)\n        qconnect(f.actionManage_Note_Types.triggered, self.mw.onNoteTypes)\n        qconnect(f.actionDelete.triggered, self.delete_selected_notes)\n\n        # cards\n        qconnect(f.actionChange_Deck.triggered, self.set_deck_of_selected_cards)\n        qconnect(f.action_Info.triggered, self.showCardInfo)\n        qconnect(f.actionReposition.triggered, self.reposition)\n        qconnect(f.action_set_due_date.triggered, self.set_due_date)\n        qconnect(f.action_grade_now.triggered, self.grade_now)\n        qconnect(f.action_forget.triggered, self.forget_cards)\n        qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards)\n        qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards)\n\n        def set_flag_func(desired_flag: int) -> Callable:\n            return lambda: self.set_flag_of_selected_cards(desired_flag)\n\n        for flag in self.mw.flags.all():\n            qconnect(\n                getattr(self.form, flag.action).triggered, set_flag_func(flag.index)\n            )\n        self._update_flag_labels()\n        qconnect(f.actionExport.triggered, self._on_export_notes)\n\n        # jumps\n        qconnect(f.actionPreviousCard.triggered, self.onPreviousCard)\n        qconnect(f.actionNextCard.triggered, self.onNextCard)\n        qconnect(f.actionFirstCard.triggered, self.onFirstCard)\n        qconnect(f.actionLastCard.triggered, self.onLastCard)\n        qconnect(f.actionFind.triggered, self.onFind)\n        qconnect(f.actionNote.triggered, self.onNote)\n        qconnect(f.actionSidebar.triggered, self.focusSidebar)\n        qconnect(f.actionToggleSidebar.triggered, self.toggle_sidebar)\n        qconnect(f.actionCardList.triggered, self.onCardList)\n\n        # help\n        qconnect(f.actionGuide.triggered, self.onHelp)\n\n        # keyboard shortcut for shift+home/end\n        self.pgUpCut = QShortcut(QKeySequence(\"Shift+Home\"), self)\n        qconnect(self.pgUpCut.activated, self.onFirstCard)\n        self.pgDownCut = QShortcut(QKeySequence(\"Shift+End\"), self)\n        qconnect(self.pgDownCut.activated, self.onLastCard)\n\n        # add-on hook\n        gui_hooks.browser_menus_did_init(self)\n        self.mw.maybeHideAccelerators(self)\n\n        add_ellipsis_to_action_label(f.actionCopy)\n        add_ellipsis_to_action_label(f.action_forget)\n        add_ellipsis_to_action_label(f.action_grade_now)\n\n    def _editor_web_view(self) -> EditorWebView:\n        assert self.editor is not None\n        editor_web_view = self.editor.web\n        assert editor_web_view is not None\n        return editor_web_view\n\n    def closeEvent(self, evt: QCloseEvent | None) -> None:\n        assert evt is not None\n\n        if self._closeEventHasCleanedUp:\n            evt.accept()\n            return\n\n        assert self.editor is not None\n\n        self.editor.call_after_note_saved(self._closeWindow)\n        evt.ignore()\n\n    def _closeWindow(self) -> None:\n        assert self.editor is not None\n\n        self._cleanup_preview()\n        self._card_info.close()\n        self.editor.cleanup()\n        self.table.cleanup()\n        self.sidebar.cleanup()\n        saveSplitter(self.form.splitter, \"editor3\")\n        saveGeom(self, self._editor_state_key)\n        saveState(self, self._editor_state_key)\n        self.teardownHooks()\n        self.mw.maybeReset()\n        aqt.dialogs.markClosed(\"Browser\")\n        self._closeEventHasCleanedUp = True\n        self.mw.deferred_delete_and_garbage_collect(self)\n        self.close()\n\n    @ensure_editor_saved\n    def closeWithCallback(self, onsuccess: Callable) -> None:\n        self._closeWindow()\n        onsuccess()\n\n    def keyPressEvent(self, evt: QKeyEvent | None) -> None:\n        assert evt is not None\n\n        if evt.key() == Qt.Key.Key_Escape:\n            self.close()\n        else:\n            super().keyPressEvent(evt)\n\n    def reopen(\n        self,\n        _mw: AnkiQt,\n        card: Card | None = None,\n        search: tuple[str | SearchNode] | None = None,\n    ) -> None:\n        if search is not None:\n            self.search_for_terms(*search)\n            self.form.searchEdit.setFocus()\n        if card is not None:\n            if search is None:\n                # implicitly assume 'card' is in the current deck\n                self._default_search(card)\n                self.form.searchEdit.setFocus()\n            self.table.select_single_card(card.id)\n\n    # Searching\n    ######################################################################\n\n    def setupSearch(\n        self,\n        card: Card | None = None,\n        search: tuple[str | SearchNode] | None = None,\n    ) -> None:\n        assert self.mw.pm.profile is not None\n\n        line_edit = self._line_edit()\n        qconnect(line_edit.returnPressed, self.onSearchActivated)\n        self.form.searchEdit.setCompleter(None)\n        line_edit.setPlaceholderText(tr.browsing_search_bar_hint())\n        line_edit.setMaxLength(2000000)\n        self.form.searchEdit.addItems(\n            [\"\"] + self.mw.pm.profile.get(\"searchHistory\", [])\n        )\n        if search is not None:\n            self.search_for_terms(*search)\n        else:\n            self._default_search(card)\n        self.form.searchEdit.setFocus()\n        if card:\n            self.table.select_single_card(card.id)\n\n    # search triggered by user\n    @ensure_editor_saved\n    def onSearchActivated(self) -> None:\n        text = self.current_search()\n        try:\n            normed = self.col.build_search_string(text)\n        except SearchError as err:\n            showWarning(markdown(str(err)))\n        except Exception as err:\n            showWarning(str(err))\n        else:\n            self.search_for(normed)\n            self.update_history()\n\n    def search_for(self, search: str, prompt: str | None = None) -> None:\n        \"\"\"Keep track of search string so that we reuse identical search when\n        refreshing, rather than whatever is currently in the search field.\n        Optionally set the search bar to a different text than the actual search.\n        \"\"\"\n\n        self._lastSearchTxt = search\n        prompt = search if prompt is None else prompt\n        self.form.searchEdit.setCurrentIndex(-1)\n        self._line_edit().setText(prompt)\n        self.search()\n\n    def current_search(self) -> str:\n        return self._line_edit().text().replace(\"\\n\", \" \")\n\n    def search(self) -> None:\n        \"\"\"Search triggered programmatically. Caller must have saved note first.\"\"\"\n\n        try:\n            self.table.search(self._lastSearchTxt)\n        except Exception as err:\n            showWarning(str(err))\n\n    def update_history(self) -> None:\n        assert self.mw.pm.profile is not None\n\n        sh = self.mw.pm.profile.get(\"searchHistory\", [])\n        if self._lastSearchTxt in sh:\n            sh.remove(self._lastSearchTxt)\n        sh.insert(0, self._lastSearchTxt)\n        sh = sh[:30]\n        self.form.searchEdit.clear()\n        self.form.searchEdit.addItems(sh)\n        self.mw.pm.profile[\"searchHistory\"] = sh\n\n    def updateTitle(self) -> None:\n        selected = self.table.len_selection()\n        cur = self.table.len()\n        tr_title = (\n            tr.browsing_window_title_notes\n            if self.table.is_notes_mode()\n            else tr.browsing_window_title\n        )\n        self.setWindowTitle(\n            without_unicode_isolation(tr_title(total=cur, selected=selected))\n        )\n\n    def search_for_terms(self, *search_terms: str | SearchNode) -> None:\n        search = self.col.build_search_string(*search_terms)\n        self.form.searchEdit.setEditText(search)\n        self.onSearchActivated()\n\n    def _default_search(self, card: Card | None = None) -> None:\n        default = self.col.get_config_string(Config.String.DEFAULT_SEARCH_TEXT)\n        if default.strip():\n            search = default\n            prompt = default\n        else:\n            search = self.col.build_search_string(SearchNode(deck=\"current\"))\n            prompt = \"\"\n        if card is not None:\n            search = gui_hooks.default_search(search, card)\n        self.search_for(search, prompt)\n\n    def onReset(self) -> None:\n        self.sidebar.refresh()\n        self.begin_reset()\n        self.end_reset()\n\n    # caller must have called editor.saveNow() before calling this or .reset()\n    def begin_reset(self) -> None:\n        assert self.editor is not None\n\n        self.editor.set_note(None, hide=False)\n        self.mw.progress.start()\n        self.table.begin_reset()\n\n    def end_reset(self) -> None:\n        self.table.end_reset()\n        self.mw.progress.finish()\n\n    # Table & Editor\n    ######################################################################\n\n    def setup_table(self) -> None:\n        self.table = Table(self)\n        self.table.set_view(self.form.tableView)\n        self._switch = switch = Switch(12, tr.browsing_cards(), tr.browsing_notes())\n        switch.setChecked(self.table.is_notes_mode())\n        switch.setToolTip(tr.browsing_toggle_showing_cards_notes())\n        qconnect(self.form.action_toggle_mode.triggered, switch.toggle)\n        qconnect(switch.toggled, self.on_table_state_changed)\n        self.form.gridLayout.addWidget(switch, 0, 0)\n\n    def setupEditor(self) -> None:\n        QShortcut(QKeySequence(\"Ctrl+Shift+P\"), self, self.onTogglePreview)\n\n        def add_preview_button(editor: Editor) -> None:\n            editor._links[\"preview\"] = lambda _editor: self.onTogglePreview()\n\n        gui_hooks.editor_did_init.append(add_preview_button)\n        self.editor = aqt.editor.Editor(\n            self.mw,\n            self.form.fieldsArea,\n            self,\n            editor_mode=aqt.editor.EditorMode.BROWSER,\n        )\n        gui_hooks.editor_did_init.remove(add_preview_button)\n\n    @ensure_editor_saved\n    def on_all_or_selected_rows_changed(self) -> None:\n        \"\"\"Called after the selected or all rows (searching, toggling mode) have\n        changed. Update window title, card preview, context actions, and editor.\n        \"\"\"\n        if self._closeEventHasCleanedUp:\n            return\n\n        self.updateTitle()\n        # if there is only one selected card, use it in the editor\n        # it might differ from the current card\n        self.card = self.table.get_single_selected_card()\n        self.singleCard = bool(self.card)\n\n        splitter_widget = self.form.splitter.widget(1)\n        assert splitter_widget is not None\n\n        splitter_widget.setVisible(self.singleCard)\n\n        assert self.editor is not None\n\n        if self.singleCard:\n            assert self.card is not None\n\n            self.editor.set_note(self.card.note(), focusTo=self.focusTo)\n            self.focusTo = None\n            self.editor.card = self.card\n        else:\n            self.editor.set_note(None)\n        self._renderPreview()\n        self._update_row_actions()\n        self._update_selection_actions()\n        gui_hooks.browser_did_change_row(self)\n\n    @deprecated(info=\"please use on_all_or_selected_rows_changed() instead.\")\n    def onRowChanged(self, *args: Any) -> None:\n        self.on_all_or_selected_rows_changed()\n\n    def on_current_row_changed(self) -> None:\n        \"\"\"Called after the row of the current element has changed.\"\"\"\n        if self._closeEventHasCleanedUp:\n            return\n        self.current_card = self.table.get_current_card()\n        self._update_current_actions()\n        self._update_card_info()\n\n    def _update_row_actions(self) -> None:\n        has_rows = bool(self.table.len())\n        self.form.actionSelectAll.setEnabled(has_rows)\n        self.form.actionInvertSelection.setEnabled(has_rows)\n        self.form.actionFirstCard.setEnabled(has_rows)\n        self.form.actionLastCard.setEnabled(has_rows)\n\n    def _update_selection_actions(self) -> None:\n        has_selection = bool(self.table.len_selection())\n        self.form.actionSelectNotes.setEnabled(has_selection)\n        self.form.actionExport.setEnabled(has_selection)\n        self.form.actionAdd_Tags.setEnabled(has_selection)\n        self.form.actionRemove_Tags.setEnabled(has_selection)\n        self.form.actionToggle_Mark.setEnabled(has_selection)\n        self.form.actionChangeModel.setEnabled(has_selection)\n        self.form.actionDelete.setEnabled(has_selection)\n        self.form.actionChange_Deck.setEnabled(has_selection)\n        self.form.action_set_due_date.setEnabled(has_selection)\n        self.form.action_forget.setEnabled(has_selection)\n        self.form.actionReposition.setEnabled(has_selection)\n        self.form.actionToggle_Suspend.setEnabled(has_selection)\n        self.form.action_toggle_bury.setEnabled(has_selection)\n        self.form.menuFlag.setEnabled(has_selection)\n\n    def _update_current_actions(self) -> None:\n        self._update_flags_menu()\n        self._update_toggle_bury_action()\n        self._update_toggle_mark_action()\n        self._update_toggle_suspend_action()\n        self.form.actionCopy.setEnabled(self.table.has_current())\n        self.form.action_Info.setEnabled(self.table.has_current())\n        self.form.actionPreviousCard.setEnabled(self.table.has_previous())\n        self.form.actionNextCard.setEnabled(self.table.has_next())\n\n    @ensure_editor_saved\n    def on_table_state_changed(self, checked: bool) -> None:\n        self.mw.progress.start()\n        try:\n            self.table.toggle_state(checked, self._lastSearchTxt)\n        except Exception as err:\n            self.mw.progress.finish()\n            self._switch.blockSignals(True)\n            self._switch.toggle()\n            self._switch.blockSignals(False)\n            show_exception(parent=self, exception=err)\n        else:\n            self.mw.progress.finish()\n\n    # Sidebar\n    ######################################################################\n\n    def setupSidebar(self) -> None:\n        dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)\n        dw.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)\n        dw.setObjectName(\"Sidebar\")\n        dock_area = (\n            Qt.DockWidgetArea.RightDockWidgetArea\n            if self.layoutDirection() == Qt.LayoutDirection.RightToLeft\n            else Qt.DockWidgetArea.LeftDockWidgetArea\n        )\n        dw.setAllowedAreas(dock_area)\n\n        self.sidebar = SidebarTreeView(self)\n        self.sidebarTree = self.sidebar  # legacy alias\n        dw.setWidget(self.sidebar)\n        qconnect(\n            self.form.actionSidebarFilter.triggered,\n            self.focusSidebarSearchBar,\n        )\n        qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange)\n        grid = QGridLayout()\n        grid.addWidget(self.sidebar.searchBar, 0, 0)\n        grid.addWidget(self.sidebar.toolbar, 0, 1)\n        grid.addWidget(self.sidebar, 1, 0, 1, 2)\n        grid.setContentsMargins(8, 4, 0, 0)\n        grid.setSpacing(0)\n        w = QWidget()\n        w.setLayout(grid)\n        dw.setWidget(w)\n        self.sidebarDockWidget.setFloating(False)\n\n        self.sidebarDockWidget.setTitleBarWidget(QWidget())\n        self.addDockWidget(dock_area, dw)\n\n        # schedule sidebar to refresh after browser window has loaded, so the\n        # UI is more responsive\n        self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)\n\n    def showSidebar(self, show: bool = True) -> None:\n        self.sidebarDockWidget.setVisible(show)\n\n    def onSidebarVisibilityChange(self, visible):\n        margins = self.form.verticalLayout_3.contentsMargins()\n        skip_left_margin = visible and not (\n            is_mac and aqt.mw.pm.get_widget_style() == WidgetStyle.NATIVE\n        )\n        margins.setLeft(0 if skip_left_margin else margins.right())\n        self.form.verticalLayout_3.setContentsMargins(margins)\n\n        if visible:\n            self.sidebar.refresh()\n\n    def focusSidebar(self) -> None:\n        self.showSidebar()\n        self.sidebar.setFocus()\n\n    def focusSidebarSearchBar(self) -> None:\n        self.showSidebar()\n        self.sidebar.searchBar.setFocus()\n\n    def toggle_sidebar(self) -> None:\n        self.showSidebar(not self.sidebarDockWidget.isVisible())\n\n    # legacy\n\n    def setFilter(self, *terms: str) -> None:\n        self.sidebar.update_search(*terms)\n\n    # Info\n    ######################################################################\n\n    def showCardInfo(self) -> None:\n        self._card_info.show()\n\n    def _update_card_info(self) -> None:\n        self._card_info.set_card(self.current_card)\n\n    # Menu helpers\n    ######################################################################\n\n    def selected_cards(self) -> Sequence[CardId]:\n        return self.table.get_selected_card_ids()\n\n    def selected_notes(self) -> Sequence[NoteId]:\n        return self.table.get_selected_note_ids()\n\n    def selectedNotesAsCards(self) -> Sequence[CardId]:\n        return self.table.get_card_ids_from_selected_note_ids()\n\n    def onHelp(self) -> None:\n        openHelp(HelpPage.BROWSING)\n\n    # legacy\n\n    selectedCards = selected_cards\n    selectedNotes = selected_notes\n\n    # Misc menu options\n    ######################################################################\n\n    def on_create_copy(self) -> None:\n        if note := self.table.get_current_note():\n            current_card = self.table.get_current_card()\n            assert current_card is not None\n\n            deck_id = current_card.current_deck_id()\n            aqt.dialogs.open(\"AddCards\", self.mw).set_note(note, deck_id)\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def onChangeModel(self) -> None:\n        ids = self.selected_notes()\n        change_notetype_dialog(parent=self, note_ids=ids)\n\n    def createFilteredDeck(self) -> None:\n        search = self.current_search()\n        if KeyboardModifiersPressed().alt:\n            aqt.dialogs.open(\"FilteredDeckConfigDialog\", self.mw, search_2=search)\n        else:\n            aqt.dialogs.open(\"FilteredDeckConfigDialog\", self.mw, search=search)\n\n    # Preview\n    ######################################################################\n\n    def onTogglePreview(self) -> None:\n        assert self.editor is not None\n\n        if self._previewer:\n            self._previewer.close()\n        elif self.editor.note:\n            self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed)\n            self._previewer.open()\n            self.toggle_preview_button_state(True)\n\n    def _renderPreview(self) -> None:\n        if self._previewer:\n            if self.singleCard:\n                self._previewer.render_card()\n            else:\n                self.onTogglePreview()\n\n    def toggle_preview_button_state(self, active: bool) -> None:\n        assert self.editor is not None\n\n        if self.editor.web:\n            self.editor.web.eval(f\"togglePreviewButtonState({json.dumps(active)});\")\n\n    def _cleanup_preview(self) -> None:\n        if self._previewer:\n            self._previewer.cancel_timer()\n            self._previewer.close()\n\n    def _on_preview_closed(self) -> None:\n        av_player.stop_and_clear_queue()\n        self.toggle_preview_button_state(False)\n        self._previewer = None\n\n    # Card deletion\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    def delete_selected_notes(self) -> None:\n        # ensure deletion is not accidentally triggered when the user is focused\n        # in the editing screen or search bar\n        focus = self.focusWidget()\n        if focus != self.form.tableView:\n            return\n\n        assert self.editor is not None\n\n        self.editor.set_note(None)\n        nids = self.table.to_row_of_unselected_note()\n        remove_notes(parent=self, note_ids=nids).run_in_background()\n\n    # legacy\n\n    deleteNotes = delete_selected_notes\n\n    # Deck change\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def set_deck_of_selected_cards(self) -> None:\n        from aqt.studydeck import StudyDeck\n\n        assert self.mw.col is not None\n        assert self.mw.col.db is not None\n\n        cids = self.table.get_selected_card_ids()\n        did = self.mw.col.db.scalar(\"select did from cards where id = ?\", cids[0])\n\n        deck_dict = self.mw.col.decks.get(did)\n        assert deck_dict is not None\n\n        current = deck_dict[\"name\"]\n\n        def callback(ret: StudyDeck) -> None:\n            if not ret.name:\n                return\n            did = self.col.decks.id(ret.name)\n\n            assert did is not None\n\n            set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background()\n\n        StudyDeck(\n            self.mw,\n            current=current,\n            accept=tr.browsing_move_cards(),\n            title=tr.browsing_change_deck(),\n            help=HelpPage.BROWSING,\n            parent=self,\n            callback=callback,\n        )\n\n    # legacy\n\n    setDeck = set_deck_of_selected_cards\n\n    # Tags\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def add_tags_to_selected_notes(\n        self,\n        tags: str | None = None,\n    ) -> None:\n        \"Shows prompt if tags not provided.\"\n        if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())):\n            return\n\n        space_separated_tags = re.sub(r\"[ \\n\\t\\v]+\", \" \", tags)\n        add_tags_to_notes(\n            parent=self,\n            note_ids=self.selected_notes(),\n            space_separated_tags=space_separated_tags,\n        ).run_in_background(initiator=self)\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def remove_tags_from_selected_notes(self, tags: str | None = None) -> None:\n        \"Shows prompt if tags not provided.\"\n        if not (\n            tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete())\n        ):\n            return\n\n        remove_tags_from_notes(\n            parent=self, note_ids=self.selected_notes(), space_separated_tags=tags\n        ).run_in_background(initiator=self)\n\n    def _prompt_for_tags(self, prompt: str) -> str | None:\n        (tags, ok) = getTag(self, self.col, prompt)\n        if not ok:\n            return None\n        else:\n            return tags\n\n    @no_arg_trigger\n    @ensure_editor_saved\n    def clear_unused_tags(self) -> None:\n        clear_unused_tags(parent=self).run_in_background()\n\n    addTags = add_tags_to_selected_notes\n    deleteTags = remove_tags_from_selected_notes\n    clearUnusedTags = clear_unused_tags\n\n    # Suspending\n    ######################################################################\n\n    def _update_toggle_suspend_action(self) -> None:\n        is_suspended = bool(\n            self.current_card and self.current_card.queue == QUEUE_TYPE_SUSPENDED\n        )\n        self.form.actionToggle_Suspend.setChecked(is_suspended)\n\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def suspend_selected_cards(self, checked: bool) -> None:\n        cids = self.selected_cards()\n        if checked:\n            suspend_cards(parent=self, card_ids=cids).run_in_background()\n        else:\n            unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background()\n\n    # Burying\n    ######################################################################\n\n    def _update_toggle_bury_action(self) -> None:\n        is_buried = bool(\n            self.current_card\n            and self.current_card.queue\n            in (QUEUE_TYPE_MANUALLY_BURIED, QUEUE_TYPE_SIBLING_BURIED)\n        )\n        self.form.action_toggle_bury.setChecked(is_buried)\n\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def bury_selected_cards(self, checked: bool) -> None:\n        cids = self.selected_cards()\n        if checked:\n            bury_cards(parent=self, card_ids=cids).run_in_background()\n        else:\n            unbury_cards(parent=self.mw, card_ids=cids).run_in_background()\n\n    # Exporting\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    def _on_export_notes(self) -> None:\n        if not self.mw.pm.legacy_import_export():\n            nids = self.selected_notes()\n            ExportDialog(self.mw, nids=nids, parent=self)\n        else:\n            cids = self.selectedNotesAsCards()\n            LegacyExportDialog(self.mw, cids=list(cids), parent=self)\n\n    # Flags & Marking\n    ######################################################################\n\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def set_flag_of_selected_cards(self, flag: int) -> None:\n        if not self.current_card:\n            return\n\n        # flag needs toggling off?\n        if flag == self.current_card.user_flag():\n            flag = 0\n\n        set_card_flag(\n            parent=self, card_ids=self.selected_cards(), flag=flag\n        ).run_in_background()\n\n    def _update_flags_menu(self) -> None:\n        flag = self.current_card and self.current_card.user_flag()\n        flag = flag or 0\n\n        for f in self.mw.flags.all():\n            getattr(self.form, f.action).setChecked(flag == f.index)\n\n        qtMenuShortcutWorkaround(self.form.menuFlag)\n\n    def _update_flag_labels(self) -> None:\n        for flag in self.mw.flags.all():\n            getattr(self.form, flag.action).setText(flag.label)\n\n    def toggle_mark_of_selected_notes(self, checked: bool) -> None:\n        if checked:\n            self.add_tags_to_selected_notes(tags=MARKED_TAG)\n        else:\n            self.remove_tags_from_selected_notes(tags=MARKED_TAG)\n\n    def _update_toggle_mark_action(self) -> None:\n        is_marked = bool(\n            self.current_card and self.current_card.note().has_tag(MARKED_TAG)\n        )\n        self.form.actionToggle_Mark.setChecked(is_marked)\n\n    # Scheduling\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def reposition(self) -> None:\n        if op := reposition_new_cards_dialog(\n            parent=self, card_ids=self.selected_cards()\n        ):\n            op.run_in_background()\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def set_due_date(self) -> None:\n        if op := set_due_date_dialog(\n            parent=self,\n            card_ids=self.selected_cards(),\n            config_key=Config.String.SET_DUE_BROWSER,\n        ):\n            op.run_in_background()\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def forget_cards(self) -> None:\n        if op := forget_cards(\n            parent=self,\n            card_ids=self.selected_cards(),\n            context=ScheduleCardsAsNew.Context.BROWSER,\n        ):\n            op.run_in_background()\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def grade_now(self) -> None:\n        \"\"\"Show dialog to grade selected cards.\"\"\"\n        dialog = QDialog(self)\n        dialog.setWindowTitle(tr.actions_grade_now())\n        layout = QHBoxLayout()\n        dialog.setLayout(layout)\n        # Add grade buttons\n        for ease, label in [\n            (1, tr.studying_again()),\n            (2, tr.studying_hard()),\n            (3, tr.studying_good()),\n            (4, tr.studying_easy()),\n        ]:\n            btn = QPushButton(label)\n\n            def cb(ease: int) -> None:\n                grade_now(\n                    parent=self, card_ids=self.selected_cards(), ease=ease\n                ).run_in_background()\n                dialog.accept()\n\n            qconnect(\n                btn.clicked,\n                functools.partial(cb, ease=ease),\n            )\n            if key := aqt.mw.pm.get_answer_key(ease):\n                QShortcut(key, dialog, activated=btn.click)  # type: ignore\n                btn.setToolTip(tr.actions_shortcut_key(key))\n            layout.addWidget(btn)\n\n        # Add cancel button\n        cancel_btn = QPushButton(tr.actions_cancel())\n        qconnect(cancel_btn.clicked, dialog.reject)\n        layout.addWidget(cancel_btn)\n\n        dialog.exec()\n\n    # Edit: selection\n    ######################################################################\n\n    @no_arg_trigger\n    @skip_if_selection_is_empty\n    @ensure_editor_saved\n    def selectNotes(self) -> None:\n        nids = self.selected_notes()\n        # clear the selection so we don't waste energy preserving it\n        self.table.clear_selection()\n        search = self.col.build_search_string(\n            SearchNode(nids=SearchNode.IdList(ids=nids))\n        )\n        self.search_for(search)\n        self.table.select_all()\n\n    # Hooks\n    ######################################################################\n\n    def setupHooks(self) -> None:\n        gui_hooks.undo_state_did_change.append(self.on_undo_state_change)\n        gui_hooks.backend_will_block.append(self.table.on_backend_will_block)\n        gui_hooks.backend_did_block.append(self.table.on_backend_did_block)\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n        gui_hooks.focus_did_change.append(self.on_focus_change)\n        gui_hooks.flag_label_did_change.append(self._update_flag_labels)\n        gui_hooks.collection_will_temporarily_close.append(self._on_temporary_close)\n\n    def teardownHooks(self) -> None:\n        gui_hooks.undo_state_did_change.remove(self.on_undo_state_change)\n        gui_hooks.backend_will_block.remove(self.table.on_backend_will_block)\n        gui_hooks.backend_did_block.remove(self.table.on_backend_did_block)\n        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)\n        gui_hooks.focus_did_change.remove(self.on_focus_change)\n        gui_hooks.flag_label_did_change.remove(self._update_flag_labels)\n        gui_hooks.collection_will_temporarily_close.remove(self._on_temporary_close)\n\n    def _on_temporary_close(self, col: Collection) -> None:\n        # we could reload browser columns in the future; for now we just close\n        self.close()\n\n    # Undo\n    ######################################################################\n\n    def undo(self) -> None:\n        undo(parent=self)\n\n    def redo(self) -> None:\n        redo(parent=self)\n\n    def on_undo_state_change(self, info: UndoActionsInfo) -> None:\n        self.form.actionUndo.setText(info.undo_text)\n        self.form.actionUndo.setEnabled(info.can_undo)\n        self.form.actionRedo.setText(info.redo_text)\n        self.form.actionRedo.setEnabled(info.can_redo)\n        self.form.actionRedo.setVisible(info.show_redo)\n\n    # Edit: replacing\n    ######################################################################\n\n    @no_arg_trigger\n    @ensure_editor_saved\n    def onFindReplace(self) -> None:\n        FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes())\n\n    # Edit: finding dupes\n    ######################################################################\n\n    @no_arg_trigger\n    @ensure_editor_saved\n    def onFindDupes(self) -> None:\n        from aqt.browser.find_duplicates import FindDuplicatesDialog\n\n        FindDuplicatesDialog(browser=self, mw=self.mw)\n\n    # Jumping\n    ######################################################################\n\n    def has_previous_card(self) -> bool:\n        return self.table.has_previous()\n\n    def has_next_card(self) -> bool:\n        return self.table.has_next()\n\n    def onPreviousCard(self) -> None:\n        assert self.editor is not None\n\n        self.focusTo = self.editor.currentField\n        self.editor.call_after_note_saved(self.table.to_previous_row)\n\n    def onNextCard(self) -> None:\n        assert self.editor is not None\n\n        self.focusTo = self.editor.currentField\n        self.editor.call_after_note_saved(self.table.to_next_row)\n\n    def onFirstCard(self) -> None:\n        self.table.to_first_row()\n\n    def onLastCard(self) -> None:\n        self.table.to_last_row()\n\n    def onFind(self) -> None:\n        self.form.searchEdit.setFocus()\n        self._line_edit().selectAll()\n\n    def onNote(self) -> None:\n        def cb():\n            assert self.editor is not None and self.editor.web is not None\n            self.editor.web.setFocus()\n            self.editor.loadNote(focusTo=0)\n\n        assert self.editor is not None\n        self.editor.call_after_note_saved(cb)\n\n    def onCardList(self) -> None:\n        self.form.tableView.setFocus()\n\n    def _line_edit(self) -> QLineEdit:\n        line_edit = self.form.searchEdit.lineEdit()\n        assert line_edit is not None\n        return line_edit\n"
  },
  {
    "path": "qt/aqt/browser/card_info.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json\nfrom collections.abc import Callable\n\nfrom google.protobuf.json_format import MessageToDict\n\nimport aqt\nfrom anki.cards import Card, CardId\nfrom anki.errors import NotFoundError\nfrom anki.lang import without_unicode_isolation\nfrom aqt.qt import *\nfrom aqt.utils import (\n    disable_help_button,\n    qconnect,\n    restoreGeom,\n    saveGeom,\n    setWindowIcon,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\nclass CardInfoDialog(QDialog):\n    TITLE = \"browser card info\"\n    GEOMETRY_KEY = \"revlog\"\n    silentlyClose = True\n\n    def __init__(\n        self,\n        parent: QWidget | None,\n        mw: aqt.AnkiQt,\n        card: Card | None,\n        on_close: Callable | None = None,\n        geometry_key: str | None = None,\n        window_title: str | None = None,\n    ) -> None:\n        super().__init__(parent)\n        self.mw = mw\n        self._on_close = on_close\n        self.GEOMETRY_KEY = geometry_key or self.GEOMETRY_KEY\n        if window_title:\n            self.setWindowTitle(window_title)\n        self._setup_ui(card.id if card else None)\n        self.show()\n\n    def _setup_ui(self, card_id: CardId | None) -> None:\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.setMinimumSize(400, 300)\n        disable_help_button(self)\n        restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800))\n        setWindowIcon(self)\n\n        self.web: AnkiWebView | None = AnkiWebView(\n            kind=AnkiWebViewKind.BROWSER_CARD_INFO\n        )\n        self.web.setVisible(False)\n        self.web.load_sveltekit_page(f\"card-info/{card_id}\")\n        layout = QVBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.addWidget(self.web)\n        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)\n        buttons.setContentsMargins(10, 0, 10, 10)\n        layout.addWidget(buttons)\n        qconnect(buttons.rejected, self.reject)\n\n        self.copy_debug_info = QShortcut(  # type: ignore\n            \"ctrl+c\", self, activated=lambda: self.copy_card_info(card_id)\n        )\n\n        self.setLayout(layout)\n\n    def copy_card_info(self, card_id: CardId | None) -> None:\n        if self.web and self.web.selectedText():\n            self.web.onCopy()\n            return\n        if card_id is None:\n            return\n        assert aqt.mw.col.db, tr.errors_inconsistent_db_state()\n\n        proto_info = aqt.mw.col.card_stats_data(card_id)\n        info = MessageToDict(proto_info)\n\n        card = aqt.mw.col.get_card(card_id)\n\n        revlog = aqt.mw.col.db.execute(\n            f\"SELECT * FROM revlog WHERE cid == {card_id} ORDER BY id DESC\"\n        )\n        deck = aqt.mw.col.decks.get(card.did) or dict()\n        config = aqt.mw.col.decks.get_config(deck.get(\"conf\", -1)) or dict()\n\n        info[\"deck\"] = deck\n        info[\"config\"] = config\n\n        info[\"config\"].pop(\"name\", None)\n        info[\"deck\"].pop(\"name\", None)\n        info[\"deck\"].pop(\"desc\", None)\n        info[\"deck\"].pop(\"usn\", None)\n        info.pop(\"usn\", None)\n        info.pop(\"cardType\", None)\n        info.pop(\"notetype\", None)\n        info.pop(\"preset\", None)\n\n        info[\"cardRow\"] = aqt.mw.col.db.execute(\n            f\"SELECT * FROM cards WHERE id == {card_id} ORDER BY id DESC\"\n        )[0]\n\n        new_revlog = [\n            {\"row\": revlog, \"info\": card_info_review}\n            for revlog, card_info_review in zip(revlog, info.get(\"revlog\", []))\n        ]\n        info[\"revlog\"] = new_revlog\n        info[\"rollover\"] = aqt.mw.col.get_config(\"rollover\")\n\n        clipboard = QApplication.clipboard()\n        assert clipboard is not None\n        clipboard.setText(json.dumps(info, indent=2))\n\n        tooltip(tr.about_copied_to_clipboard())\n\n    def update_card(self, card_id: CardId | None) -> None:\n        try:\n            self.mw.col.get_card(card_id)\n        except NotFoundError:\n            card_id = None\n\n        assert self.web is not None\n        self.web.eval(f\"anki.updateCard('{card_id}');\")\n\n    def reject(self) -> None:\n        if self._on_close:\n            self._on_close()\n        assert self.web is not None\n        self.web.cleanup()\n        self.web = None\n        saveGeom(self, self.GEOMETRY_KEY)\n        return QDialog.reject(self)\n\n\nclass CardInfoManager:\n    \"\"\"Wrapper class to conveniently toggle, update and close a card info dialog.\"\"\"\n\n    def __init__(self, mw: aqt.AnkiQt, geometry_key: str, window_title: str):\n        self.mw = mw\n        self.geometry_key = geometry_key\n        self.window_title = window_title\n        self._card: Card | None = None\n        self._dialog: CardInfoDialog | None = None\n\n    def show(self) -> None:\n        if self._dialog:\n            self._dialog.activateWindow()\n            self._dialog.raise_()\n        else:\n            self._dialog = CardInfoDialog(\n                None,\n                self.mw,\n                self._card,\n                self._on_close,\n                self.geometry_key,\n                self.window_title,\n            )\n\n    def set_card(self, card: Card | None) -> None:\n        self._card = card\n        if self._dialog:\n            self._dialog.update_card(card.id if card else None)\n\n    def close(self) -> None:\n        if self._dialog:\n            self._dialog.reject()\n\n    def _on_close(self) -> None:\n        self._dialog = None\n\n\nclass BrowserCardInfo(CardInfoManager):\n    def __init__(self, mw: aqt.AnkiQt):\n        super().__init__(\n            mw,\n            \"revlog\",\n            without_unicode_isolation(\n                tr.card_stats_current_card(context=tr.qt_misc_browse())\n            ),\n        )\n\n\nclass ReviewerCardInfo(CardInfoManager):\n    def __init__(self, mw: aqt.AnkiQt):\n        super().__init__(\n            mw,\n            \"reviewerCardInfo\",\n            without_unicode_isolation(\n                tr.card_stats_current_card(context=tr.decks_study())\n            ),\n        )\n\n\nclass PreviousReviewerCardInfo(CardInfoManager):\n    def __init__(self, mw: aqt.AnkiQt):\n        super().__init__(\n            mw,\n            \"previousReviewerCardInfo\",\n            without_unicode_isolation(\n                tr.card_stats_previous_card(context=tr.decks_study())\n            ),\n        )\n"
  },
  {
    "path": "qt/aqt/browser/find_and_replace.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.notes import NoteId\nfrom aqt import AnkiQt\nfrom aqt.operations import QueryOp\nfrom aqt.operations.note import find_and_replace\nfrom aqt.operations.tag import find_and_replace_tag\nfrom aqt.qt import *\nfrom aqt.utils import (\n    HelpPage,\n    disable_help_button,\n    openHelp,\n    qconnect,\n    restore_combo_history,\n    restore_combo_index_for_session,\n    restore_is_checked,\n    restoreGeom,\n    save_combo_history,\n    save_combo_index_for_session,\n    save_is_checked,\n    saveGeom,\n    tooltip,\n    tr,\n)\n\n\nclass FindAndReplaceDialog(QDialog):\n    COMBO_NAME = \"BrowserFindAndReplace\"\n\n    def __init__(\n        self,\n        parent: QWidget,\n        *,\n        mw: AnkiQt,\n        note_ids: Sequence[NoteId],\n        field: str | None = None,\n    ) -> None:\n        \"\"\"\n        If 'field' is passed, only this is added to the field selector.\n        Otherwise, the fields belonging to the 'note_ids' are added.\n        \"\"\"\n        super().__init__(parent)\n        self.mw = mw\n        self.note_ids = note_ids\n        self.field_names: list[str] = []\n        self._field = field\n\n        if field:\n            self._show([field])\n        elif note_ids:\n            # fetch field names and then show\n            QueryOp(\n                parent=mw,\n                op=lambda col: col.field_names_for_note_ids(note_ids),\n                success=self._show,\n            ).run_in_background()\n        else:\n            self._show([])\n\n    def _show(self, field_names: Sequence[str]) -> None:\n        # add \"all fields\" and \"tags\" to the top of the list\n        self.field_names = [\n            tr.browsing_all_fields(),\n            tr.editing_tags(),\n        ] + list(field_names)\n\n        disable_help_button(self)\n        self.form = aqt.forms.findreplace.Ui_Dialog()\n        self.form.setupUi(self)\n        self.setWindowModality(Qt.WindowModality.WindowModal)\n\n        self._find_history = restore_combo_history(\n            self.form.find, self.COMBO_NAME + \"Find\"\n        )\n\n        find_completer = self.form.find.completer()\n        assert find_completer is not None\n        find_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)\n        self._replace_history = restore_combo_history(\n            self.form.replace, self.COMBO_NAME + \"Replace\"\n        )\n        replace_completer = self.form.replace.completer()\n        assert replace_completer is not None\n        replace_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)\n\n        if not self.note_ids:\n            # no selected notes to affect\n            self.form.selected_notes.setChecked(False)\n            self.form.selected_notes.setEnabled(False)\n        elif self._field:\n            self.form.selected_notes.setChecked(False)\n\n        restore_is_checked(self.form.re, self.COMBO_NAME + \"Regex\")\n        restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + \"ignoreCase\")\n\n        self.form.field.addItems(self.field_names)\n        if self._field:\n            self.form.field.setCurrentIndex(self.field_names.index(self._field))\n        else:\n            restore_combo_index_for_session(\n                self.form.field, self.field_names, self.COMBO_NAME + \"Field\"\n            )\n\n        qconnect(self.form.buttonBox.helpRequested, self.show_help)\n\n        restoreGeom(self, \"findreplace\")\n        self.show()\n        self.form.find.setFocus()\n\n    def accept(self) -> None:\n        saveGeom(self, \"findreplace\")\n        save_combo_index_for_session(self.form.field, self.COMBO_NAME + \"Field\")\n\n        search = save_combo_history(\n            self.form.find, self._find_history, self.COMBO_NAME + \"Find\"\n        )\n        replace = save_combo_history(\n            self.form.replace, self._replace_history, self.COMBO_NAME + \"Replace\"\n        )\n        regex = self.form.re.isChecked()\n        match_case = not self.form.ignoreCase.isChecked()\n        save_is_checked(self.form.re, self.COMBO_NAME + \"Regex\")\n        save_is_checked(self.form.ignoreCase, self.COMBO_NAME + \"ignoreCase\")\n\n        if not self.form.selected_notes.isChecked():\n            # an empty list means *all* notes\n            self.note_ids = []\n\n        parent_widget = self.parentWidget()\n        assert parent_widget is not None\n\n        # tags?\n        if self.form.field.currentIndex() == 1:\n            op = find_and_replace_tag(\n                parent=parent_widget,\n                note_ids=self.note_ids,\n                search=search,\n                replacement=replace,\n                regex=regex,\n                match_case=match_case,\n            )\n        else:\n            # fields\n            if self.form.field.currentIndex() == 0:\n                field = None\n            else:\n                field = self.field_names[self.form.field.currentIndex()]\n\n            op = find_and_replace(\n                parent=parent_widget,\n                note_ids=self.note_ids,\n                search=search,\n                replacement=replace,\n                regex=regex,\n                field_name=field,\n                match_case=match_case,\n            )\n\n        if not self.note_ids:\n            op.success(\n                lambda out: tooltip(\n                    tr.browsing_notes_updated(count=out.count),\n                    parent=self.parentWidget(),\n                )\n            )\n        op.run_in_background()\n\n        super().accept()\n\n    def show_help(self) -> None:\n        openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)\n"
  },
  {
    "path": "qt/aqt/browser/find_duplicates.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport html\nfrom typing import Any\n\nimport anki\nimport anki.find\nimport aqt\nimport aqt.forms\nfrom anki.collection import SearchNode\nfrom anki.notes import NoteId\nfrom aqt.qt import *\nfrom aqt.qt import sip\n\nfrom ..operations import QueryOp\nfrom ..operations.tag import add_tags_to_notes\nfrom ..utils import (\n    disable_help_button,\n    restore_combo_history,\n    restore_combo_index_for_session,\n    restoreGeom,\n    save_combo_history,\n    save_combo_index_for_session,\n    saveGeom,\n    tr,\n)\nfrom . import Browser\n\n\nclass FindDuplicatesDialog(QDialog):\n    def __init__(self, browser: Browser, mw: aqt.AnkiQt):\n        super().__init__(parent=browser)\n        self.browser = browser\n        self.mw = mw\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.form = form = aqt.forms.finddupes.Ui_Dialog()\n        form.setupUi(self)\n        restoreGeom(self, \"findDupes\")\n        disable_help_button(self)\n        searchHistory = restore_combo_history(form.search, \"findDupesFind\")\n\n        fields = sorted(\n            anki.find.fieldNames(self.mw.col, downcase=False), key=lambda x: x.lower()\n        )\n        form.fields.addItems(fields)\n        restore_combo_index_for_session(form.fields, fields, \"findDupesFields\")\n        self._dupesButton: QPushButton | None = None\n        self._dupes: list[tuple[str, list[NoteId]]] = []\n\n        # links\n        form.webView.set_bridge_command(self._on_duplicate_clicked, context=self)\n        form.webView.stdHtml(\"\", context=self)\n\n        def on_finished(code: Any) -> None:\n            saveGeom(self, \"findDupes\")\n\n        qconnect(self.finished, on_finished)\n\n        def on_click() -> None:\n            search_text = save_combo_history(\n                form.search, searchHistory, \"findDupesFind\"\n            )\n            save_combo_index_for_session(form.fields, \"findDupesFields\")\n            field = fields[form.fields.currentIndex()]\n            QueryOp(\n                parent=self.browser,\n                op=lambda col: col.find_dupes(field, search_text),\n                success=self.show_duplicates_report,\n            ).run_in_background()\n\n        search = form.buttonBox.addButton(\n            tr.actions_search(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n\n        assert search is not None\n\n        qconnect(search.clicked, on_click)\n        self.show()\n\n    def show_duplicates_report(self, dupes: list[tuple[str, list[NoteId]]]) -> None:\n        if sip.isdeleted(self):\n            return\n        self._dupes = dupes\n        if not self._dupesButton:\n            self._dupesButton = b = self.form.buttonBox.addButton(\n                tr.browsing_tag_duplicates(), QDialogButtonBox.ButtonRole.ActionRole\n            )\n\n            assert b is not None\n\n            qconnect(b.clicked, self._tag_duplicates)\n        text = \"\"\n        groups = len(dupes)\n        notes = sum(len(r[1]) for r in dupes)\n        part1 = tr.browsing_group(count=groups)\n        part2 = tr.browsing_note_count(count=notes)\n        text += tr.browsing_found_as_across_bs(part=part1, whole=part2)\n        text += \"<p><ol>\"\n        for val, nids in dupes:\n            text += (\n                \"\"\"<li><a href=# onclick=\"pycmd('%s');return false;\">%s</a>: %s</a>\"\"\"\n                % (\n                    html.escape(\n                        self.mw.col.build_search_string(\n                            SearchNode(nids=SearchNode.IdList(ids=nids))\n                        )\n                    ),\n                    tr.browsing_note_count(count=len(nids)),\n                    html.escape(val),\n                )\n            )\n        text += \"</ol>\"\n        self.form.webView.stdHtml(text, context=self)\n\n    def _tag_duplicates(self) -> None:\n        if not self._dupes:\n            return\n\n        note_ids = set()\n        for _, nids in self._dupes:\n            note_ids.update(nids)\n\n        add_tags_to_notes(\n            parent=self,\n            note_ids=list(note_ids),\n            space_separated_tags=tr.browsing_duplicate(),\n        ).run_in_background()\n\n    def _on_duplicate_clicked(self, link: str) -> None:\n        self.browser.search_for(link)\n        self.browser.onNote()\n"
  },
  {
    "path": "qt/aqt/browser/layout.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom enum import Enum\n\nfrom aqt.qt import QEvent, QObject, QSplitter, Qt\n\n\nclass BrowserLayout(Enum):\n    AUTO = \"auto\"\n    VERTICAL = \"vertical\"\n    HORIZONTAL = \"horizontal\"\n\n\nclass QSplitterHandleEventFilter(QObject):\n    \"\"\"Event filter that equalizes QSplitter panes on double-clicking the handle\"\"\"\n\n    def __init__(self, splitter: QSplitter):\n        super().__init__(splitter)\n        self._splitter = splitter\n\n    def eventFilter(self, object: QObject | None, event: QEvent | None) -> bool:\n        assert event is not None\n\n        if event.type() == QEvent.Type.MouseButtonDblClick:\n            splitter_parent = self._splitter.parentWidget()\n\n            assert splitter_parent is not None\n\n            if self._splitter.orientation() == Qt.Orientation.Horizontal:\n                half_size = splitter_parent.width() // 2\n            else:\n                half_size = splitter_parent.height() // 2\n            self._splitter.setSizes([half_size, half_size])\n            return True\n\n        return super().eventFilter(object, event)\n"
  },
  {
    "path": "qt/aqt/browser/previewer.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json\nimport re\nimport time\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport aqt.browser\nfrom anki.cards import Card\nfrom anki.collection import Config\nfrom anki.tags import MARKED_TAG\nfrom aqt import AnkiQt, gui_hooks, is_mac\nfrom aqt.qt import (\n    QCheckBox,\n    QDialog,\n    QDialogButtonBox,\n    QKeySequence,\n    QShortcut,\n    Qt,\n    QTimer,\n    QVBoxLayout,\n    qconnect,\n)\nfrom aqt.reviewer import replay_audio\nfrom aqt.sound import av_player, play_clicked_audio\nfrom aqt.theme import theme_manager\nfrom aqt.utils import disable_help_button, restoreGeom, saveGeom, setWindowIcon, tr\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\nLastStateAndMod = tuple[str, int, int]\n\n\nclass Previewer(QDialog):\n    _last_state: LastStateAndMod | None = None\n    _card_changed = False\n    _last_render: int | float = 0\n    _timer: QTimer | None = None\n    _show_both_sides = False\n\n    def __init__(\n        self,\n        parent: aqt.browser.Browser | None,\n        mw: AnkiQt,\n        on_close: Callable[[], None],\n    ) -> None:\n        super().__init__(None, Qt.WindowType.Window)\n        mw.garbage_collect_on_dialog_finish(self)\n        self._open = True\n        self._parent = parent\n        self._close_callback = on_close\n        self.mw = mw\n        disable_help_button(self)\n        setWindowIcon(self)\n        gui_hooks.previewer_did_init(self)\n\n    def card(self) -> Card | None:\n        raise NotImplementedError\n\n    def card_changed(self) -> bool:\n        raise NotImplementedError\n\n    def open(self) -> None:\n        self._state = \"question\"\n        self._last_state = None\n        self._create_gui()\n        self._setup_web_view()\n        self.render_card()\n        restoreGeom(self, \"preview\")\n        self.show()\n\n    def _create_gui(self) -> None:\n        self.setWindowTitle(tr.actions_preview())\n\n        self.close_shortcut = QShortcut(QKeySequence(\"Ctrl+Shift+P\"), self)\n        qconnect(self.close_shortcut.activated, self.close)\n\n        qconnect(self.finished, self._on_finished)\n        self.silentlyClose = True\n        self.vbox = QVBoxLayout()\n        spacing = 6\n        self.vbox.setContentsMargins(0, 0, 0, 0)\n        self.vbox.setSpacing(spacing)\n        self._web: AnkiWebView | None = AnkiWebView(kind=AnkiWebViewKind.PREVIEWER)\n        self.vbox.addWidget(self._web)\n        self.bbox = QDialogButtonBox()\n        self.bbox.setContentsMargins(\n            spacing, spacing if is_mac else 0, spacing, spacing\n        )\n        self.bbox.setLayoutDirection(Qt.LayoutDirection.LeftToRight)\n\n        gui_hooks.card_review_webview_did_init(self._web, AnkiWebViewKind.PREVIEWER)\n\n        self._replay = self.bbox.addButton(\n            tr.actions_replay_audio(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n        assert self._replay is not None\n        self._replay.setAutoDefault(False)\n        self._replay.setShortcut(QKeySequence(\"R\"))\n        self._replay.setToolTip(tr.actions_shortcut_key(val=\"R\"))\n        qconnect(self._replay.clicked, self._on_replay_audio)\n\n        both_sides_button = QCheckBox(tr.qt_misc_back_side_only())\n        both_sides_button.setShortcut(QKeySequence(\"B\"))\n        both_sides_button.setToolTip(tr.actions_shortcut_key(val=\"B\"))\n        self.bbox.addButton(both_sides_button, QDialogButtonBox.ButtonRole.ActionRole)\n        self._show_both_sides = self.mw.col.get_config_bool(\n            Config.Bool.PREVIEW_BOTH_SIDES\n        )\n        both_sides_button.setChecked(self._show_both_sides)\n        qconnect(both_sides_button.toggled, self._on_show_both_sides)\n\n        self.vbox.addWidget(self.bbox)\n        self.setLayout(self.vbox)\n\n    def _on_finished(self, ok: int) -> None:\n        saveGeom(self, \"preview\")\n        self._on_close()\n\n    def _on_replay_audio(self) -> None:\n        assert self._web is not None\n        card = self.card()\n        assert card is not None\n\n        gui_hooks.audio_will_replay(self._web, card, self._state == \"question\")\n\n        if self._state == \"question\":\n            replay_audio(card, True)\n        elif self._state == \"answer\":\n            replay_audio(card, False)\n\n    def _on_close(self) -> None:\n        self._open = False\n        self._close_callback()\n\n        assert self._web is not None\n\n        self._web.cleanup()\n        self._web = None\n\n    def _setup_web_view(self) -> None:\n        assert self._web is not None\n\n        self._web.stdHtml(\n            self.mw.reviewer.revHtml(),\n            css=[\"css/reviewer.css\"],\n            js=[\n                \"js/mathjax.js\",\n                \"js/vendor/mathjax/tex-chtml-full.js\",\n                \"js/reviewer.js\",\n            ],\n            context=self,\n        )\n        self._web.allow_drops = True\n        self._web.eval(\"_blockDefaultDragDropBehavior();\")\n        self._web.set_bridge_command(self._on_bridge_cmd, self)\n\n    def _on_bridge_cmd(self, cmd: str) -> Any:\n        if cmd.startswith(\"play:\"):\n            card = self.card()\n            assert card is not None\n\n            play_clicked_audio(cmd, card)\n\n    def _update_flag_and_mark_icons(self, card: Card | None) -> None:\n        if card:\n            flag = card.user_flag()\n            marked = card.note(reload=True).has_tag(MARKED_TAG)\n        else:\n            flag = 0\n            marked = False\n\n        assert self._web is not None\n\n        self._web.eval(f\"_drawFlag({flag}); _drawMark({json.dumps(marked)});\")\n\n    def render_card(self) -> None:\n        self.cancel_timer()\n        # Keep track of whether render() has ever been called\n        # with cardChanged=True since the last successful render\n        self._card_changed |= self.card_changed()\n        # avoid rendering in quick succession\n        elap_ms = int((time.time() - self._last_render) * 1000)\n        delay = 300\n        if elap_ms < delay:\n            self._timer = self.mw.progress.timer(\n                delay - elap_ms, self._render_scheduled, False, parent=self\n            )\n        else:\n            self._render_scheduled()\n\n    def cancel_timer(self) -> None:\n        if self._timer:\n            self._timer.stop()\n            self._timer = None\n\n    def _render_scheduled(self) -> None:\n        self.cancel_timer()\n        self._last_render = time.time()\n\n        if not self._open:\n            return\n        c = self.card()\n        self._update_flag_and_mark_icons(c)\n        func = \"_showQuestion\"\n        ans_txt = \"\"\n        if not c:\n            txt = tr.qt_misc_please_select_1_card()\n            bodyclass = \"\"\n            self._last_state = None\n        else:\n            if self._show_both_sides:\n                self._state = \"answer\"\n            elif self._card_changed:\n                self._state = \"question\"\n\n            currentState = self._state_and_mod()\n            if currentState == self._last_state:\n                # nothing has changed, avoid refreshing\n                return\n\n            # need to force reload even if answer\n            txt = c.question(reload=True)\n            ans_txt = c.answer()\n\n            if self._state == \"answer\":\n                func = \"_showAnswer\"\n                txt = ans_txt\n            txt = re.sub(r\"\\[\\[type:[^]]+\\]\\]\", \"\", txt)\n\n            bodyclass = theme_manager.body_classes_for_card_ord(c.ord)\n\n            assert self._web is not None\n\n            if c.autoplay():\n                self._web.setPlaybackRequiresGesture(False)\n                if self._show_both_sides:\n                    # if we're showing both sides at once, remove any audio\n                    # from the answer that's appeared on the question already\n                    question_audio = c.question_av_tags()\n                    only_on_answer_audio = [\n                        x for x in c.answer_av_tags() if x not in question_audio\n                    ]\n                    audio = question_audio + only_on_answer_audio\n                elif self._state == \"question\":\n                    audio = c.question_av_tags()\n                else:\n                    audio = c.answer_av_tags()\n            else:\n                audio = []\n                self._web.setPlaybackRequiresGesture(True)\n            gui_hooks.av_player_will_play_tags(audio, self._state, self)\n            av_player.play_tags(audio)\n            txt = self.mw.prepare_card_text_for_display(txt)\n            txt = gui_hooks.card_will_show(txt, c, f\"preview{self._state.capitalize()}\")\n            self._last_state = self._state_and_mod()\n\n        js: str\n        if self._state == \"question\":\n            ans_txt = self.mw.col.media.escape_media_filenames(ans_txt)\n            js = f\"{func}({json.dumps(txt)}, {json.dumps(ans_txt)}, '{bodyclass}');\"\n        else:\n            js = f\"{func}({json.dumps(txt)}, '{bodyclass}');\"\n\n        assert self._web is not None\n        self._web.eval(js)\n        self._card_changed = False\n\n    def _on_show_both_sides(self, toggle: bool) -> None:\n        assert self._web is not None\n\n        self._show_both_sides = toggle\n        self.mw.col.set_config_bool(Config.Bool.PREVIEW_BOTH_SIDES, toggle)\n\n        card = self.card()\n        assert card is not None\n\n        gui_hooks.previewer_will_redraw_after_show_both_sides_toggled(\n            self._web, card, self._state == \"question\", toggle\n        )\n\n        if self._state == \"answer\" and not toggle:\n            self._state = \"question\"\n        self.render_card()\n\n    def _state_and_mod(self) -> tuple[str, int, int]:\n        c = self.card()\n\n        assert c is not None\n\n        n = c.note()\n        n.load()\n        return (self._state, c.id, n.mod)\n\n    def state(self) -> str:\n        return self._state\n\n\nclass MultiCardPreviewer(Previewer):\n    def card(self) -> Card | None:\n        # need to state explicitly it's not implement to avoid W0223\n        raise NotImplementedError\n\n    def card_changed(self) -> bool:\n        # need to state explicitly it's not implement to avoid W0223\n        raise NotImplementedError\n\n    def _create_gui(self) -> None:\n        super()._create_gui()\n        self._prev = self.bbox.addButton(\n            \">\" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else \"<\",\n            QDialogButtonBox.ButtonRole.ActionRole,\n        )\n\n        assert self._prev is not None\n\n        self._prev.setAutoDefault(False)\n        self._prev.setShortcut(QKeySequence(\"Left\"))\n        self._prev.setToolTip(tr.qt_misc_shortcut_key_left_arrow())\n\n        self._next = self.bbox.addButton(\n            \"<\" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else \">\",\n            QDialogButtonBox.ButtonRole.ActionRole,\n        )\n\n        assert self._next is not None\n\n        self._next.setAutoDefault(True)\n        self._next.setShortcut(QKeySequence(\"Right\"))\n        self._next.setToolTip(tr.qt_misc_shortcut_key_right_arrow_or_enter())\n\n        qconnect(self._prev.clicked, self._on_prev)\n        qconnect(self._next.clicked, self._on_next)\n\n    def _on_prev(self) -> None:\n        if self._state == \"answer\" and not self._show_both_sides:\n            self._state = \"question\"\n            self.render_card()\n        else:\n            self._on_prev_card()\n\n    def _on_prev_card(self) -> None:\n        pass\n\n    def _on_next(self) -> None:\n        if self._state == \"question\":\n            self._state = \"answer\"\n            self.render_card()\n        else:\n            self._on_next_card()\n\n    def _on_next_card(self) -> None:\n        pass\n\n    def _updateButtons(self) -> None:\n        if not self._open:\n            return\n\n        assert self._prev is not None\n        assert self._next is not None\n\n        self._prev.setEnabled(self._should_enable_prev())\n        self._next.setEnabled(self._should_enable_next())\n\n    def _should_enable_prev(self) -> bool:\n        return self._state == \"answer\" and not self._show_both_sides\n\n    def _should_enable_next(self) -> bool:\n        return self._state == \"question\"\n\n    def _on_close(self) -> None:\n        super()._on_close()\n        self._prev = None\n        self._next = None\n\n\nclass BrowserPreviewer(MultiCardPreviewer):\n    _last_card_id = 0\n    _parent: aqt.browser.Browser | None\n\n    def __init__(\n        self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None]\n    ) -> None:\n        super().__init__(parent=parent, mw=mw, on_close=on_close)\n\n    def card(self) -> Card | None:\n        assert self._parent is not None\n\n        if self._parent.singleCard:\n            return self._parent.card\n        else:\n            return None\n\n    def card_changed(self) -> bool:\n        c = self.card()\n        if not c:\n            return True\n        else:\n            changed = c.id != self._last_card_id\n            self._last_card_id = c.id\n            return changed\n\n    def _on_prev_card(self) -> None:\n        assert self._parent is not None\n\n        self._parent.onPreviousCard()\n\n    def _on_next_card(self) -> None:\n        assert self._parent is not None\n\n        self._parent.onNextCard()\n\n    def _should_enable_prev(self) -> bool:\n        assert self._parent is not None\n\n        return super()._should_enable_prev() or self._parent.has_previous_card()\n\n    def _should_enable_next(self) -> bool:\n        assert self._parent is not None\n\n        return super()._should_enable_next() or self._parent.has_next_card()\n\n    def _render_scheduled(self) -> None:\n        super()._render_scheduled()\n        self._updateButtons()\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n# ruff: noqa: F401\nfrom anki.utils import is_mac\nfrom aqt.theme import theme_manager\n\nfrom .item import SidebarItem, SidebarItemType\nfrom .model import SidebarModel\nfrom .searchbar import SidebarSearchBar\nfrom .toolbar import SidebarTool, SidebarToolbar\nfrom .tree import SidebarStage, SidebarTreeView\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/item.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Iterable\nfrom enum import Enum, auto\n\nfrom anki.collection import SearchNode\nfrom aqt.theme import ColoredIcon\n\n\nclass SidebarItemType(Enum):\n    ROOT = auto()\n    SAVED_SEARCH_ROOT = auto()\n    SAVED_SEARCH = auto()\n    TODAY_ROOT = auto()\n    TODAY = auto()\n    FLAG_ROOT = auto()\n    FLAG = auto()\n    FLAG_NONE = auto()\n    CARD_STATE_ROOT = auto()\n    CARD_STATE = auto()\n    DECK_ROOT = auto()\n    DECK_CURRENT = auto()\n    DECK = auto()\n    NOTETYPE_ROOT = auto()\n    NOTETYPE = auto()\n    NOTETYPE_TEMPLATE = auto()\n    NOTETYPE_FIELD = auto()\n    TAG_ROOT = auto()\n    TAG_NONE = auto()\n    TAG = auto()\n\n    CUSTOM = auto()\n\n    @staticmethod\n    def section_roots() -> Iterable[SidebarItemType]:\n        return (type for type in SidebarItemType if type.name.endswith(\"_ROOT\"))\n\n    def is_section_root(self) -> bool:\n        return self in self.section_roots()\n\n    def is_editable(self) -> bool:\n        return self in (\n            SidebarItemType.FLAG,\n            SidebarItemType.SAVED_SEARCH,\n            SidebarItemType.DECK,\n            SidebarItemType.TAG,\n        )\n\n    def can_be_added_to(self) -> bool:\n        return self == SidebarItemType.DECK\n\n    def is_deletable(self) -> bool:\n        return self in (\n            SidebarItemType.SAVED_SEARCH,\n            SidebarItemType.DECK,\n            SidebarItemType.TAG,\n        )\n\n\nclass SidebarItem:\n    def __init__(\n        self,\n        name: str,\n        icon: str | ColoredIcon,\n        search_node: SearchNode | None = None,\n        on_expanded: Callable[[bool], None] | None = None,\n        expanded: bool = False,\n        item_type: SidebarItemType = SidebarItemType.CUSTOM,\n        id: int = 0,\n        name_prefix: str = \"\",\n    ) -> None:\n        self.name = name\n        self.name_prefix = name_prefix\n        self.full_name = name_prefix + name\n        self.icon = icon\n        self.item_type = item_type\n        self.id = id\n        self.search_node = search_node\n        self.on_expanded = on_expanded\n        self.children: list[SidebarItem] = []\n        self.tooltip: str = name\n        self._parent_item: SidebarItem | None = None\n        self._expanded = expanded\n        self._row_in_parent: int | None = None\n        self._search_matches_self = False\n        self._search_matches_child = False\n\n    def add_child(self, cb: SidebarItem) -> None:\n        self.children.append(cb)\n        cb._parent_item = self\n\n    def add_simple(\n        self,\n        name: str,\n        icon: str | ColoredIcon,\n        type: SidebarItemType,\n        search_node: SearchNode | None,\n    ) -> SidebarItem:\n        \"Add child sidebar item, and return it.\"\n        item = SidebarItem(\n            name=name,\n            icon=icon,\n            search_node=search_node,\n            item_type=type,\n        )\n        self.add_child(item)\n        return item\n\n    @property\n    def expanded(self) -> bool:\n        return self._expanded\n\n    @expanded.setter\n    def expanded(self, expanded: bool) -> None:\n        if self.expanded != expanded:\n            self._expanded = expanded\n            if self.on_expanded:\n                self.on_expanded(expanded)\n\n    def show_expanded(self, searching: bool) -> bool:\n        if not searching:\n            return self.expanded\n        if self._search_matches_child:\n            return True\n        # if search matches top level, expand children one level\n        return self._search_matches_self and self.item_type.is_section_root()\n\n    def is_highlighted(self) -> bool:\n        return self._search_matches_self\n\n    def search(self, lowered_text: str) -> bool:\n        \"True if we or child matched.\"\n        self._search_matches_self = lowered_text in self.name.lower()\n        self._search_matches_child = any(\n            [child.search(lowered_text) for child in self.children]\n        )\n        return self._search_matches_self or self._search_matches_child\n\n    def has_same_id(self, other: SidebarItem) -> bool:\n        \"True if `other` is same type, with same id/name.\"\n        if other.item_type == self.item_type:\n            if self.item_type == SidebarItemType.TAG:\n                return self.full_name == other.full_name\n            elif self.item_type in (\n                SidebarItemType.SAVED_SEARCH,\n                SidebarItemType.TODAY,\n                SidebarItemType.CARD_STATE,\n            ):\n                return self.name == other.name\n            elif self.item_type in [\n                SidebarItemType.NOTETYPE_TEMPLATE,\n                SidebarItemType.NOTETYPE_FIELD,\n            ]:\n                assert other._parent_item is not None\n                assert self._parent_item is not None\n\n                return (\n                    other.id == self.id\n                    and other._parent_item.id == self._parent_item.id\n                )\n            else:\n                return other.id == self.id\n\n        return False\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/model.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.browser\nfrom aqt.browser.sidebar.item import SidebarItem\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\n\n\nclass SidebarModel(QAbstractItemModel):\n    def __init__(\n        self, sidebar: aqt.browser.sidebar.SidebarTreeView, root: SidebarItem\n    ) -> None:\n        super().__init__(sidebar)\n        self.sidebar = sidebar\n        self.root = root\n        self._cache_rows(root)\n\n    def _cache_rows(self, node: SidebarItem) -> None:\n        \"Cache index of children in parent.\"\n        for row, item in enumerate(node.children):\n            item._row_in_parent = row\n            self._cache_rows(item)\n\n    def item_for_index(self, idx: QModelIndex) -> SidebarItem:\n        return idx.internalPointer()\n\n    def index_for_item(self, item: SidebarItem) -> QModelIndex:\n        assert item._row_in_parent is not None\n        return self.createIndex(item._row_in_parent, 0, item)\n\n    def search(self, text: str) -> bool:\n        return self.root.search(text.lower())\n\n    # Qt API\n    ######################################################################\n\n    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:\n        if not parent.isValid():\n            return len(self.root.children)\n        else:\n            item: SidebarItem = parent.internalPointer()\n            return len(item.children)\n\n    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:\n        return 1\n\n    def index(\n        self, row: int, column: int, parent: QModelIndex = QModelIndex()\n    ) -> QModelIndex:\n        if not self.hasIndex(row, column, parent):\n            return QModelIndex()\n\n        parentItem: SidebarItem\n        if not parent.isValid():\n            parentItem = self.root\n        else:\n            parentItem = parent.internalPointer()\n\n        item = parentItem.children[row]\n        return self.createIndex(row, column, item)\n\n    def parent(self, child: QModelIndex) -> QModelIndex:  # type: ignore\n        if not child.isValid():\n            return QModelIndex()\n\n        childItem: SidebarItem = child.internalPointer()\n        parentItem = childItem._parent_item\n\n        if parentItem is None or parentItem == self.root:\n            return QModelIndex()\n\n        row = parentItem._row_in_parent\n        assert row is not None\n\n        return self.createIndex(row, 0, parentItem)\n\n    def data(\n        self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole\n    ) -> QVariant:\n        if not index.isValid():\n            return QVariant()\n\n        if role not in (\n            Qt.ItemDataRole.DisplayRole,\n            Qt.ItemDataRole.DecorationRole,\n            Qt.ItemDataRole.ToolTipRole,\n            Qt.ItemDataRole.EditRole,\n        ):\n            return QVariant()\n\n        item: SidebarItem = index.internalPointer()\n\n        if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):\n            return QVariant(item.name)\n        if role == Qt.ItemDataRole.ToolTipRole:\n            return QVariant(item.tooltip)\n        return QVariant(theme_manager.icon_from_resources(item.icon))\n\n    def setData(\n        self, index: QModelIndex, text: str, _role: int = Qt.ItemDataRole.EditRole\n    ) -> bool:\n        return self.sidebar._on_rename(index.internalPointer(), text)\n\n    def supportedDropActions(self) -> Qt.DropAction:\n        return Qt.DropAction.MoveAction\n\n    def flags(self, index: QModelIndex) -> Qt.ItemFlag:\n        if not index.isValid():\n            return Qt.ItemFlag.ItemIsEnabled\n        flags = (\n            Qt.ItemFlag.ItemIsEnabled\n            | Qt.ItemFlag.ItemIsSelectable\n            | Qt.ItemFlag.ItemIsDragEnabled\n        )\n        item: SidebarItem = index.internalPointer()\n        if item.item_type in self.sidebar.valid_drop_types:\n            flags |= Qt.ItemFlag.ItemIsDropEnabled\n        if item.item_type.is_editable():\n            flags |= Qt.ItemFlag.ItemIsEditable\n\n        return flags\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/searchbar.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.browser\nimport aqt.gui_hooks\nfrom aqt.qt import *\n\n\nclass SidebarSearchBar(QLineEdit):\n    def __init__(self, sidebar: aqt.browser.sidebar.SidebarTreeView) -> None:\n        QLineEdit.__init__(self, sidebar)\n        self.setPlaceholderText(sidebar.col.tr.browsing_sidebar_filter())\n        self.sidebar = sidebar\n        self.timer = QTimer(self)\n        self.timer.setInterval(600)\n        self.timer.setSingleShot(True)\n        self.setFrame(False)\n\n        qconnect(self.timer.timeout, self.onSearch)\n        qconnect(self.textChanged, self.onTextChanged)\n\n    def onTextChanged(self, text: str) -> None:\n        if not self.timer.isActive():\n            self.timer.start()\n\n    def onSearch(self) -> None:\n        self.sidebar.search_for(self.text())\n\n    def keyPressEvent(self, evt: QKeyEvent | None) -> None:\n        assert evt is not None\n        if evt.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down):\n            self.sidebar.setFocus()\n        elif evt.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):\n            self.onSearch()\n        else:\n            QLineEdit.keyPressEvent(self, evt)\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/toolbar.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom enum import Enum, auto\n\nimport aqt\nimport aqt.browser\nimport aqt.gui_hooks\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\nfrom aqt.utils import tr\n\n\nclass SidebarTool(Enum):\n    SELECT = auto()\n    SEARCH = auto()\n\n\nclass SidebarToolbar(QToolBar):\n    _tools: tuple[tuple[SidebarTool, str, Callable[[], str]], ...] = (\n        (\n            SidebarTool.SEARCH,\n            \"mdi:magnify\",\n            tr.actions_search,\n        ),\n        (\n            SidebarTool.SELECT,\n            \"mdi:selection-drag\",\n            tr.actions_select,\n        ),\n    )\n\n    def __init__(self, sidebar: aqt.browser.sidebar.SidebarTreeView) -> None:\n        super().__init__()\n        self.sidebar = sidebar\n        self._action_group = QActionGroup(self)\n        qconnect(self._action_group.triggered, self._on_action_group_triggered)\n        self._setup_tools()\n        self.setIconSize(QSize(18, 18))\n        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)\n        self.setStyle(QStyleFactory.create(\"fusion\"))\n        aqt.gui_hooks.theme_did_change.append(self._update_icons)\n\n    def _setup_tools(self) -> None:\n        for row, tool in enumerate(self._tools):\n            action = self.addAction(\n                theme_manager.icon_from_resources(tool[1]), tool[2]()\n            )\n            assert action is not None\n            action.setCheckable(True)\n            action.setShortcut(f\"Alt+{row + 1}\")\n            self._action_group.addAction(action)\n        # always start with first tool\n        active = 0\n        self._action_group.actions()[active].setChecked(True)\n        self.sidebar.tool = self._tools[active][0]\n\n    def _on_action_group_triggered(self, action: QAction) -> None:\n        index = self._action_group.actions().index(action)\n        self.sidebar.tool = self._tools[index][0]\n\n    def cleanup(self) -> None:\n        aqt.gui_hooks.theme_did_change.remove(self._update_icons)\n\n    def _update_icons(self) -> None:\n        for idx, action in enumerate(self._action_group.actions()):\n            action.setIcon(theme_manager.icon_from_resources(self._tools[idx][1]))\n"
  },
  {
    "path": "qt/aqt/browser/sidebar/tree.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Iterable\nfrom enum import Enum, auto\nfrom typing import cast\n\nimport aqt\nimport aqt.browser\nimport aqt.operations\nfrom anki.collection import (\n    Config,\n    OpChanges,\n    OpChangesWithCount,\n    SearchJoiner,\n    SearchNode,\n)\nfrom anki.decks import DeckCollapseScope, DeckId, DeckTreeNode\nfrom anki.models import NotetypeId\nfrom anki.notes import Note\nfrom anki.tags import TagTreeNode\nfrom anki.types import assert_exhaustive\nfrom aqt import colors, gui_hooks\nfrom aqt.browser.find_and_replace import FindAndReplaceDialog\nfrom aqt.browser.sidebar.item import SidebarItem, SidebarItemType\nfrom aqt.browser.sidebar.model import SidebarModel\nfrom aqt.browser.sidebar.searchbar import SidebarSearchBar\nfrom aqt.browser.sidebar.toolbar import SidebarTool, SidebarToolbar\nfrom aqt.clayout import CardLayout\nfrom aqt.fields import FieldDialog\nfrom aqt.models import Models\nfrom aqt.operations import CollectionOp, QueryOp\nfrom aqt.operations.deck import (\n    remove_decks,\n    rename_deck,\n    reparent_decks,\n    set_deck_collapsed,\n)\nfrom aqt.operations.tag import (\n    remove_tags_from_all_notes,\n    rename_tag,\n    reparent_tags,\n    set_tag_collapsed,\n)\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.theme import ColoredIcon, theme_manager\nfrom aqt.utils import (\n    KeyboardModifiersPressed,\n    askUser,\n    getOnlyText,\n    showInfo,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass SidebarStage(Enum):\n    ROOT = auto()\n    SAVED_SEARCHES = auto()\n    TODAY = auto()\n    FLAGS = auto()\n    CARD_STATE = auto()\n    DECKS = auto()\n    NOTETYPES = auto()\n    TAGS = auto()\n\n\n# fixme: we should have a top-level Sidebar class inheriting from QWidget that\n# handles the treeview, search bar and so on. Currently the treeview embeds the\n# search bar which is wrong, and the layout code is handled in browser.py instead\n# of here\nclass SidebarTreeView(QTreeView):\n    def __init__(self, browser: aqt.browser.Browser) -> None:\n        super().__init__()\n        self.browser = browser\n        self.mw = browser.mw\n        self.col = self.mw.col\n        self.current_search: str | None = None\n        self.valid_drop_types: tuple[SidebarItemType, ...] = ()\n        self._refresh_needed = False\n\n        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        self.customContextMenuRequested.connect(self.onContextMenu)  # type: ignore\n        self.setUniformRowHeights(True)\n        self.setHeaderHidden(True)\n        self.setIndentation(15)\n        self.setAutoExpandDelay(600)\n        self.setDragDropOverwriteMode(False)\n        self.setEditTriggers(QAbstractItemView.EditTrigger.EditKeyPressed)\n\n        qconnect(self.expanded, self._on_expansion)\n        qconnect(self.collapsed, self._on_collapse)\n\n        self._setup_style()\n\n        # these do not really belong here, they should be in a higher-level class\n        self.toolbar = SidebarToolbar(self)\n        self.searchBar = SidebarSearchBar(self)\n\n        gui_hooks.flag_label_did_change.append(self.refresh)\n        gui_hooks.theme_did_change.append(self._setup_style)\n\n    def _setup_style(self) -> None:\n        # match window background color and tweak style\n        bgcolor = QPalette().window().color().name()\n        theme_manager.var(colors.BORDER)\n        styles = [\n            \"padding: 3px\",\n            \"padding-right: 0px\",\n            \"border: 0\",\n            f\"background: {bgcolor}\",\n        ]\n\n        self.setStyleSheet(\"QTreeView { %s }\" % \";\".join(styles))\n\n    def cleanup(self) -> None:\n        self.toolbar.cleanup()\n        gui_hooks.flag_label_did_change.remove(self.refresh)\n        gui_hooks.theme_did_change.remove(self._setup_style)\n\n    @property\n    def tool(self) -> SidebarTool:\n        return self._tool\n\n    @tool.setter\n    def tool(self, tool: SidebarTool) -> None:\n        self._tool = tool\n        if tool == SidebarTool.SEARCH:\n            selection_mode = QAbstractItemView.SelectionMode.SingleSelection\n            drag_drop_mode = QAbstractItemView.DragDropMode.NoDragDrop\n            double_click_expands = False\n        else:\n            selection_mode = QAbstractItemView.SelectionMode.ExtendedSelection\n            drag_drop_mode = QAbstractItemView.DragDropMode.InternalMove\n            double_click_expands = True\n        self.setSelectionMode(selection_mode)\n        self.setDragDropMode(drag_drop_mode)\n        self.setExpandsOnDoubleClick(double_click_expands)\n\n    def model(self) -> SidebarModel:\n        return cast(SidebarModel, super().model())\n\n    # Refreshing\n    ###########################\n\n    def op_executed(\n        self, changes: OpChanges, handler: object | None, focused: bool\n    ) -> None:\n        if changes.browser_sidebar and handler is not self:\n            self._refresh_needed = True\n        if focused:\n            self.refresh_if_needed()\n\n    def refresh_if_needed(self) -> None:\n        if self._refresh_needed:\n            self.refresh()\n            self._refresh_needed = False\n\n    def refresh(self, new_current: SidebarItem | None = None) -> None:\n        \"Refresh list. No-op if sidebar is not visible.\"\n        if not self.isVisible():\n            return\n\n        if not new_current and self.model() and (idx := self.currentIndex()):\n            new_current = self.model().item_for_index(idx)\n\n        def on_done(root: SidebarItem) -> None:\n            # user may have closed browser\n            if sip.isdeleted(self):\n                return\n\n            # block repainting during refreshing to avoid flickering\n            self.setUpdatesEnabled(False)\n\n            if old_model := self.model():\n                old_model.deleteLater()\n            model = SidebarModel(self, root)\n            self.setModel(model)\n\n            if self.current_search:\n                self.search_for(self.current_search)\n            else:\n                self._expand_where_necessary(model)\n            if new_current:\n                self.restore_current(new_current)\n\n            self.setUpdatesEnabled(True)\n\n            # needs to be set after changing model\n            qconnect(\n                self._selection_model().selectionChanged, self._on_selection_changed\n            )\n\n        QueryOp(\n            parent=self.browser, op=lambda _: self._root_tree(), success=on_done\n        ).run_in_background()\n\n    def restore_current(self, current: SidebarItem) -> None:\n        if current_item := self.find_item(current.has_same_id):\n            index = self.model().index_for_item(current_item)\n\n            self._selection_model().setCurrentIndex(\n                index, QItemSelectionModel.SelectionFlag.SelectCurrent\n            )\n            self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)\n\n    def find_item(\n        self,\n        is_target: Callable[[SidebarItem], bool],\n        parent: SidebarItem | None = None,\n    ) -> SidebarItem | None:\n        def find_item_rec(parent: SidebarItem) -> SidebarItem | None:\n            if is_target(parent):\n                return parent\n            for child in parent.children:\n                if item := find_item_rec(child):\n                    return item\n            return None\n\n        return find_item_rec(parent or self.model().root)\n\n    def search_for(self, text: str) -> None:\n        self.showColumn(0)\n        if not text.strip():\n            self.current_search = None\n            self.refresh()\n            return\n\n        self.current_search = text\n        # start from a collapsed state, as it's faster\n        self.collapseAll()\n        self.setColumnHidden(0, not self.model().search(text))\n        self._expand_where_necessary(self.model(), searching=True)\n\n    def _expand_where_necessary(\n        self,\n        model: SidebarModel,\n        parent: QModelIndex | None = None,\n        searching: bool = False,\n    ) -> None:\n        scroll_to_first_match = searching\n\n        def expand_node(parent: QModelIndex) -> None:\n            nonlocal scroll_to_first_match\n\n            for row in range(model.rowCount(parent)):\n                idx = model.index(row, 0, parent)\n                if not idx.isValid():\n                    continue\n\n                # descend into children first\n                expand_node(idx)\n\n                if item := model.item_for_index(idx):\n                    if item.show_expanded(searching):\n                        self.setExpanded(idx, True)\n                    if item.is_highlighted() and scroll_to_first_match:\n                        self._selection_model().setCurrentIndex(\n                            idx,\n                            QItemSelectionModel.SelectionFlag.SelectCurrent,\n                        )\n                        self.scrollTo(\n                            idx, QAbstractItemView.ScrollHint.PositionAtCenter\n                        )\n                        scroll_to_first_match = False\n\n        expand_node(parent or QModelIndex())\n\n    def update_search(\n        self,\n        *terms: str | SearchNode,\n        joiner: SearchJoiner = \"AND\",\n    ) -> None:\n        \"\"\"Modify the current search string based on modifier keys, then refresh.\"\"\"\n        mods = KeyboardModifiersPressed()\n        previous = SearchNode(parsable_text=self.browser.current_search())\n        current = self.mw.col.group_searches(*terms, joiner=joiner)\n\n        # if Alt pressed, invert\n        if mods.alt:\n            current = SearchNode(negated=current)\n\n        try:\n            if mods.control and mods.shift:\n                # If Ctrl+Shift, replace searches nodes of the same type.\n                search = self.col.replace_in_search_node(previous, current)\n            elif mods.control:\n                # If Ctrl, AND with previous\n                search = self.col.join_searches(previous, current, \"AND\")\n            elif mods.shift:\n                # If Shift, OR with previous\n                search = self.col.join_searches(previous, current, \"OR\")\n            else:\n                search = self.col.build_search_string(current)\n        except Exception as e:\n            showWarning(str(e))\n        else:\n            self.browser.search_for(search)\n\n    # Qt API\n    ###########\n\n    def drawRow(\n        self, painter: QPainter | None, options: QStyleOptionViewItem, idx: QModelIndex\n    ) -> None:\n        if self.current_search and (item := self.model().item_for_index(idx)):\n            if item.is_highlighted():\n                assert painter is not None\n\n                brush = QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG))\n                painter.save()\n                painter.fillRect(options.rect, brush)\n                painter.restore()\n        return super().drawRow(painter, options, idx)\n\n    def dropEvent(self, event: QDropEvent | None) -> None:\n        assert event is not None\n\n        model = self.model()\n        if qtmajor == 5:\n            pos = event.pos()  # type: ignore\n        else:\n            pos = event.position().toPoint()\n        target_item = model.item_for_index(self.indexAt(pos))\n        if self.handle_drag_drop(self._selected_items(), target_item):\n            event.acceptProposedAction()\n\n    def mouseReleaseEvent(self, event: QMouseEvent | None) -> None:\n        assert event is not None\n\n        super().mouseReleaseEvent(event)\n        if (\n            self.tool == SidebarTool.SEARCH\n            and event.button() == Qt.MouseButton.LeftButton\n        ):\n            if qtmajor == 5:\n                pos = event.pos()  # type: ignore\n            else:\n                pos = event.position().toPoint()\n            if (index := self.currentIndex()) == self.indexAt(pos):\n                self._on_search(index)\n\n    def keyPressEvent(self, event: QKeyEvent | None) -> None:\n        assert event is not None\n\n        index = self.currentIndex()\n        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):\n            if not self.isPersistentEditorOpen(index):\n                self._on_search(index)\n        elif event.key() == Qt.Key.Key_Delete:\n            self._on_delete_key(index)\n        else:\n            super().keyPressEvent(event)\n\n    # Slots\n    ###########\n\n    def _on_selection_changed(self, _new: QItemSelection, _old: QItemSelection) -> None:\n        valid_drop_types = []\n        selected_items = self._selected_items()\n        selected_types = [item.item_type for item in selected_items]\n\n        # check if renaming is allowed\n        if all(item_type == SidebarItemType.DECK for item_type in selected_types):\n            valid_drop_types += [SidebarItemType.DECK, SidebarItemType.DECK_ROOT]\n        elif all(item_type == SidebarItemType.TAG for item_type in selected_types):\n            valid_drop_types += [SidebarItemType.TAG, SidebarItemType.TAG_ROOT]\n\n        # check if creating a saved search is allowed\n        if len(selected_items) == 1:\n            if (\n                selected_types[0] != SidebarItemType.SAVED_SEARCH\n                and selected_items[0].search_node is not None\n            ):\n                valid_drop_types += [\n                    SidebarItemType.SAVED_SEARCH_ROOT,\n                    SidebarItemType.SAVED_SEARCH,\n                ]\n\n        self.valid_drop_types = tuple(valid_drop_types)\n\n    def handle_drag_drop(self, sources: list[SidebarItem], target: SidebarItem) -> bool:\n        if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT):\n            return self._handle_drag_drop_decks(sources, target)\n        if target.item_type in (SidebarItemType.TAG, SidebarItemType.TAG_ROOT):\n            return self._handle_drag_drop_tags(sources, target)\n        if target.item_type in (\n            SidebarItemType.SAVED_SEARCH_ROOT,\n            SidebarItemType.SAVED_SEARCH,\n        ):\n            return self._handle_drag_drop_saved_search(sources, target)\n        return False\n\n    def _handle_drag_drop_decks(\n        self, sources: list[SidebarItem], target: SidebarItem\n    ) -> bool:\n        deck_ids = [\n            DeckId(source.id)\n            for source in sources\n            if source.item_type == SidebarItemType.DECK\n        ]\n        if not deck_ids:\n            return False\n\n        new_parent = DeckId(target.id)\n\n        reparent_decks(\n            parent=self.browser, deck_ids=deck_ids, new_parent=new_parent\n        ).run_in_background()\n\n        return True\n\n    def _handle_drag_drop_tags(\n        self, sources: list[SidebarItem], target: SidebarItem\n    ) -> bool:\n        tags = [\n            source.full_name\n            for source in sources\n            if source.item_type == SidebarItemType.TAG\n        ]\n        if not tags:\n            return False\n\n        if target.item_type == SidebarItemType.TAG_ROOT:\n            new_parent = \"\"\n        else:\n            new_parent = target.full_name\n\n        reparent_tags(\n            parent=self.browser, tags=tags, new_parent=new_parent\n        ).run_in_background()\n\n        return True\n\n    def _handle_drag_drop_saved_search(\n        self, sources: list[SidebarItem], _target: SidebarItem\n    ) -> bool:\n        if len(sources) != 1 or sources[0].search_node is None:\n            return False\n        self._save_search(\n            sources[0].name, self.col.build_search_string(sources[0].search_node)\n        )\n        return True\n\n    def _on_search(self, index: QModelIndex) -> None:\n        if model := self.model():\n            if item := model.item_for_index(index):\n                if search_node := item.search_node:\n                    self.update_search(search_node)\n\n    def _on_rename(self, item: SidebarItem, text: str) -> bool:\n        new_name = text.replace('\"', \"\")\n        if not new_name and item.item_type == SidebarItemType.FLAG:\n            self.restore_default_flag_name(item)\n        elif new_name and new_name != item.name:\n            if item.item_type == SidebarItemType.DECK:\n                self.rename_deck(item, new_name)\n            elif item.item_type == SidebarItemType.SAVED_SEARCH:\n                self.rename_saved_search(item, new_name)\n            elif item.item_type == SidebarItemType.TAG:\n                self.rename_tag(item, new_name)\n            elif item.item_type == SidebarItemType.FLAG:\n                self.rename_flag(item, new_name)\n        # renaming may be asynchronous so always return False\n        return False\n\n    def _on_delete_key(self, index: QModelIndex) -> None:\n        if item := self.model().item_for_index(index):\n            if self._enable_delete(item):\n                self._on_delete(item)\n\n    def _enable_delete(self, item: SidebarItem) -> bool:\n        return item.item_type.is_deletable() and all(\n            s.item_type == item.item_type for s in self._selected_items()\n        )\n\n    def _on_add(self, item: SidebarItem):\n        self.browser.add_card(DeckId(item.id))\n\n    def _on_delete(self, item: SidebarItem) -> None:\n        if item.item_type == SidebarItemType.SAVED_SEARCH:\n            self.remove_saved_searches(item)\n        elif item.item_type == SidebarItemType.DECK:\n            self.delete_decks(item)\n        elif item.item_type == SidebarItemType.TAG:\n            self.remove_tags(item)\n\n    def _on_expansion(self, idx: QModelIndex) -> None:\n        if self.current_search:\n            return\n        if item := self.model().item_for_index(idx):\n            item.expanded = True\n\n    def _on_collapse(self, idx: QModelIndex) -> None:\n        if self.current_search:\n            return\n        if item := self.model().item_for_index(idx):\n            item.expanded = False\n\n    # Tree building\n    ###########################\n\n    def _root_tree(self) -> SidebarItem:\n        root = SidebarItem(\"\", \"\", item_type=SidebarItemType.ROOT)\n\n        for stage in SidebarStage:\n            handled = gui_hooks.browser_will_build_tree(\n                False, root, stage, self.browser\n            )\n            if not handled:\n                self._build_stage(root, stage)\n\n        return root\n\n    def _build_stage(self, root: SidebarItem, stage: SidebarStage) -> None:\n        if stage is SidebarStage.SAVED_SEARCHES:\n            self._saved_searches_tree(root)\n        elif stage is SidebarStage.CARD_STATE:\n            self._card_state_tree(root)\n        elif stage is SidebarStage.TODAY:\n            self._today_tree(root)\n        elif stage is SidebarStage.FLAGS:\n            self._flags_tree(root)\n        elif stage is SidebarStage.DECKS:\n            self._deck_tree(root)\n        elif stage is SidebarStage.NOTETYPES:\n            self._notetype_tree(root)\n        elif stage is SidebarStage.TAGS:\n            self._tag_tree(root)\n        elif stage is SidebarStage.ROOT:\n            pass\n        else:\n            assert_exhaustive(stage)\n\n    def _section_root(\n        self,\n        *,\n        root: SidebarItem,\n        name: str,\n        icon: str | ColoredIcon,\n        collapse_key: Config.Bool.V,\n        type: SidebarItemType | None = None,\n    ) -> SidebarItem:\n        assert type is not None\n\n        def update(expanded: bool) -> None:\n            CollectionOp(\n                self.browser,\n                lambda col: col.set_config_bool(collapse_key, not expanded),\n            ).run_in_background(initiator=self)\n\n        top = SidebarItem(\n            name,\n            icon,\n            on_expanded=update,\n            expanded=not self.col.get_config_bool(collapse_key),\n            item_type=type,\n        )\n        root.add_child(top)\n\n        return top\n\n    # Tree: Saved Searches\n    ###########################\n\n    def _saved_searches_tree(self, root: SidebarItem) -> None:\n        icon = \"icons:heart-outline.svg\"\n        saved = self._get_saved_searches()\n\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_saved_searches(),\n            icon=icon,\n            collapse_key=Config.Bool.COLLAPSE_SAVED_SEARCHES,\n            type=SidebarItemType.SAVED_SEARCH_ROOT,\n        )\n\n        for name, filt in sorted(saved.items()):\n            item = SidebarItem(\n                name,\n                icon,\n                search_node=SearchNode(parsable_text=filt),\n                item_type=SidebarItemType.SAVED_SEARCH,\n            )\n            root.add_child(item)\n\n    # Tree: Today\n    ###########################\n\n    def _today_tree(self, root: SidebarItem) -> None:\n        icon = \"icons:clock-outline.svg\"\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_today(),\n            icon=icon,\n            collapse_key=Config.Bool.COLLAPSE_TODAY,\n            type=SidebarItemType.TODAY_ROOT,\n        )\n        type = SidebarItemType.TODAY\n\n        root.add_simple(\n            name=tr.browsing_sidebar_due_today(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(due_on_day=0),\n        )\n        root.add_simple(\n            name=tr.browsing_added_today(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(added_in_days=1),\n        )\n        root.add_simple(\n            name=tr.browsing_edited_today(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(edited_in_days=1),\n        )\n        root.add_simple(\n            name=tr.browsing_studied_today(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(rated=SearchNode.Rated(days=1)),\n        )\n        root.add_simple(\n            name=tr.browsing_sidebar_first_review(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(introduced_in_days=1),\n        )\n        root.add_simple(\n            name=tr.browsing_sidebar_rescheduled(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(\n                rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_BY_RESCHEDULE)\n            ),\n        )\n        root.add_simple(\n            name=tr.browsing_again_today(),\n            icon=icon,\n            type=type,\n            search_node=SearchNode(\n                rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN)\n            ),\n        )\n        root.add_simple(\n            name=tr.browsing_sidebar_overdue(),\n            icon=icon,\n            type=type,\n            search_node=self.col.group_searches(\n                SearchNode(card_state=SearchNode.CARD_STATE_DUE),\n                SearchNode(negated=SearchNode(due_on_day=0)),\n            ),\n        )\n\n    # Tree: Card State\n    ###########################\n\n    def _card_state_tree(self, root: SidebarItem) -> None:\n        icon = \"icons:circle.svg\"\n        icon_outline = \"icons:circle-outline.svg\"\n\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_card_state(),\n            icon=icon_outline,\n            collapse_key=Config.Bool.COLLAPSE_CARD_STATE,\n            type=SidebarItemType.CARD_STATE_ROOT,\n        )\n        type = SidebarItemType.CARD_STATE\n        colored_icon = ColoredIcon(path=icon, color=colors.FG_DISABLED)\n\n        root.add_simple(\n            tr.actions_new(),\n            icon=colored_icon.with_color(colors.STATE_NEW),\n            type=type,\n            search_node=SearchNode(card_state=SearchNode.CARD_STATE_NEW),\n        )\n\n        root.add_simple(\n            name=tr.scheduling_learning(),\n            icon=colored_icon.with_color(colors.STATE_LEARN),\n            type=type,\n            search_node=SearchNode(card_state=SearchNode.CARD_STATE_LEARN),\n        )\n        root.add_simple(\n            name=tr.browsing_sidebar_card_state_review(),\n            icon=colored_icon.with_color(colors.STATE_REVIEW),\n            type=type,\n            search_node=SearchNode(card_state=SearchNode.CARD_STATE_REVIEW),\n        )\n        root.add_simple(\n            name=tr.browsing_suspended(),\n            icon=colored_icon.with_color(colors.STATE_SUSPENDED),\n            type=type,\n            search_node=SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED),\n        )\n        root.add_simple(\n            name=tr.browsing_buried(),\n            icon=colored_icon.with_color(colors.STATE_BURIED),\n            type=type,\n            search_node=SearchNode(card_state=SearchNode.CARD_STATE_BURIED),\n        )\n\n    # Tree: Flags\n    ###########################\n\n    def _flags_tree(self, root: SidebarItem) -> None:\n        icon_off = \"icons:flag-variant-off-outline.svg\"\n        icon_outline = \"icons:flag-variant-outline.svg\"\n\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_flags(),\n            icon=icon_outline,\n            collapse_key=Config.Bool.COLLAPSE_FLAGS,\n            type=SidebarItemType.FLAG_ROOT,\n        )\n        root.search_node = SearchNode(flag=SearchNode.FLAG_ANY)\n\n        root.add_simple(\n            tr.browsing_no_flag(),\n            icon=icon_off,\n            type=SidebarItemType.FLAG_NONE,\n            search_node=SearchNode(flag=SearchNode.FLAG_NONE),\n        )\n\n        for flag in self.mw.flags.all():\n            root.add_child(\n                SidebarItem(\n                    name=flag.label,\n                    icon=flag.icon,\n                    search_node=flag.search_node,\n                    item_type=SidebarItemType.FLAG,\n                    id=flag.index,\n                )\n            )\n\n    # Tree: Tags\n    ###########################\n\n    def _tag_tree(self, root: SidebarItem) -> None:\n        icon = \"icons:tag-outline.svg\"\n        icon_off = \"icons:tag-off-outline.svg\"\n\n        def render(\n            root: SidebarItem, nodes: Iterable[TagTreeNode], head: str = \"\"\n        ) -> None:\n            def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]:\n                full_name = head + node.name\n                return lambda expanded: set_tag_collapsed(\n                    parent=self, tag=full_name, collapsed=not expanded\n                ).run_in_background(initiator=self)\n\n            for node in nodes:\n                item = SidebarItem(\n                    name=node.name,\n                    icon=icon,\n                    search_node=SearchNode(tag=head + node.name),\n                    on_expanded=toggle_expand(node),\n                    expanded=not node.collapsed,\n                    item_type=SidebarItemType.TAG,\n                    name_prefix=head,\n                )\n                root.add_child(item)\n                newhead = f\"{head + node.name}::\"\n                render(item, node.children, newhead)\n\n        tree = self.col.tags.tree()\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_tags(),\n            icon=icon,\n            collapse_key=Config.Bool.COLLAPSE_TAGS,\n            type=SidebarItemType.TAG_ROOT,\n        )\n        root.search_node = SearchNode(tag=\"_*\")\n        root.add_simple(\n            name=tr.browsing_sidebar_untagged(),\n            icon=icon_off,\n            type=SidebarItemType.TAG_NONE,\n            search_node=SearchNode(negated=SearchNode(tag=\"_*\")),\n        )\n\n        render(root, tree.children)\n\n    # Tree: Decks\n    ###########################\n\n    def _deck_tree(self, root: SidebarItem) -> None:\n        icon = \"icons:book-outline.svg\"\n        icon_current = \"icons:book-clock-outline.svg\"\n        icon_filtered = \"icons:book-cog-outline.svg\"\n\n        def render(\n            root: SidebarItem, nodes: Iterable[DeckTreeNode], head: str = \"\"\n        ) -> None:\n            def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]:\n                return lambda expanded: set_deck_collapsed(\n                    parent=self,\n                    deck_id=DeckId(node.deck_id),\n                    collapsed=not expanded,\n                    scope=DeckCollapseScope.BROWSER,\n                ).run_in_background(\n                    initiator=self,\n                )\n\n            for node in nodes:\n                item = SidebarItem(\n                    name=node.name,\n                    icon=icon_filtered if node.filtered else icon,\n                    search_node=SearchNode(deck=head + node.name),\n                    on_expanded=toggle_expand(node),\n                    expanded=not node.collapsed,\n                    item_type=SidebarItemType.DECK,\n                    id=node.deck_id,\n                    name_prefix=head,\n                )\n                root.add_child(item)\n                newhead = f\"{head + node.name}::\"\n                render(item, node.children, newhead)\n\n        tree = self.col.decks.deck_tree()\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_decks(),\n            icon=icon,\n            collapse_key=Config.Bool.COLLAPSE_DECKS,\n            type=SidebarItemType.DECK_ROOT,\n        )\n        root.search_node = SearchNode(deck=\"_*\")\n        current = root.add_simple(\n            name=tr.browsing_current_deck(),\n            icon=icon_current,\n            type=SidebarItemType.DECK_CURRENT,\n            search_node=SearchNode(deck=\"current\"),\n        )\n        current.id = self.mw.col.decks.selected()\n\n        render(root, tree.children)\n\n    # Tree: Notetypes\n    ###########################\n\n    def _notetype_tree(self, root: SidebarItem) -> None:\n        notetype_icon = \"icons:newspaper.svg\"\n        template_icon = \"icons:application-braces-outline.svg\"\n        field_icon = \"icons:form-textbox.svg\"\n\n        root = self._section_root(\n            root=root,\n            name=tr.browsing_sidebar_notetypes(),\n            icon=notetype_icon,\n            collapse_key=Config.Bool.COLLAPSE_NOTETYPES,\n            type=SidebarItemType.NOTETYPE_ROOT,\n        )\n        root.search_node = SearchNode(note=\"_*\")\n\n        for nt in sorted(self.col.models.all(), key=lambda nt: nt[\"name\"].lower()):\n            item = SidebarItem(\n                nt[\"name\"],\n                notetype_icon,\n                search_node=SearchNode(note=nt[\"name\"]),\n                item_type=SidebarItemType.NOTETYPE,\n                id=nt[\"id\"],\n            )\n\n            for c, tmpl in enumerate(nt[\"tmpls\"]):\n                child = SidebarItem(\n                    tmpl[\"name\"],\n                    template_icon,\n                    search_node=self.col.group_searches(\n                        SearchNode(note=nt[\"name\"]), SearchNode(template=c)\n                    ),\n                    item_type=SidebarItemType.NOTETYPE_TEMPLATE,\n                    id=tmpl[\"ord\"],\n                )\n                item.add_child(child)\n\n            for c, fld in enumerate(nt[\"flds\"]):\n                child = SidebarItem(\n                    fld[\"name\"],\n                    field_icon,\n                    search_node=self.col.group_searches(\n                        SearchNode(note=nt[\"name\"]), SearchNode(field_name=fld[\"name\"])\n                    ),\n                    item_type=SidebarItemType.NOTETYPE_FIELD,\n                    id=fld[\"ord\"],\n                )\n                item.add_child(child)\n\n            root.add_child(item)\n\n    # Context menu\n    ###########################\n\n    def onContextMenu(self, point: QPoint) -> None:\n        index: QModelIndex = self.indexAt(point)\n        item = self.model().item_for_index(index)\n        if item and self._selection_model().isSelected(index):\n            self.show_context_menu(item, index)\n\n    def show_context_menu(self, item: SidebarItem, index: QModelIndex) -> None:\n        menu = QMenu()\n        self._maybe_add_type_specific_actions(menu, item)\n        menu.addSeparator()\n        self._maybe_add_add_action(menu, item)\n        self._maybe_add_delete_action(menu, item, index)\n        self._maybe_add_rename_actions(menu, item, index)\n        self._maybe_add_find_and_replace_action(menu, item, index)\n        menu.addSeparator()\n        self._maybe_add_search_actions(menu)\n        menu.addSeparator()\n        self._maybe_add_tree_actions(menu)\n        gui_hooks.browser_sidebar_will_show_context_menu(self, menu, item, index)\n        if menu.children():\n            menu.exec(QCursor.pos())\n\n    def _maybe_add_type_specific_actions(self, menu: QMenu, item: SidebarItem) -> None:\n        if item.item_type in (SidebarItemType.NOTETYPE, SidebarItemType.NOTETYPE_ROOT):\n            menu.addAction(\n                tr.browsing_manage_note_types(), lambda: self.manage_notetype(item)\n            )\n        elif item.item_type == SidebarItemType.NOTETYPE_TEMPLATE:\n            menu.addAction(tr.notetypes_cards(), lambda: self.manage_template(item))\n        elif item.item_type == SidebarItemType.NOTETYPE_FIELD:\n            menu.addAction(tr.notetypes_fields(), lambda: self.manage_fields(item))\n        elif item.item_type == SidebarItemType.SAVED_SEARCH_ROOT:\n            menu.addAction(\n                tr.browsing_sidebar_save_current_search(), self.save_current_search\n            )\n        elif item.item_type == SidebarItemType.SAVED_SEARCH:\n            menu.addAction(\n                tr.browsing_update_saved_search(),\n                lambda: self.update_saved_search(item),\n            )\n        elif item.item_type == SidebarItemType.TAG:\n            if all(s.item_type == item.item_type for s in self._selected_items()):\n                menu.addAction(\n                    tr.browsing_add_to_selected_notes(), self.add_tags_to_selected_notes\n                )\n                menu.addAction(\n                    tr.browsing_remove_from_selected_notes(),\n                    self.remove_tags_from_selected_notes,\n                )\n\n    def _maybe_add_add_action(self, menu: QMenu, item: SidebarItem) -> None:\n        if item.item_type.can_be_added_to():\n            menu.addAction(tr.browsing_add_notes(), lambda: self._on_add(item))\n\n    def _maybe_add_delete_action(\n        self, menu: QMenu, item: SidebarItem, index: QModelIndex\n    ) -> None:\n        if self._enable_delete(item):\n            menu.addAction(tr.actions_delete(), lambda: self._on_delete(item))\n\n    def _maybe_add_rename_actions(\n        self, menu: QMenu, item: SidebarItem, index: QModelIndex\n    ) -> None:\n        if item.item_type.is_editable() and len(self._selected_items()) == 1:\n            menu.addAction(tr.actions_rename(), lambda: self.edit(index))\n            if (\n                item.item_type in (SidebarItemType.TAG, SidebarItemType.DECK)\n                and item.name_prefix\n            ):\n                menu.addAction(\n                    tr.actions_rename_with_parents(),\n                    lambda: self._on_rename_with_parents(item),\n                )\n\n    def _maybe_add_find_and_replace_action(\n        self, menu: QMenu, item: SidebarItem, index: QModelIndex\n    ) -> None:\n        if (\n            len(self._selected_items()) == 1\n            and item.item_type is SidebarItemType.NOTETYPE_FIELD\n        ):\n            menu.addAction(\n                tr.browsing_find_and_replace(), lambda: self._on_find_and_replace(item)\n            )\n\n    def _maybe_add_search_actions(self, menu: QMenu) -> None:\n        nodes = [\n            item.search_node for item in self._selected_items() if item.search_node\n        ]\n        if not nodes:\n            return\n        if len(nodes) == 1:\n            menu.addAction(tr.actions_search(), lambda: self.update_search(*nodes))\n            return\n        sub_menu = menu.addMenu(tr.actions_search())\n        assert sub_menu is not None\n\n        sub_menu.addAction(\n            tr.actions_all_selected(), lambda: self.update_search(*nodes)\n        )\n        sub_menu.addAction(\n            tr.actions_any_selected(),\n            lambda: self.update_search(*nodes, joiner=\"OR\"),\n        )\n\n    def _maybe_add_tree_actions(self, menu: QMenu) -> None:\n        def set_expanded(expanded: bool) -> None:\n            for index in self.selectedIndexes():\n                self.setExpanded(index, expanded)\n\n        def set_children_expanded(expanded: bool) -> None:\n            for index in self.selectedIndexes():\n                self.setExpanded(index, True)\n                for row in range(self.model().rowCount(index)):\n                    self.setExpanded(self.model().index(row, 0, index), expanded)\n\n        if self.current_search:\n            return\n\n        selected_items = self._selected_items()\n        if not any(item.children for item in selected_items):\n            return\n\n        if any(not item.expanded for item in selected_items if item.children):\n            menu.addAction(tr.browsing_sidebar_expand(), lambda: set_expanded(True))\n        if any(item.expanded for item in selected_items if item.children):\n            menu.addAction(tr.browsing_sidebar_collapse(), lambda: set_expanded(False))\n        if any(\n            not c.expanded for i in selected_items for c in i.children if c.children\n        ):\n            menu.addAction(\n                tr.browsing_sidebar_expand_children(),\n                lambda: set_children_expanded(True),\n            )\n        if any(c.expanded for i in selected_items for c in i.children if c.children):\n            menu.addAction(\n                tr.browsing_sidebar_collapse_children(),\n                lambda: set_children_expanded(False),\n            )\n\n    def _on_rename_with_parents(self, item: SidebarItem) -> None:\n        title = \"Anki\"\n        if item.item_type is SidebarItemType.TAG:\n            title = tr.actions_rename_tag()\n        elif item.item_type is SidebarItemType.DECK:\n            title = tr.actions_rename_deck()\n\n        new_name = getOnlyText(\n            tr.actions_new_name(), title=title, default=item.full_name\n        ).replace('\"', \"\")\n        if not new_name or new_name == item.full_name:\n            return\n\n        if item.item_type is SidebarItemType.TAG:\n\n            def success(out: OpChangesWithCount) -> None:\n                if out.count:\n                    tooltip(tr.browsing_notes_updated(count=out.count), parent=self)\n                else:\n                    showInfo(tr.browsing_tag_rename_warning_empty(), parent=self)\n\n            rename_tag(\n                parent=self,\n                current_name=item.full_name,\n                new_name=new_name,\n            ).success(success).run_in_background()\n\n        elif item.item_type is SidebarItemType.DECK:\n            rename_deck(\n                parent=self,\n                deck_id=DeckId(item.id),\n                new_name=new_name,\n            ).run_in_background()\n\n    def _on_find_and_replace(self, item: SidebarItem) -> None:\n        field = None\n        if item.item_type is SidebarItemType.NOTETYPE_FIELD:\n            field = item.name\n        FindAndReplaceDialog(\n            self,\n            mw=self.mw,\n            note_ids=self.browser.selected_notes(),\n            field=field,\n        )\n\n    # Flags\n    ###########################\n\n    def rename_flag(self, item: SidebarItem, new_name: str) -> None:\n        item.name = new_name\n        self.mw.flags.rename_flag(item.id, new_name)\n\n    def restore_default_flag_name(self, item: SidebarItem) -> None:\n        self.mw.flags.restore_default_flag_name(item.id)\n        item.name = self.mw.flags.get_flag(item.id).label\n\n    # Decks\n    ###########################\n\n    def rename_deck(self, item: SidebarItem, new_name: str) -> None:\n        if not new_name or new_name == item.name:\n            return\n\n        # update UI immediately, to avoid redraw\n        item.name = new_name\n\n        rename_deck(\n            parent=self,\n            deck_id=DeckId(item.id),\n            new_name=item.name_prefix + new_name,\n        ).run_in_background()\n\n    def delete_decks(self, _item: SidebarItem) -> None:\n        remove_decks(\n            parent=self, deck_name=_item.name, deck_ids=self._selected_decks()\n        ).run_in_background()\n\n    # Tags\n    ###########################\n\n    def remove_tags(self, item: SidebarItem) -> None:\n        tags = self.mw.col.tags.join(self._selected_tags())\n        item.name = \"...\"\n\n        remove_tags_from_all_notes(\n            parent=self.browser, space_separated_tags=tags\n        ).run_in_background()\n\n    def rename_tag(self, item: SidebarItem, new_name: str) -> None:\n        if not new_name or new_name == item.name:\n            return\n\n        old_name = item.name\n        old_full_name = item.full_name\n        new_full_name = item.name_prefix + new_name\n\n        item.name = new_name\n        item.full_name = new_full_name\n\n        def success(out: OpChangesWithCount) -> None:\n            if out.count:\n                tooltip(tr.browsing_notes_updated(count=out.count), parent=self)\n            else:\n                # revert renaming of sidebar item\n                item.full_name = old_full_name\n                item.name = old_name\n                showInfo(tr.browsing_tag_rename_warning_empty(), parent=self)\n\n        rename_tag(\n            parent=self.browser,\n            current_name=old_full_name,\n            new_name=new_full_name,\n        ).success(success).run_in_background()\n\n    def add_tags_to_selected_notes(self) -> None:\n        tags = \" \".join(item.full_name for item in self._selected_items())\n        self.browser.add_tags_to_selected_notes(tags)\n\n    def remove_tags_from_selected_notes(self) -> None:\n        tags = \" \".join(item.full_name for item in self._selected_items())\n        self.browser.remove_tags_from_selected_notes(tags)\n\n    # Saved searches\n    ####################################\n\n    _saved_searches_key = \"savedFilters\"\n\n    def _get_saved_searches(self) -> dict[str, str]:\n        return self.col.get_config(self._saved_searches_key, {})\n\n    def _set_saved_searches(self, searches: dict[str, str]) -> None:\n        self.col.set_config(self._saved_searches_key, searches)\n\n    def _get_current_search(self) -> str | None:\n        try:\n            return self.col.build_search_string(self.browser.current_search())\n        except Exception as e:\n            showWarning(str(e))\n            return None\n\n    def _save_search(self, name: str, search: str, update: bool = False) -> None:\n        conf = self._get_saved_searches()\n        if not update and name in conf:\n            if conf[name] == search:\n                # nothing to do\n                return\n            if not askUser(tr.browsing_confirm_saved_search_overwrite(name=name)):\n                # don't overwrite existing saved search\n                return\n        conf[name] = search\n        self._set_saved_searches(conf)\n        self.refresh(SidebarItem(name, \"\", item_type=SidebarItemType.SAVED_SEARCH))\n\n    def remove_saved_searches(self, _item: SidebarItem) -> None:\n        selected = self._selected_saved_searches()\n        conf = self._get_saved_searches()\n        for name in selected:\n            del conf[name]\n        self._set_saved_searches(conf)\n        self.refresh()\n\n    def rename_saved_search(self, item: SidebarItem, new_name: str) -> None:\n        old_name = item.name\n        conf = self._get_saved_searches()\n        try:\n            filt = conf[old_name]\n        except KeyError:\n            return\n        if new_name in conf and not askUser(\n            tr.browsing_confirm_saved_search_overwrite(name=new_name)\n        ):\n            return\n        conf[new_name] = filt\n        del conf[old_name]\n        self._set_saved_searches(conf)\n        item.name = new_name\n        self.refresh()\n\n    def save_current_search(self) -> None:\n        if (search := self._get_current_search()) is None:\n            return\n        name = getOnlyText(tr.browsing_please_give_your_filter_a_name())\n        if not name:\n            return\n        self._save_search(name, search)\n\n    def update_saved_search(self, item: SidebarItem) -> None:\n        if (search := self._get_current_search()) is None:\n            return\n        self._save_search(item.name, search, update=True)\n\n    # Notetypes and templates\n    ####################################\n\n    def manage_notetype(self, item: SidebarItem) -> None:\n        Models(\n            self.mw,\n            parent=self.browser,\n            fromMain=True,\n            selected_notetype_id=NotetypeId(item.id),\n        )\n\n    def manage_template(self, item: SidebarItem) -> None:\n        assert item._parent_item is not None\n\n        note = Note(self.col, self.col.models.get(NotetypeId(item._parent_item.id)))\n        CardLayout(self.mw, note, ord=item.id, parent=self, fill_empty=True)\n\n    def manage_fields(self, item: SidebarItem) -> None:\n        assert item._parent_item is not None\n\n        notetype = self.mw.col.models.get(NotetypeId(item._parent_item.id))\n        assert notetype is not None\n\n        FieldDialog(self.mw, notetype, parent=self, open_at=item.id)\n\n    # Helpers\n    ####################################\n\n    def _selected_items(self) -> list[SidebarItem]:\n        return [self.model().item_for_index(idx) for idx in self.selectedIndexes()]\n\n    def _selected_decks(self) -> list[DeckId]:\n        return [\n            DeckId(item.id)\n            for item in self._selected_items()\n            if item.item_type == SidebarItemType.DECK\n        ]\n\n    def _selected_saved_searches(self) -> list[str]:\n        return [\n            item.name\n            for item in self._selected_items()\n            if item.item_type == SidebarItemType.SAVED_SEARCH\n        ]\n\n    def _selected_tags(self) -> list[str]:\n        return [\n            item.full_name\n            for item in self._selected_items()\n            if item.item_type == SidebarItemType.TAG\n        ]\n\n    def _selection_model(self) -> QItemSelectionModel:\n        selection_model = self.selectionModel()\n        assert selection_model is not None\n        return selection_model\n"
  },
  {
    "path": "qt/aqt/browser/table/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\n# ruff: noqa: F401\nimport copy\nimport time\nfrom collections.abc import Generator, Sequence\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, Union\n\nimport aqt\nimport aqt.browser\nfrom anki.cards import CardId\nfrom anki.collection import BrowserColumns as Columns\nfrom anki.collection import BrowserRow\nfrom anki.notes import NoteId\nfrom aqt import colors\nfrom aqt.qt import QColor\nfrom aqt.utils import tr\n\nColumn = Columns.Column\nItemId = Union[CardId, NoteId]\nItemList = Union[Sequence[CardId], Sequence[NoteId]]\n\n\n@dataclass\nclass SearchContext:\n    search: str\n    browser: aqt.browser.Browser\n    order: bool | str | Column = True\n    reverse: bool = False\n    addon_metadata: dict | None = None\n    # if set, provided ids will be used instead of the regular search\n    ids: Sequence[ItemId] | None = None\n\n\nclass Cell:\n    def __init__(\n        self, text: str, is_rtl: bool, elide_mode: BrowserRow.Cell.TextElideMode.V\n    ) -> None:\n        self.text: str = text\n        self.is_rtl: bool = is_rtl\n        self.elide_mode: aqt.Qt.TextElideMode = backend_elide_mode_to_aqt_elide_mode(\n            elide_mode\n        )\n\n\nclass CellRow:\n    is_disabled: bool = False\n\n    def __init__(\n        self,\n        cells: Generator[tuple[str, bool, BrowserRow.Cell.TextElideMode.V], None, None],\n        color: BrowserRow.Color.V,\n        font_name: str,\n        font_size: int,\n    ) -> None:\n        self.refreshed_at: float = time.time()\n        self.cells: tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells)\n        self.color: dict[str, str] | None = backend_color_to_aqt_color(color)\n        self.font_name: str = font_name or \"arial\"\n        self.font_size: int = font_size if font_size > 0 else 12\n\n    def is_stale(self, threshold: float) -> bool:\n        return self.refreshed_at < threshold\n\n    @staticmethod\n    def generic(length: int, cell_text: str) -> CellRow:\n        return CellRow(\n            ((cell_text, False, BrowserRow.Cell.ElideRight) for cell in range(length)),\n            BrowserRow.COLOR_DEFAULT,\n            \"arial\",\n            12,\n        )\n\n    @staticmethod\n    def placeholder(length: int) -> CellRow:\n        return CellRow.generic(length, \"...\")\n\n    @staticmethod\n    def disabled(length: int, cell_text: str) -> CellRow:\n        row = CellRow.generic(length, cell_text)\n        row.is_disabled = True\n        return row\n\n\ndef backend_elide_mode_to_aqt_elide_mode(\n    elide_mode: BrowserRow.Cell.TextElideMode.V,\n) -> aqt.Qt.TextElideMode:\n    if elide_mode == BrowserRow.Cell.ElideLeft:\n        return aqt.Qt.TextElideMode.ElideLeft\n    if elide_mode == BrowserRow.Cell.ElideMiddle:\n        return aqt.Qt.TextElideMode.ElideMiddle\n    if elide_mode == BrowserRow.Cell.ElideNone:\n        return aqt.Qt.TextElideMode.ElideNone\n    return aqt.Qt.TextElideMode.ElideRight\n\n\ndef backend_color_to_aqt_color(color: BrowserRow.Color.V) -> dict[str, str] | None:\n    temp_color = None\n\n    if color == BrowserRow.COLOR_MARKED:\n        temp_color = colors.STATE_MARKED\n    if color == BrowserRow.COLOR_SUSPENDED:\n        temp_color = colors.STATE_SUSPENDED\n    if color == BrowserRow.COLOR_BURIED:\n        temp_color = colors.STATE_BURIED\n    if color == BrowserRow.COLOR_FLAG_RED:\n        temp_color = colors.FLAG_1\n    if color == BrowserRow.COLOR_FLAG_ORANGE:\n        temp_color = colors.FLAG_2\n    if color == BrowserRow.COLOR_FLAG_GREEN:\n        temp_color = colors.FLAG_3\n    if color == BrowserRow.COLOR_FLAG_BLUE:\n        temp_color = colors.FLAG_4\n    if color == BrowserRow.COLOR_FLAG_PINK:\n        temp_color = colors.FLAG_5\n    if color == BrowserRow.COLOR_FLAG_TURQUOISE:\n        temp_color = colors.FLAG_6\n    if color == BrowserRow.COLOR_FLAG_PURPLE:\n        temp_color = colors.FLAG_7\n\n    return adjusted_bg_color(temp_color)\n\n\ndef adjusted_bg_color(color: dict[str, str] | None) -> dict[str, str] | None:\n    if color:\n        adjusted_color = copy.copy(color)\n        light = QColor(color[\"light\"]).lighter(150)\n        adjusted_color[\"light\"] = light.name()\n        dark = QColor(color[\"dark\"]).darker(150)\n        adjusted_color[\"dark\"] = dark.name()\n        return adjusted_color\n    else:\n        return None\n\n\nfrom .model import DataModel\nfrom .state import CardState, ItemState, NoteState\nfrom .table import StatusDelegate, Table\n"
  },
  {
    "path": "qt/aqt/browser/table/model.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable, Sequence\nfrom typing import Any\n\nimport aqt\nimport aqt.browser\nfrom anki.cards import Card, CardId\nfrom anki.collection import BrowserColumns as Columns\nfrom anki.collection import Collection\nfrom anki.consts import *\nfrom anki.errors import BackendError, NotFoundError\nfrom anki.notes import Note, NoteId\nfrom aqt import gui_hooks\nfrom aqt.browser.table import Cell, CellRow, Column, ItemId, SearchContext\nfrom aqt.browser.table.state import ItemState\nfrom aqt.qt import *\nfrom aqt.utils import tr\n\n\nclass DataModel(QAbstractTableModel):\n    \"\"\"Data manager for the browser table.\n\n    _items -- The card or note ids currently hold and corresponding to the\n              table's rows.\n    _rows -- The cached data objects to render items to rows.\n    columns -- The data objects of all available columns, used to define the display\n               of active columns and list all toggleable columns to the user.\n    _block_updates -- If True, serve stale content to avoid hitting the DB.\n    _stale_cutoff -- A threshold to decide whether a cached row has gone stale.\n    \"\"\"\n\n    def __init__(\n        self,\n        parent: QObject,\n        col: Collection,\n        state: ItemState,\n        row_state_will_change_callback: Callable,\n        row_state_changed_callback: Callable,\n    ) -> None:\n        super().__init__(parent)\n        self.col: Collection = col\n        self.columns: dict[str, Column] = {\n            c.key: c for c in self.col.all_browser_columns()\n        }\n        gui_hooks.browser_did_fetch_columns(self.columns)\n        self._state: ItemState = state\n        self._items: Sequence[ItemId] = []\n        self._rows: dict[int, CellRow] = {}\n        self._block_updates = False\n        self._stale_cutoff = 0.0\n        self._on_row_state_will_change = row_state_will_change_callback\n        self._on_row_state_changed = row_state_changed_callback\n        assert aqt.mw is not None\n        self._want_tooltips = aqt.mw.pm.show_browser_table_tooltips()\n\n    # Row Object Interface\n    ######################################################################\n\n    # Get Rows\n\n    def get_cell(self, index: QModelIndex) -> Cell:\n        return self.get_row(index).cells[index.column()]\n\n    def get_row(self, index: QModelIndex) -> CellRow:\n        item = self.get_item(index)\n        if row := self._rows.get(item):\n            if not self._block_updates and row.is_stale(self._stale_cutoff):\n                # need to refresh\n                return self._fetch_row_and_update_cache(index, item, row)\n            # return row, even if it's stale\n            return row\n        if self._block_updates:\n            # blank row until we unblock\n            return CellRow.placeholder(self.len_columns())\n        # missing row, need to build\n        return self._fetch_row_and_update_cache(index, item, None)\n\n    def _fetch_row_and_update_cache(\n        self, index: QModelIndex, item: ItemId, old_row: CellRow | None\n    ) -> CellRow:\n        \"\"\"Fetch a row from the backend, add it to the cache and return it.\n        Then fire callbacks if the row is being deleted or restored.\n        \"\"\"\n        new_row = self._fetch_row_from_backend(item)\n        # row state has changed if existence of cached and fetched counterparts differ\n        # if the row was previously uncached, it is assumed to have existed\n        state_change = (\n            new_row.is_disabled\n            if old_row is None\n            else old_row.is_disabled != new_row.is_disabled\n        )\n        if state_change:\n            self._on_row_state_will_change(index, not new_row.is_disabled)\n        self._rows[item] = new_row\n        if state_change:\n            self._on_row_state_changed(index, not new_row.is_disabled)\n        return self._rows[item]\n\n    def _fetch_row_from_backend(self, item: ItemId) -> CellRow:\n        try:\n            row = CellRow(*self.col.browser_row_for_id(item))\n        except BackendError as e:\n            return CellRow.disabled(self.len_columns(), str(e))\n        except Exception:\n            return CellRow.disabled(\n                self.len_columns(), tr.errors_please_check_database()\n            )\n        except BaseException:\n            # fatal error like a panic in the backend - dump it to the\n            # console so it gets picked up by the error handler\n            import traceback\n\n            traceback.print_exc()\n            # and prevent Qt from firing a storm of follow-up errors\n            self._block_updates = True\n            return CellRow.generic(self.len_columns(), \"error\")\n\n        gui_hooks.browser_did_fetch_row(\n            item, self._state.is_notes_mode(), row, self._state.active_columns\n        )\n        return row\n\n    def get_cached_row(self, index: QModelIndex) -> CellRow | None:\n        \"\"\"Get row if it is cached, regardless of staleness.\"\"\"\n        return self._rows.get(self.get_item(index))\n\n    # Reset\n\n    def mark_cache_stale(self) -> None:\n        self._stale_cutoff = time.time()\n\n    def reset(self) -> None:\n        self.begin_reset()\n        self.end_reset()\n\n    def begin_reset(self) -> None:\n        self.beginResetModel()\n        self.mark_cache_stale()\n\n    def end_reset(self) -> None:\n        self.endResetModel()\n\n    # Block/Unblock\n\n    def begin_blocking(self) -> None:\n        self._block_updates = True\n\n    def end_blocking(self) -> None:\n        self._block_updates = False\n        self.redraw_cells()\n\n    def redraw_cells(self) -> None:\n        \"Update cell contents, without changing search count/columns/sorting.\"\n        if self.is_empty():\n            return\n        top_left = self.index(0, 0)\n        bottom_right = self.index(self.len_rows() - 1, self.len_columns() - 1)\n        self.dataChanged.emit(top_left, bottom_right)  # type: ignore\n\n    # Item Interface\n    ######################################################################\n\n    # Get metadata\n\n    def is_empty(self) -> bool:\n        return not self._items\n\n    def len_rows(self) -> int:\n        return len(self._items)\n\n    def len_columns(self) -> int:\n        return len(self._state.active_columns)\n\n    # Get items (card or note ids depending on state)\n\n    def get_item(self, index: QModelIndex) -> ItemId:\n        return self._items[index.row()]\n\n    def get_items(self, indices: list[QModelIndex]) -> Sequence[ItemId]:\n        return [self.get_item(index) for index in indices]\n\n    def get_card_ids(self, indices: list[QModelIndex]) -> Sequence[CardId]:\n        return self._state.get_card_ids(self.get_items(indices))\n\n    def get_note_ids(self, indices: list[QModelIndex]) -> Sequence[NoteId]:\n        return self._state.get_note_ids(self.get_items(indices))\n\n    def get_note_id(self, index: QModelIndex) -> NoteId | None:\n        if nid_list := self._state.get_note_ids([self.get_item(index)]):\n            return nid_list[0]\n        return None\n\n    # Get row numbers from items\n\n    def get_item_row(self, item: ItemId) -> int | None:\n        for row, i in enumerate(self._items):\n            if i == item:\n                return row\n        return None\n\n    def get_item_rows(self, items: Sequence[ItemId]) -> list[int]:\n        rows = []\n        for row, i in enumerate(self._items):\n            if i in items:\n                rows.append(row)\n        return rows\n\n    def get_card_row(self, card_id: CardId) -> int | None:\n        return self.get_item_row(self._state.get_item_from_card_id(card_id))\n\n    # Get objects (cards or notes)\n\n    def get_card(self, index: QModelIndex) -> Card | None:\n        \"\"\"Try to return the indicated, possibly deleted card.\"\"\"\n        if not index.isValid():\n            return None\n        # The browser code will be calling .note() on the returned card, but\n        # the note might have been be deleted while the card still exists.\n        try:\n            card = self._state.get_card(self.get_item(index))\n            card.note()\n        except NotFoundError:\n            return None\n        return card\n\n    def get_note(self, index: QModelIndex) -> Note | None:\n        \"\"\"Try to return the indicated, possibly deleted note.\"\"\"\n        if not index.isValid():\n            return None\n        try:\n            return self._state.get_note(self.get_item(index))\n        except NotFoundError:\n            return None\n\n    # Table Interface\n    ######################################################################\n\n    def toggle_state(self, context: SearchContext) -> ItemState:\n        self.begin_reset()\n        self._state = self._state.toggle_state()\n        try:\n            self._search_inner(context)\n        except Exception:\n            # rollback to prevent inconsistent state\n            self._state = self._state.toggle_state()\n            raise\n        finally:\n            self.end_reset()\n        return self._state\n\n    # Rows\n\n    def search(self, context: SearchContext) -> None:\n        self.begin_reset()\n        try:\n            self._search_inner(context)\n        finally:\n            self.end_reset()\n\n    def _search_inner(self, context: SearchContext) -> None:\n        if context.order is True:\n            try:\n                context.order = self.columns[self._state.sort_column]\n            except KeyError:\n                # invalid sort column in config\n                context.order = self.columns[\"noteCrt\"]\n            context.reverse = self._state.sort_backwards\n        context.addon_metadata = {}\n        gui_hooks.browser_will_search(context)\n        if context.ids is None:\n            context.ids = self._state.find_items(\n                context.search, context.order, context.reverse\n            )\n        gui_hooks.browser_did_search(context)\n        self._items = context.ids\n        self._rows = {}\n\n    def reverse(self) -> None:\n        self.beginResetModel()\n        self._items = list(reversed(self._items))\n        self.endResetModel()\n\n    # Columns\n\n    def column_at(self, index: QModelIndex) -> Column:\n        return self.column_at_section(index.column())\n\n    def column_at_section(self, section: int) -> Column:\n        \"\"\"Returns the column object corresponding to the active column at index or the default\n        column object if no data is associated with the active column.\n        \"\"\"\n        key = self._state.column_key_at(section)\n        try:\n            return self.columns[key]\n        except KeyError:\n            self.columns[key] = addon_column_fillin(key)\n            return self.columns[key]\n\n    def active_column_index(self, column: str) -> int | None:\n        return (\n            self._state.active_columns.index(column)\n            if column in self._state.active_columns\n            else None\n        )\n\n    def toggle_column(self, column: str) -> None:\n        self.begin_reset()\n        self._state.toggle_active_column(column)\n        self.end_reset()\n\n    # Model interface\n    ######################################################################\n\n    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:\n        if parent and parent.isValid():\n            return 0\n        return self.len_rows()\n\n    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:\n        if parent and parent.isValid():\n            return 0\n        return self.len_columns()\n\n    def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:\n        if not index.isValid():\n            return QVariant()\n        if role == Qt.ItemDataRole.FontRole:\n            if not self.column_at(index).uses_cell_font:\n                return QVariant()\n            qfont = QFont()\n            row = self.get_row(index)\n            qfont.setFamily(row.font_name)\n            qfont.setPixelSize(row.font_size)\n            return qfont\n        elif role == Qt.ItemDataRole.TextAlignmentRole:\n            align: Qt.AlignmentFlag | int = Qt.AlignmentFlag.AlignVCenter\n            if self.column_at(index).alignment == Columns.ALIGNMENT_CENTER:\n                align |= Qt.AlignmentFlag.AlignHCenter\n            return getattr(align, \"value\", align)\n        elif role == Qt.ItemDataRole.DisplayRole:\n            return self.get_cell(index).text\n        elif role == Qt.ItemDataRole.ToolTipRole and self._want_tooltips:\n            return self.get_cell(index).text\n        return QVariant()\n\n    def headerData(\n        self, section: int, orientation: Qt.Orientation, role: int = 0\n    ) -> str | None:\n        if (\n            orientation == Qt.Orientation.Horizontal\n            and role == Qt.ItemDataRole.DisplayRole\n        ):\n            return self._state.column_label(self.column_at_section(section))\n        return None\n\n    def flags(self, index: QModelIndex) -> Qt.ItemFlag:\n        # shortcut for large selections (Ctrl+A) to avoid fetching large numbers of rows at once\n        if row := self.get_cached_row(index):\n            if row.is_disabled:\n                return Qt.ItemFlag(Qt.ItemFlag.NoItemFlags)\n        return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable\n\n\ndef addon_column_fillin(key: str) -> Column:\n    \"\"\"Return a column with generic fields and a label indicating to the user that this column was\n    added by an add-on.\n    \"\"\"\n    return Column(\n        key=key,\n        cards_mode_label=f\"{tr.browsing_addon()} ({key})\",\n        notes_mode_label=f\"{tr.browsing_addon()} ({key})\",\n        sorting_cards=Columns.SORTING_NONE,\n        sorting_notes=Columns.SORTING_NONE,\n        uses_cell_font=False,\n        alignment=Columns.ALIGNMENT_CENTER,\n    )\n"
  },
  {
    "path": "qt/aqt/browser/table/state.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod, abstractproperty\nfrom collections.abc import Sequence\nfrom typing import cast\n\nfrom anki.browser import BrowserConfig\nfrom anki.cards import Card, CardId\nfrom anki.collection import Collection\nfrom anki.errors import NotFoundError\nfrom anki.notes import Note, NoteId\nfrom anki.utils import ids2str\nfrom aqt.browser.table import Column, ItemId, ItemList\n\n\nclass ItemState(ABC):\n    GEOMETRY_KEY_PREFIX: str\n    SORT_COLUMN_KEY: str\n    SORT_BACKWARDS_KEY: str\n    _active_columns: list[str]\n\n    def __init__(self, col: Collection) -> None:\n        self.col = col\n        self._sort_column = self.col.get_config(self.SORT_COLUMN_KEY)\n        self._sort_backwards = self.col.get_config(self.SORT_BACKWARDS_KEY, False)\n\n    def is_notes_mode(self) -> bool:\n        \"\"\"Return True if the state is a NoteState.\"\"\"\n        return isinstance(self, NoteState)\n\n    # Stateless Helpers\n\n    def note_ids_from_card_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:\n        assert self.col.db is not None\n        return self.col.db.list(\n            f\"select distinct nid from cards where id in {ids2str(items)}\"\n        )\n\n    def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:\n        assert self.col.db is not None\n        return self.col.db.list(f\"select id from cards where nid in {ids2str(items)}\")\n\n    def column_key_at(self, index: int) -> str:\n        return self._active_columns[index]\n\n    def column_label(self, column: Column) -> str:\n        return (\n            column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label\n        )\n\n    def column_tooltip(self, column: Column) -> str:\n        if self.is_notes_mode():\n            return column.notes_mode_tooltip\n        return column.cards_mode_tooltip\n\n    # Columns and sorting\n\n    # abstractproperty is deprecated but used due to mypy limitations\n    # (https://github.com/python/mypy/issues/1362)\n    @abstractproperty\n    def active_columns(self) -> list[str]:\n        \"\"\"Return the saved or default columns for the state.\"\"\"\n\n    @abstractmethod\n    def toggle_active_column(self, column: str) -> None:\n        \"\"\"Add or remove an active column.\"\"\"\n\n    @property\n    def sort_column(self) -> str:\n        return self._sort_column\n\n    @sort_column.setter\n    def sort_column(self, column: str) -> None:\n        self.col.set_config(self.SORT_COLUMN_KEY, column)\n        self._sort_column = column\n\n    @property\n    def sort_backwards(self) -> bool:\n        \"If true, descending order.\"\n        return self._sort_backwards\n\n    @sort_backwards.setter\n    def sort_backwards(self, order: bool) -> None:\n        self.col.set_config(self.SORT_BACKWARDS_KEY, order)\n        self._sort_backwards = order\n\n    # Get objects\n\n    @abstractmethod\n    def get_card(self, item: ItemId) -> Card:\n        \"\"\"Return the item if it's a card or its first card if it's a note.\"\"\"\n\n    @abstractmethod\n    def get_note(self, item: ItemId) -> Note:\n        \"\"\"Return the item if it's a note or its note if it's a card.\"\"\"\n\n    # Get ids\n\n    @abstractmethod\n    def find_items(\n        self, search: str, order: bool | str | Column, reverse: bool\n    ) -> Sequence[ItemId]:\n        \"\"\"Return the item ids fitting the given search and order.\"\"\"\n\n    @abstractmethod\n    def get_item_from_card_id(self, card: CardId) -> ItemId:\n        \"\"\"Return the appropriate item id for a card id.\"\"\"\n\n    @abstractmethod\n    def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:\n        \"\"\"Return the card ids for the given item ids.\"\"\"\n\n    @abstractmethod\n    def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:\n        \"\"\"Return the note ids for the given item ids.\"\"\"\n\n    # Toggle\n\n    @abstractmethod\n    def toggle_state(self) -> ItemState:\n        \"\"\"Return an instance of the other state.\"\"\"\n\n    @abstractmethod\n    def get_new_items(self, old_items: Sequence[ItemId]) -> ItemList:\n        \"\"\"Given a list of ids from the other state, return the corresponding ids for this state.\"\"\"\n\n\nclass CardState(ItemState):\n    GEOMETRY_KEY_PREFIX = \"editor\"\n    SORT_COLUMN_KEY = BrowserConfig.CARDS_SORT_COLUMN_KEY\n    SORT_BACKWARDS_KEY = BrowserConfig.CARDS_SORT_BACKWARDS_KEY\n\n    def __init__(self, col: Collection) -> None:\n        super().__init__(col)\n        self._active_columns = self.col.load_browser_card_columns()\n\n    @property\n    def active_columns(self) -> list[str]:\n        return self._active_columns\n\n    def toggle_active_column(self, column: str) -> None:\n        if column in self._active_columns:\n            self._active_columns.remove(column)\n        else:\n            self._active_columns.append(column)\n        self.col.set_browser_card_columns(self._active_columns)\n\n    def get_card(self, item: ItemId) -> Card:\n        return self.col.get_card(CardId(item))\n\n    def get_note(self, item: ItemId) -> Note:\n        return self.get_card(item).note()\n\n    def find_items(\n        self, search: str, order: bool | str | Column, reverse: bool\n    ) -> Sequence[ItemId]:\n        return self.col.find_cards(search, order, reverse)\n\n    def get_item_from_card_id(self, card: CardId) -> ItemId:\n        return card\n\n    def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:\n        return cast(Sequence[CardId], items)\n\n    def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:\n        return super().note_ids_from_card_ids(items)\n\n    def toggle_state(self) -> NoteState:\n        return NoteState(self.col)\n\n    def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[CardId]:\n        return super().card_ids_from_note_ids(old_items)\n\n\nclass NoteState(ItemState):\n    GEOMETRY_KEY_PREFIX = \"editorNotesMode\"\n    SORT_COLUMN_KEY = BrowserConfig.NOTES_SORT_COLUMN_KEY\n    SORT_BACKWARDS_KEY = BrowserConfig.NOTES_SORT_BACKWARDS_KEY\n\n    def __init__(self, col: Collection) -> None:\n        super().__init__(col)\n        self._active_columns = self.col.load_browser_note_columns()\n\n    @property\n    def active_columns(self) -> list[str]:\n        return self._active_columns\n\n    def toggle_active_column(self, column: str) -> None:\n        if column in self._active_columns:\n            self._active_columns.remove(column)\n        else:\n            self._active_columns.append(column)\n        self.col.set_browser_note_columns(self._active_columns)\n\n    def get_card(self, item: ItemId) -> Card:\n        if cards := self.get_note(item).cards():\n            return cards[0]\n        raise NotFoundError(\"card not found\", None, None, None)\n\n    def get_note(self, item: ItemId) -> Note:\n        return self.col.get_note(NoteId(item))\n\n    def find_items(\n        self, search: str, order: bool | str | Column, reverse: bool\n    ) -> Sequence[ItemId]:\n        return self.col.find_notes(search, order, reverse)\n\n    def get_item_from_card_id(self, card: CardId) -> ItemId:\n        return self.col.get_card(card).note().id\n\n    def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:\n        return super().card_ids_from_note_ids(items)\n\n    def get_note_ids(self, items: Sequence[ItemId]) -> Sequence[NoteId]:\n        return cast(Sequence[NoteId], items)\n\n    def toggle_state(self) -> CardState:\n        return CardState(self.col)\n\n    def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[NoteId]:\n        return super().note_ids_from_card_ids(old_items)\n"
  },
  {
    "path": "qt/aqt/browser/table/table.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Sequence\nfrom typing import Any\n\nimport aqt\nimport aqt.browser\nimport aqt.forms\nfrom anki.cards import Card, CardId\nfrom anki.collection import Collection, Config, OpChanges\nfrom anki.consts import *\nfrom anki.notes import Note, NoteId\nfrom aqt import gui_hooks\nfrom aqt.browser.table import Columns, ItemId, SearchContext\nfrom aqt.browser.table.model import DataModel\nfrom aqt.browser.table.state import CardState, ItemState, NoteState\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    KeyboardModifiersPressed,\n    qtMenuShortcutWorkaround,\n    restoreHeader,\n    saveHeader,\n    showInfo,\n    tr,\n)\n\n\nclass Table:\n    SELECTION_LIMIT: int = 500\n\n    def __init__(self, browser: aqt.browser.Browser) -> None:\n        self.browser = browser\n        self.col: Collection = browser.col\n        self._state: ItemState = (\n            NoteState(self.col)\n            if self.col.get_config_bool(Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE)\n            else CardState(self.col)\n        )\n        self._model = DataModel(\n            self.browser,\n            self.col,\n            self._state,\n            self._on_row_state_will_change,\n            self._on_row_state_changed,\n        )\n        self._view: QTableView | None = None\n        # cached for performance\n        self._len_selection = 0\n        self._selected_rows: list[QModelIndex] | None = None\n        # temporarily set for selection preservation\n        self._current_item: ItemId | None = None\n        self._selected_items: Sequence[ItemId] = []\n\n    def set_view(self, view: QTableView) -> None:\n        self._view = view\n        self._setup_view()\n        self._setup_headers()\n\n    def cleanup(self) -> None:\n        self._save_header()\n\n    # Public Methods\n    ######################################################################\n\n    # Get metadata\n\n    def len(self) -> int:\n        return self._model.len_rows()\n\n    def len_selection(self, refresh: bool = False) -> int:\n        # `len(self._view.selectionModel().selectedRows())` is slow for large\n        # selections, because Qt queries flags() for every selected cell, so we\n        # calculate the number of selected rows ourselves\n        return self._len_selection\n\n    def has_current(self) -> bool:\n        return self._selection_model().currentIndex().isValid()\n\n    def has_previous(self) -> bool:\n        return self.has_current() and self._current().row() > 0\n\n    def has_next(self) -> bool:\n        return self.has_current() and self._current().row() < self.len() - 1\n\n    def is_notes_mode(self) -> bool:\n        return self._state.is_notes_mode()\n\n    # Get objects\n\n    def get_current_card(self) -> Card | None:\n        return self._model.get_card(self._current())\n\n    def get_current_note(self) -> Note | None:\n        return self._model.get_note(self._current())\n\n    def get_single_selected_card(self) -> Card | None:\n        \"\"\"If there is only one row selected return its card, else None.\n        This may be a different one than the current card.\"\"\"\n        if self.len_selection() != 1:\n            return None\n        return self._model.get_card(self._selected()[0])\n\n    # Get ids\n\n    def get_selected_card_ids(self) -> Sequence[CardId]:\n        return self._model.get_card_ids(self._selected())\n\n    def get_selected_note_ids(self) -> Sequence[NoteId]:\n        return self._model.get_note_ids(self._selected())\n\n    def get_card_ids_from_selected_note_ids(self) -> Sequence[CardId]:\n        return self._state.card_ids_from_note_ids(self.get_selected_note_ids())\n\n    # Selecting\n\n    def select_all(self) -> None:\n        assert self._view is not None\n        self._view.selectAll()\n\n    def clear_selection(self) -> None:\n        self._len_selection = 0\n        self._selected_rows = None\n        self._selection_model().clear()\n\n    def invert_selection(self) -> None:\n        selection_model = self._selection_model()\n        selection = selection_model.selection()\n        self.select_all()\n        selection_model.select(\n            selection,\n            QItemSelectionModel.SelectionFlag.Deselect\n            | QItemSelectionModel.SelectionFlag.Rows,\n        )\n\n    def select_single_card(\n        self, card_id: CardId, scroll_even_if_visible: bool = True\n    ) -> None:\n        \"\"\"Try to set the selection to the item corresponding to the given card.\"\"\"\n        self._reset_selection()\n        if (row := self._model.get_card_row(card_id)) is not None:\n            assert self._view is not None\n            self._view.selectRow(row)\n            self._scroll_to_row(row, scroll_even_if_visible)\n        else:\n            self.browser.on_all_or_selected_rows_changed()\n            self.browser.on_current_row_changed()\n\n    # Reset\n\n    def reset(self) -> None:\n        \"\"\"Reload table data from collection and redraw.\"\"\"\n        self.begin_reset()\n        self.end_reset()\n\n    def begin_reset(self) -> None:\n        self._save_selection()\n        self._model.begin_reset()\n\n    def end_reset(self) -> None:\n        self._model.end_reset()\n        self._restore_selection(self._intersected_selection)\n\n    def on_backend_will_block(self) -> None:\n        # make sure the card list doesn't try to refresh itself during the operation,\n        # as that will block the UI\n        self._model.begin_blocking()\n\n    def on_backend_did_block(self) -> None:\n        self._model.end_blocking()\n\n    def redraw_cells(self) -> None:\n        self._model.redraw_cells()\n\n    def op_executed(\n        self, changes: OpChanges, handler: object | None, focused: bool\n    ) -> None:\n        if changes.browser_table:\n            self._model.mark_cache_stale()\n        if focused:\n            self.redraw_cells()\n\n    # Modify table\n\n    def search(self, txt: str) -> None:\n        self._save_selection()\n        self._model.search(SearchContext(search=txt, browser=self.browser))\n        self._restore_selection(self._intersected_selection)\n\n    def toggle_state(self, is_notes_mode: bool, last_search: str) -> None:\n        if is_notes_mode == self.is_notes_mode():\n            return\n        self._save_header()\n        self._save_selection()\n        self._state = self._model.toggle_state(\n            SearchContext(search=last_search, browser=self.browser)\n        )\n        self.col.set_config_bool(\n            Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE,\n            self.is_notes_mode(),\n        )\n        self._restore_header()\n        self._restore_selection(self._toggled_selection)\n\n    # Move cursor\n\n    def to_previous_row(self) -> None:\n        self._move_current(QAbstractItemView.CursorAction.MoveUp)\n\n    def to_next_row(self) -> None:\n        self._move_current(QAbstractItemView.CursorAction.MoveDown)\n\n    def to_first_row(self) -> None:\n        self._move_current_to_row(0)\n\n    def to_last_row(self) -> None:\n        self._move_current_to_row(self._model.len_rows() - 1)\n\n    def to_row_of_unselected_note(self) -> Sequence[NoteId]:\n        \"\"\"Select and set focus to a row whose note is not selected, trying\n        the rows below the bottomost, then above the topmost selected row.\n        If that's not possible, clear selection.\n        Return previously selected note ids.\n        \"\"\"\n        nids = self.get_selected_note_ids()\n\n        bottom = max(r.row() for r in self._selected()) + 1\n        for row in range(bottom, self.len()):\n            index = self._model.index(row, 0)\n            if self._model.get_row(index).is_disabled:\n                continue\n            if self._model.get_note_id(index) in nids:\n                continue\n            self._move_current_to_row(row)\n            return nids\n\n        top = min(r.row() for r in self._selected()) - 1\n        for row in range(top, -1, -1):\n            index = self._model.index(row, 0)\n            if self._model.get_row(index).is_disabled:\n                continue\n            if self._model.get_note_id(index) in nids:\n                continue\n            self._move_current_to_row(row)\n            return nids\n\n        self._reset_selection()\n        self.browser.on_all_or_selected_rows_changed()\n        self.browser.on_current_row_changed()\n        return nids\n\n    def clear_current(self) -> None:\n        self._selection_model().setCurrentIndex(\n            QModelIndex(),\n            QItemSelectionModel.SelectionFlag.NoUpdate,\n        )\n\n    # Private methods\n    ######################################################################\n\n    # Helpers\n\n    def _current(self) -> QModelIndex:\n        return self._selection_model().currentIndex()\n\n    def _selected(self) -> list[QModelIndex]:\n        if self._selected_rows is None:\n            self._selected_rows = self._selection_model().selectedRows()\n        return self._selected_rows\n\n    def _set_current(self, row: int, column: int = 0) -> None:\n        index = self._model.index(row, self._horizontal_header().logicalIndex(column))\n        self._selection_model().setCurrentIndex(\n            index,\n            QItemSelectionModel.SelectionFlag.NoUpdate,\n        )\n\n    def _reset_selection(self) -> None:\n        \"\"\"Remove selection and focus without emitting signals.\n        If no selection change is triggered afterwards, `browser.on_all_or_selected_rows_changed()`\n        and `browser.on_current_row_changed()` must be called.\n        \"\"\"\n        self._selection_model().reset()\n        self._len_selection = 0\n        self._selected_rows = None\n\n    def _select_rows(self, rows: list[int]) -> None:\n        selection = QItemSelection()\n        for row in rows:\n            selection.select(\n                self._model.index(row, 0),\n                self._model.index(row, self._model.len_columns() - 1),\n            )\n        self._selection_model().select(\n            selection, QItemSelectionModel.SelectionFlag.SelectCurrent\n        )\n\n    def _set_sort_indicator(self) -> None:\n        hh = self._horizontal_header()\n        index = self._model.active_column_index(self._state.sort_column)\n        if index is None:\n            hh.setSortIndicatorShown(False)\n            return\n        if self._state.sort_backwards:\n            order = Qt.SortOrder.DescendingOrder\n        else:\n            order = Qt.SortOrder.AscendingOrder\n        hh.blockSignals(True)\n        hh.setSortIndicator(index, order)\n        hh.blockSignals(False)\n        hh.setSortIndicatorShown(True)\n\n    def _set_column_sizes(self) -> None:\n        hh = self._horizontal_header()\n        hh.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)\n        hh.setSectionResizeMode(\n            hh.logicalIndex(self._model.len_columns() - 1),\n            QHeaderView.ResizeMode.Stretch,\n        )\n        # this must be set post-resize or it doesn't work\n        hh.setCascadingSectionResizes(False)\n\n    def _save_header(self) -> None:\n        saveHeader(self._horizontal_header(), self._state.GEOMETRY_KEY_PREFIX)\n\n    def _restore_header(self) -> None:\n        hh = self._horizontal_header()\n        hh.blockSignals(True)\n        restoreHeader(hh, self._state.GEOMETRY_KEY_PREFIX)\n        self._set_column_sizes()\n        self._set_sort_indicator()\n        hh.blockSignals(False)\n\n    # Setup\n\n    def _setup_view(self) -> None:\n        assert self._view is not None\n        self._view.setSortingEnabled(True)\n        self._view.setModel(self._model)\n        self._view.selectionModel()\n        self._view.setItemDelegate(StatusDelegate(self.browser, self._model))\n        selection_model = self._selection_model()\n        qconnect(selection_model.selectionChanged, self._on_selection_changed)\n        qconnect(selection_model.currentChanged, self._on_current_changed)\n        self._view.setWordWrap(False)\n        self._view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)\n        horizontal_scroll_bar = self._view.horizontalScrollBar()\n        assert horizontal_scroll_bar is not None\n        horizontal_scroll_bar.setSingleStep(10)\n        self._update_font()\n        self._view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        qconnect(self._view.customContextMenuRequested, self._on_context_menu)\n\n    def _update_font(self) -> None:\n        # we can't choose different line heights efficiently, so we need\n        # to pick a line height big enough for any card template\n        curmax = 16\n        for m in self.col.models.all():\n            for t in m[\"tmpls\"]:\n                bsize = t.get(\"bsize\", 0)\n                curmax = max(curmax, bsize)\n\n        assert self._view is not None\n        vh = self._view.verticalHeader()\n        assert vh is not None\n        vh.setDefaultSectionSize(curmax + 6)\n\n    def _setup_headers(self) -> None:\n        assert self._view is not None\n        vh = self._view.verticalHeader()\n        assert vh is not None\n        hh = self._horizontal_header()\n        vh.hide()\n        hh.show()\n        hh.setHighlightSections(False)\n        hh.setMinimumSectionSize(50)\n        hh.setSectionsMovable(True)\n        hh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        self._restore_header()\n        qconnect(hh.customContextMenuRequested, self._on_header_context)\n        qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed)\n        qconnect(hh.sectionMoved, self._on_column_moved)\n\n    # Slots\n\n    def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:\n        if current.row() != previous.row():\n            self.browser.on_current_row_changed()\n\n    def _on_selection_changed(\n        self, selected: QItemSelection, deselected: QItemSelection\n    ) -> None:\n        # `selection.indexes()` calls `flags()` for all the selection's indexes,\n        # whereas `selectedRows()` calls it for the indexes of the resulting selection.\n        # Both may be slow, so we try to optimise.\n        if KeyboardModifiersPressed().shift or KeyboardModifiersPressed().control:\n            # Current selection is modified. The number of added/removed rows is\n            # usually smaller than the number of rows in the resulting selection.\n            self._len_selection += (\n                len(selected.indexes()) - len(deselected.indexes())\n            ) // self._model.len_columns()\n        else:\n            # New selection is created. Usually a single row or none at all.\n            self._len_selection = len(self._selection_model().selectedRows())\n        self._selected_rows = None\n        self.browser.on_all_or_selected_rows_changed()\n\n    def _on_row_state_will_change(self, index: QModelIndex, was_restored: bool) -> None:\n        if not was_restored:\n            if self._selection_model().isSelected(index):\n                self._len_selection -= 1\n                self._selected_rows = None\n                self.browser.on_all_or_selected_rows_changed()\n            if index.row() == self._current().row():\n                # avoid focus on deleted (disabled) rows\n                self.clear_current()\n                self.browser.on_current_row_changed()\n\n    def _on_row_state_changed(self, index: QModelIndex, was_restored: bool) -> None:\n        if was_restored:\n            if self._selection_model().isSelected(index):\n                self._len_selection += 1\n                self._selected_rows = None\n                self.browser.on_all_or_selected_rows_changed()\n            elif not self._current().isValid() and self.len_selection() == 0:\n                # restore focus for convenience\n                self._select_rows([index.row()])\n                self._set_current(index.row())\n                self._scroll_to_row(index.row())\n                # row change and redraw have been triggered\n                return\n        # Workaround for a bug where the flags for the first column don't update\n        # automatically (due to the shortcut in 'model.flags()')\n        top_left = self._model.index(index.row(), 0)\n        bottom_right = self._model.index(index.row(), self._model.len_columns() - 1)\n        self._model.dataChanged.emit(top_left, bottom_right)  # type: ignore\n\n    def _on_context_menu(self, _point: QPoint) -> None:\n        menu = QMenu()\n        if self.is_notes_mode():\n            main = self.browser.form.menu_Notes\n            other = self.browser.form.menu_Cards\n            other_name = tr.qt_accel_cards()\n        else:\n            main = self.browser.form.menu_Cards\n            other = self.browser.form.menu_Notes\n            other_name = tr.qt_accel_notes()\n        for action in main.actions():\n            menu.addAction(action)\n        menu.addSeparator()\n        sub_menu = menu.addMenu(other_name)\n        assert sub_menu is not None\n        for action in other.actions():\n            sub_menu.addAction(action)\n        gui_hooks.browser_will_show_context_menu(self.browser, menu)\n        qtMenuShortcutWorkaround(menu)\n        menu.exec(QCursor.pos())\n\n    def _on_header_context(self, pos: QPoint) -> None:\n        assert self._view is not None\n        gpos = self._view.mapToGlobal(pos)\n        m = QMenu()\n        m.setToolTipsVisible(True)\n        for key, column in self._model.columns.items():\n            a = m.addAction(self._state.column_label(column))\n            assert a is not None\n            a.setCheckable(True)\n            a.setChecked(self._model.active_column_index(key) is not None)\n            a.setToolTip(self._state.column_tooltip(column))\n            qconnect(\n                a.toggled,\n                lambda checked, key=key: self._on_column_toggled(checked, key),\n            )\n        gui_hooks.browser_header_will_show_context_menu(self.browser, m)\n        m.exec(gpos)\n\n    def _on_column_moved(self, *_args: Any) -> None:\n        self._set_column_sizes()\n\n    def _on_column_toggled(self, checked: bool, column: str) -> None:\n        if not checked and self._model.len_columns() < 2:\n            showInfo(tr.browsing_you_must_have_at_least_one())\n            return\n        self._model.toggle_column(column)\n        self._set_column_sizes()\n        # sorted field may have been hidden or revealed\n        self._set_sort_indicator()\n        if checked:\n            self._scroll_to_column(self._model.len_columns() - 1)\n\n    def _on_sort_column_changed(self, section: int, order: Qt.SortOrder) -> None:\n        column = self._model.column_at_section(section)\n        sorting = column.sorting_notes if self.is_notes_mode() else column.sorting_cards\n        if sorting is Columns.SORTING_NONE:\n            showInfo(tr.browsing_sorting_on_this_column_is_not())\n            self._set_sort_indicator()\n            return\n        if self._state.sort_column != column.key:\n            self._state.sort_column = column.key\n            # numeric fields default to descending\n            if sorting is Columns.SORTING_DESCENDING:\n                order = Qt.SortOrder.DescendingOrder\n            self._state.sort_backwards = order == Qt.SortOrder.DescendingOrder\n            self.browser.search()\n        else:\n            descending = order == Qt.SortOrder.DescendingOrder\n            if self._state.sort_backwards != descending:\n                self._state.sort_backwards = descending\n                self._reverse()\n        self._set_sort_indicator()\n\n    def _reverse(self) -> None:\n        self._save_selection()\n        self._model.reverse()\n        self._restore_selection(self._intersected_selection)\n\n    # Restore selection\n\n    def _save_selection(self) -> None:\n        \"\"\"Save the current item and selected items.\"\"\"\n        if self.has_current():\n            self._current_item = self._model.get_item(self._current())\n        self._selected_items = self._model.get_items(self._selected())\n\n    def _restore_selection(self, new_selected_and_current: Callable) -> None:\n        \"\"\"Restore the saved selection and current element as far as possible and scroll to the\n        new current element. Clear the saved selection.\n        \"\"\"\n        self._reset_selection()\n        if not self._model.is_empty():\n            rows, current = new_selected_and_current()\n            rows = self._qualify_selected_rows(rows, current)\n            current = current or rows[0]\n            self._select_rows(rows)\n            self._set_current(current)\n            self._scroll_to_row(current)\n        if self.len_selection() == 0:\n            # no row change will fire\n            self.browser.on_all_or_selected_rows_changed()\n            self.browser.on_current_row_changed()\n        self._selected_items = []\n        self._current_item = None\n\n    def _qualify_selected_rows(self, rows: list[int], current: int | None) -> list[int]:\n        \"\"\"Return between 1 and SELECTION_LIMIT rows, as far as possible from rows or current.\"\"\"\n        if rows:\n            if len(rows) < self.SELECTION_LIMIT:\n                return rows\n            if current and current in rows:\n                return [current]\n            return rows[0:1]\n        return [current if current else 0]\n\n    def _intersected_selection(self) -> tuple[list[int], int | None]:\n        \"\"\"Return all rows of items that were in the saved selection and the row of the saved\n        current element if present.\n        \"\"\"\n        selected_rows = self._model.get_item_rows(self._selected_items)\n        current_row = self._current_item and self._model.get_item_row(\n            self._current_item\n        )\n        return selected_rows, current_row\n\n    def _toggled_selection(self) -> tuple[list[int], int | None]:\n        \"\"\"Convert the items of the saved selection and current element to the new state and\n        return their rows.\n        \"\"\"\n        selected_rows = self._model.get_item_rows(\n            self._state.get_new_items(self._selected_items)\n        )\n        current_row = None\n        if self._current_item:\n            if new_current := self._state.get_new_items([self._current_item]):\n                current_row = self._model.get_item_row(new_current[0])\n        return selected_rows, current_row\n\n    # Move\n\n    def _scroll_to_row(self, row: int, scroll_even_if_visible: bool = False) -> None:\n        \"\"\"Scroll vertically to row.\"\"\"\n        assert self._view is not None\n        top_border = self._view.rowViewportPosition(row)\n        bottom_border = top_border + self._view.rowHeight(0)\n        viewport = self._view.viewport()\n        assert viewport is not None\n        visible = top_border >= 0 and bottom_border < viewport.height()\n        if not visible or scroll_even_if_visible:\n            horizontal_scroll_bar = self._view.horizontalScrollBar()\n            assert horizontal_scroll_bar is not None\n            horizontal = horizontal_scroll_bar.value()\n            self._view.scrollTo(\n                self._model.index(row, 0), QAbstractItemView.ScrollHint.PositionAtTop\n            )\n            horizontal_scroll_bar.setValue(horizontal)\n\n    def _scroll_to_column(self, column: int) -> None:\n        \"\"\"Scroll horizontally to column.\"\"\"\n        assert self._view is not None\n        position = self._view.columnViewportPosition(column)\n        viewport = self._view.viewport()\n        assert viewport is not None\n        visible = 0 <= position < viewport.width()\n        if not visible:\n            vertical_scroll_bar = self._view.verticalScrollBar()\n            assert vertical_scroll_bar is not None\n            vertical = vertical_scroll_bar.value()\n            self._view.scrollTo(\n                self._model.index(0, column),\n                QAbstractItemView.ScrollHint.PositionAtCenter,\n            )\n            vertical_scroll_bar.setValue(vertical)\n\n    def _move_current_to_index(self, index: QModelIndex) -> None:\n        if not self.has_current():\n            return\n\n        assert self._view is not None\n\n        # Setting current like this avoids a bug with shift-click selection\n        # https://github.com/ankitects/anki/issues/2469\n        self._view.setCurrentIndex(index)\n        self._selection_model().select(\n            index,\n            QItemSelectionModel.SelectionFlag.Clear\n            | QItemSelectionModel.SelectionFlag.Select\n            | QItemSelectionModel.SelectionFlag.Rows,\n        )\n\n    def _move_current(\n        self,\n        direction: QAbstractItemView.CursorAction,\n    ) -> None:\n        assert self._view is not None\n        index = self._view.moveCursor(\n            direction,\n            self.browser.mw.app.keyboardModifiers(),\n        )\n        self._move_current_to_index(index)\n\n    def _move_current_to_row(self, row: int) -> None:\n        selection_model = self._selection_model()\n        old = selection_model.currentIndex()\n        self._move_current_to_index(self._model.index(row, 0))\n        if not KeyboardModifiersPressed().shift:\n            return\n        new = selection_model.currentIndex()\n        selection = QItemSelection(new, old)\n        selection_model.select(\n            selection,\n            QItemSelectionModel.SelectionFlag.SelectCurrent\n            | QItemSelectionModel.SelectionFlag.Rows,\n        )\n\n    def _selection_model(self) -> QItemSelectionModel:\n        assert self._view is not None\n        selection_model = self._view.selectionModel()\n        assert selection_model is not None\n        return selection_model\n\n    def _horizontal_header(self) -> QHeaderView:\n        assert self._view is not None\n        hh = self._view.horizontalHeader()\n        assert hh is not None\n        return hh\n\n\nclass StatusDelegate(QItemDelegate):\n    def __init__(self, browser: aqt.browser.Browser, model: DataModel) -> None:\n        QItemDelegate.__init__(self, browser)\n        self._model = model\n\n    def paint(\n        self, painter: QPainter | None, option: QStyleOptionViewItem, index: QModelIndex\n    ) -> None:\n        option.textElideMode = self._model.get_cell(index).elide_mode\n        if self._model.get_cell(index).is_rtl:\n            option.direction = Qt.LayoutDirection.RightToLeft\n        if row_color := self._model.get_row(index).color:\n            brush = QBrush(theme_manager.qcolor(row_color))\n            assert painter\n            painter.save()\n            painter.fillRect(option.rect, brush)\n            painter.restore()\n        return QItemDelegate.paint(self, painter, option, index)\n"
  },
  {
    "path": "qt/aqt/changenotetype.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nimport aqt\nimport aqt.deckconf\nimport aqt.main\nimport aqt.operations\nfrom anki.collection import OpChanges\nfrom anki.models import ChangeNotetypeRequest, NotetypeId\nfrom anki.notes import NoteId\nfrom aqt.operations.notetype import change_notetype_of_notes\nfrom aqt.qt import *\nfrom aqt.utils import (\n    disable_help_button,\n    restoreGeom,\n    saveGeom,\n    showWarning,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\nclass ChangeNotetypeDialog(QDialog):\n    TITLE = \"changeNotetype\"\n    silentlyClose = True\n\n    def __init__(\n        self,\n        parent: QWidget,\n        mw: aqt.main.AnkiQt,\n        note_ids: Sequence[NoteId],\n        notetype_id: NotetypeId,\n    ) -> None:\n        QDialog.__init__(self, parent)\n        self.mw = mw\n        self._note_ids = note_ids\n        self._setup_ui(notetype_id)\n        self.show()\n\n    def _setup_ui(self, notetype_id: NotetypeId) -> None:\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.setMinimumSize(400, 300)\n        disable_help_button(self)\n        restoreGeom(self, self.TITLE, default_size=(800, 800))\n\n        self.web = AnkiWebView(kind=AnkiWebViewKind.CHANGE_NOTETYPE)\n        self.web.setVisible(False)\n        self.web.load_sveltekit_page(f\"change-notetype/{notetype_id}\")\n        layout = QVBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.addWidget(self.web)\n        self.setLayout(layout)\n\n        self.setWindowTitle(tr.browsing_change_notetype())\n\n    def reject(self) -> None:\n        self.web.cleanup()\n        self.web = None  # type: ignore\n        saveGeom(self, self.TITLE)\n        QDialog.reject(self)\n\n    def save(self, data: bytes) -> None:\n        input = ChangeNotetypeRequest()\n        input.ParseFromString(data)\n\n        if not self.mw.confirm_schema_modification():\n            return\n\n        def on_done(op: OpChanges) -> None:\n            tooltip(\n                tr.browsing_notes_updated(count=len(input.note_ids)),\n                parent=self.parentWidget(),\n            )\n            self.reject()\n\n        input.note_ids.extend(self._note_ids)\n        change_notetype_of_notes(parent=self, input=input).success(\n            on_done\n        ).run_in_background()\n\n\ndef change_notetype_dialog(parent: QWidget, note_ids: Sequence[NoteId]) -> None:\n    try:\n        notetype_id = aqt.mw.col.models.get_single_notetype_of_notes(note_ids)\n    except Exception as e:\n        showWarning(str(e), parent=parent)\n        return\n\n    ChangeNotetypeDialog(\n        parent=parent, mw=aqt.mw, note_ids=note_ids, notetype_id=notetype_id\n    )\n"
  },
  {
    "path": "qt/aqt/clayout.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom typing import Any, Match, cast\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki import stdmodels\nfrom anki.collection import OpChanges\nfrom anki.consts import *\nfrom anki.lang import with_collapsed_whitespace, without_unicode_isolation\nfrom anki.notes import Note\nfrom anki.notetypes_pb2 import StockNotetype\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.forms import browserdisp\nfrom aqt.operations.notetype import restore_notetype_to_stock, update_notetype_legacy\nfrom aqt.qt import *\nfrom aqt.schema_change_tracker import ChangeTracker\nfrom aqt.sound import av_player, play_clicked_audio\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    HelpPage,\n    ask_user_dialog,\n    askUser,\n    disable_help_button,\n    downArrow,\n    getOnlyText,\n    openHelp,\n    restoreGeom,\n    restoreSplitter,\n    saveGeom,\n    saveSplitter,\n    shortcut,\n    showInfo,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\nclass CardLayout(QDialog):\n    def __init__(\n        self,\n        mw: AnkiQt,\n        note: Note,\n        ord: int = 0,\n        parent: QWidget | None = None,\n        fill_empty: bool = False,\n    ) -> None:\n        QDialog.__init__(self, parent or mw, Qt.WindowType.Window)\n        mw.garbage_collect_on_dialog_finish(self)\n        self.mw = aqt.mw\n        self.note = note\n        self.ord = ord\n        self.col = self.mw.col.weakref()\n        self.mm = self.mw.col.models\n        note_type = note.note_type()\n        assert note_type is not None\n        self.model = note_type\n        self.templates = self.model[\"tmpls\"]\n        self.fill_empty_action_toggled = fill_empty\n        self.night_mode_is_enabled = theme_manager.night_mode\n        self.mobile_emulation_enabled = False\n        self.have_autoplayed = False\n        self.mm._remove_from_cache(self.model[\"id\"])\n        self.change_tracker = ChangeTracker(self.mw)\n        self.setupTopArea()\n        self.setupMainArea()\n        self.setupButtons()\n        self.setupShortcuts()\n        self.setWindowTitle(\n            without_unicode_isolation(\n                tr.card_templates_card_types_for(val=self.model[\"name\"])\n            )\n        )\n        disable_help_button(self)\n        v1 = QVBoxLayout()\n        v1.addWidget(self.topArea)\n        v1.addWidget(self.mainArea)\n        v1.addLayout(self.buttons)\n        v1.setContentsMargins(12, 12, 12, 12)\n        self.setLayout(v1)\n        gui_hooks.card_layout_will_show(self)\n        self.redraw_everything()\n        restoreGeom(self, \"CardLayout\")\n        restoreSplitter(self.mainArea, \"CardLayoutMainArea\")\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self.show()\n        # take the focus away from the first input area when starting up,\n        # as users tend to accidentally type into the template\n        self.setFocus()\n\n    def redraw_everything(self) -> None:\n        self.ignore_change_signals = True\n        self.updateTopArea()\n        self.ignore_change_signals = False\n        self.update_current_ordinal_and_redraw(self.ord)\n\n    def update_current_ordinal_and_redraw(self, idx: int) -> None:\n        if self.ignore_change_signals:\n            return\n        self.ord = idx\n        self.have_autoplayed = False\n        self.fill_fields_from_template()\n        self.renderPreview()\n\n    def _isCloze(self) -> bool:\n        return self.model[\"type\"] == MODEL_CLOZE\n\n    # Top area\n    ##########################################################################\n\n    def setupTopArea(self) -> None:\n        self.topArea = QWidget()\n        self.topArea.setSizePolicy(\n            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum\n        )\n        self.topAreaForm = aqt.forms.clayout_top.Ui_Form()\n        self.topAreaForm.setupUi(self.topArea)\n        self.topAreaForm.templateOptions.setText(\n            f\"{tr.actions_options()} {downArrow()}\"\n        )\n        qconnect(self.topAreaForm.templateOptions.clicked, self.onMore)\n        qconnect(\n            self.topAreaForm.templatesBox.currentIndexChanged,\n            self.update_current_ordinal_and_redraw,\n        )\n        self.topAreaForm.card_type_label.setText(tr.card_templates_card_type())\n\n    def updateTopArea(self) -> None:\n        self.updateCardNames()\n\n    def updateCardNames(self) -> None:\n        self.ignore_change_signals = True\n        combo = self.topAreaForm.templatesBox\n        combo.clear()\n        combo.addItems(\n            self._summarizedName(idx, tmpl) for (idx, tmpl) in enumerate(self.templates)\n        )\n        combo.setCurrentIndex(self.ord)\n        combo.setEnabled(not self._isCloze())\n        self.ignore_change_signals = False\n\n    def _summarizedName(self, idx: int, tmpl: dict) -> str:\n        return \"{}: {}: {} -> {}\".format(\n            idx + 1,\n            tmpl[\"name\"],\n            self._fieldsOnTemplate(tmpl[\"qfmt\"]),\n            self._fieldsOnTemplate(tmpl[\"afmt\"]),\n        )\n\n    def _fieldsOnTemplate(self, fmt: str) -> str:\n        fmt_without_comments = re.sub(\"<!--.*?-->\", \"\", fmt)\n        matches = re.findall(\"{{[^#/}]+?}}\", fmt_without_comments)\n        chars_allowed = 30\n        field_names: list[str] = []\n        for m in matches:\n            # strip off mustache\n            m = re.sub(r\"[{}]\", \"\", m)\n            # strip off modifiers\n            m = m.split(\":\")[-1]\n            # don't show 'FrontSide'\n            if m == \"FrontSide\":\n                continue\n\n            field_names.append(m)\n            chars_allowed -= len(m)\n            if chars_allowed <= 0:\n                break\n\n        s = \"+\".join(field_names)\n        if chars_allowed <= 0:\n            s += \"+...\"\n        return s\n\n    def setupShortcuts(self) -> None:\n        self.tform.front_button.setToolTip(shortcut(\"Ctrl+1\"))\n        self.tform.back_button.setToolTip(shortcut(\"Ctrl+2\"))\n        self.tform.style_button.setToolTip(shortcut(\"Ctrl+3\"))\n        QShortcut(  # type: ignore\n            QKeySequence(\"Ctrl+1\"),\n            self,\n            activated=self.tform.front_button.click,\n        )\n        QShortcut(  # type: ignore\n            QKeySequence(\"Ctrl+2\"),\n            self,\n            activated=self.tform.back_button.click,\n        )\n        QShortcut(  # type: ignore\n            QKeySequence(\"Ctrl+3\"),\n            self,\n            activated=self.tform.style_button.click,\n        )\n        QShortcut(  # type: ignore\n            QKeySequence(\"F3\"),\n            self,\n            activated=lambda: (\n                self.update_current_ordinal_and_redraw(self.ord - 1)\n                if self.ord - 1 > -1\n                else None\n            ),\n        )\n        QShortcut(  # type: ignore\n            QKeySequence(\"F4\"),\n            self,\n            activated=lambda: (\n                self.update_current_ordinal_and_redraw(self.ord + 1)\n                if self.ord + 1 < len(self.templates)\n                else None\n            ),\n        )\n        for i in range(min(len(self.cloze_numbers), 9)):\n            QShortcut(  # type: ignore\n                QKeySequence(f\"Alt+{i + 1}\"),\n                self,\n                activated=lambda n=i: self.pform.cloze_number_combo.setCurrentIndex(n),\n            )\n\n    # Main area setup\n    ##########################################################################\n\n    def setupMainArea(self) -> None:\n        split = self.mainArea = QSplitter()\n        split.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)\n        split.setOrientation(Qt.Orientation.Horizontal)\n        left = QWidget()\n        tform = self.tform = aqt.forms.template.Ui_Form()\n        tform.setupUi(left)\n        self.setup_edit_area()\n        split.addWidget(left)\n        split.setCollapsible(0, False)\n\n        right = QWidget()\n        self.pform = aqt.forms.preview.Ui_Form()\n        pform = self.pform\n        pform.setupUi(right)\n        pform.preview_front.setText(tr.card_templates_front_preview())\n        pform.preview_back.setText(tr.card_templates_back_preview())\n        pform.preview_box.setTitle(tr.card_templates_preview_box())\n\n        self.setup_preview()\n        split.addWidget(right)\n        split.setCollapsible(1, False)\n\n    def setup_edit_area(self) -> None:\n        tform = self.tform\n        editor = tform.edit_area\n\n        tform.front_button.setText(tr.card_templates_front_template())\n        tform.back_button.setText(tr.card_templates_back_template())\n        tform.style_button.setText(tr.card_templates_template_styling())\n        tform.template_box.setTitle(tr.card_templates_template_box())\n\n        cnt = self.mw.col.models.use_count(self.model)\n        tform.changes_affect_label.setText(\n            self.col.tr.card_templates_changes_will_affect_notes(count=cnt)\n        )\n\n        qconnect(editor.textChanged, self.write_edits_to_template_and_redraw)\n        qconnect(tform.front_button.clicked, self.on_editor_toggled)\n        qconnect(tform.back_button.clicked, self.on_editor_toggled)\n        qconnect(tform.style_button.clicked, self.on_editor_toggled)\n\n        self.current_editor_index = 0\n        editor.setAcceptRichText(False)\n        font = QFont(\"Consolas\")\n        if not font.exactMatch():\n            font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)\n        editor.setFont(font)\n        tab_width = self.fontMetrics().horizontalAdvance(\" \" * 4)\n        editor.setTabStopDistance(tab_width)\n\n        palette = editor.palette()\n        palette.setColor(\n            QPalette.ColorGroup.Inactive,\n            QPalette.ColorRole.Highlight,\n            QColor(\"#4169e1\" if theme_manager.night_mode else \"#FFFF80\"),\n        )\n        palette.setColor(\n            QPalette.ColorGroup.Inactive,\n            QPalette.ColorRole.HighlightedText,\n            QColor(\"#ffffff\" if theme_manager.night_mode else \"#000000\"),\n        )\n        editor.setPalette(palette)\n\n        widg = tform.search_edit\n        widg.setPlaceholderText(\"Search\")\n        qconnect(widg.textChanged, self.on_search_changed)\n        qconnect(widg.returnPressed, self.on_search_next)\n\n    def setup_cloze_number_box(self) -> None:\n        names = (tr.card_templates_card(val=n) for n in self.cloze_numbers)\n        self.pform.cloze_number_combo.addItems(names)\n        try:\n            idx = self.cloze_numbers.index(self.ord + 1)\n            self.pform.cloze_number_combo.setCurrentIndex(idx)\n        except ValueError:\n            # invalid cloze\n            pass\n        qconnect(\n            self.pform.cloze_number_combo.currentIndexChanged, self.on_change_cloze\n        )\n\n    def on_change_cloze(self, idx: int) -> None:\n        self.ord = self.cloze_numbers[idx] - 1\n        self.have_autoplayed = False\n        self._renderPreview()\n\n    def on_editor_toggled(self) -> None:\n        if self.tform.front_button.isChecked():\n            self.current_editor_index = 0\n            self.pform.preview_front.setChecked(True)\n            self.on_preview_toggled()\n            self.add_field_button.setHidden(False)\n        elif self.tform.back_button.isChecked():\n            self.current_editor_index = 1\n            self.pform.preview_back.setChecked(True)\n            self.on_preview_toggled()\n            self.add_field_button.setHidden(False)\n        else:\n            self.current_editor_index = 2\n            self.add_field_button.setHidden(True)\n\n        self.fill_fields_from_template()\n\n    def on_search_changed(self, text: str) -> None:\n        editor = self.tform.edit_area\n        if not editor.find(text):\n            # try again from top\n            cursor = editor.textCursor()\n            cursor.movePosition(QTextCursor.MoveOperation.Start)\n            editor.setTextCursor(cursor)\n            if not editor.find(text):\n                tooltip(\"No matches found.\")\n\n    def on_search_next(self) -> None:\n        text = self.tform.search_edit.text()\n        self.on_search_changed(text)\n\n    def setup_preview(self) -> None:\n        pform = self.pform\n        self.preview_web = AnkiWebView(kind=AnkiWebViewKind.CARD_LAYOUT)\n        pform.verticalLayout.addWidget(self.preview_web)\n        pform.verticalLayout.setStretch(1, 99)\n        pform.preview_front.isChecked()\n        qconnect(pform.preview_front.clicked, self.on_preview_toggled)\n        qconnect(pform.preview_back.clicked, self.on_preview_toggled)\n        pform.preview_settings.setText(\n            f\"{tr.card_templates_preview_settings()} {downArrow()}\"\n        )\n        qconnect(pform.preview_settings.clicked, self.on_preview_settings)\n\n        self.preview_web.stdHtml(\n            self.mw.reviewer.revHtml(),\n            css=[\"css/reviewer.css\"],\n            js=[\n                \"js/mathjax.js\",\n                \"js/vendor/mathjax/tex-chtml-full.js\",\n                \"js/reviewer.js\",\n            ],\n            context=self,\n        )\n        self.preview_web.allow_drops = True\n        self.preview_web.eval(\"_blockDefaultDragDropBehavior();\")\n        self.preview_web.set_bridge_command(self._on_bridge_cmd, self)\n\n        gui_hooks.card_review_webview_did_init(\n            self.preview_web, AnkiWebViewKind.CARD_LAYOUT\n        )\n\n        if self._isCloze():\n            nums = list(self.note.cloze_numbers_in_fields())\n            if self.ord + 1 not in nums:\n                # current card is empty\n                nums.append(self.ord + 1)\n            self.cloze_numbers = sorted(nums)\n            self.setup_cloze_number_box()\n        else:\n            self.cloze_numbers = []\n            self.pform.cloze_number_combo.setHidden(True)\n\n    def on_fill_empty_action_toggled(self) -> None:\n        self.fill_empty_action_toggled = not self.fill_empty_action_toggled\n        self.on_preview_toggled()\n\n    def on_night_mode_action_toggled(self) -> None:\n        self.night_mode_is_enabled = not self.night_mode_is_enabled\n        force = json.dumps(self.night_mode_is_enabled)\n        self.preview_web.eval(\n            f\"document.documentElement.classList.toggle('night-mode', {force});\"\n        )\n        self.on_preview_toggled()\n\n    def on_mobile_class_action_toggled(self) -> None:\n        self.mobile_emulation_enabled = not self.mobile_emulation_enabled\n        self.on_preview_toggled()\n\n    def on_preview_settings(self) -> None:\n        m = QMenu(self)\n\n        a = m.addAction(tr.card_templates_fill_empty())\n        assert a is not None\n        a.setCheckable(True)\n        a.setChecked(self.fill_empty_action_toggled)\n        qconnect(a.triggered, self.on_fill_empty_action_toggled)\n        if not self.note_has_empty_field():\n            a.setVisible(False)\n\n        a = m.addAction(tr.card_templates_night_mode())\n        assert a is not None\n        a.setCheckable(True)\n        a.setChecked(self.night_mode_is_enabled)\n        qconnect(a.triggered, self.on_night_mode_action_toggled)\n\n        a = m.addAction(tr.card_templates_add_mobile_class())\n        assert a is not None\n        a.setCheckable(True)\n        a.setChecked(self.mobile_emulation_enabled)\n        qconnect(a.toggled, self.on_mobile_class_action_toggled)\n\n        m.popup(self.pform.preview_settings.mapToGlobal(QPoint(0, 0)))\n\n    def on_preview_toggled(self) -> None:\n        self.have_autoplayed = False\n        self._renderPreview()\n\n    def _on_bridge_cmd(self, cmd: str) -> Any:\n        if cmd.startswith(\"play:\"):\n            play_clicked_audio(cmd, self.rendered_card)\n\n    def note_has_empty_field(self) -> bool:\n        for field in self.note.fields:\n            if not field.strip():\n                # ignores HTML, but this should suffice\n                return True\n        return False\n\n    # Buttons\n    ##########################################################################\n\n    def setupButtons(self) -> None:\n        l = self.buttons = QHBoxLayout()\n        help = QPushButton(tr.actions_help())\n        help.setAutoDefault(False)\n        l.addWidget(help)\n        qconnect(help.clicked, self.onHelp)\n        l.addStretch()\n        self.add_field_button = QPushButton(tr.fields_add_field())\n        self.add_field_button.setAutoDefault(False)\n        l.addWidget(self.add_field_button)\n        qconnect(self.add_field_button.clicked, self.onAddField)\n        if not self._isCloze():\n            flip = QPushButton(tr.card_templates_flip())\n            flip.setAutoDefault(False)\n            l.addWidget(flip)\n            qconnect(flip.clicked, self.onFlip)\n        l.addStretch()\n        save = QPushButton(tr.actions_save())\n        save.setAutoDefault(False)\n        save.setShortcut(QKeySequence(\"Ctrl+Return\"))\n        l.addWidget(save)\n        qconnect(save.clicked, self.accept)\n\n        close = QPushButton(tr.actions_cancel())\n        close.setAutoDefault(False)\n        l.addWidget(close)\n        qconnect(close.clicked, self.reject)\n\n    # Reading/writing question/answer/css\n    ##########################################################################\n\n    def current_template(self) -> dict:\n        if self._isCloze():\n            return self.templates[0]\n        return self.templates[self.ord]\n\n    def fill_fields_from_template(self) -> None:\n        t = self.current_template()\n        self.ignore_change_signals = True\n\n        if self.current_editor_index == 0:\n            text = t[\"qfmt\"]\n        elif self.current_editor_index == 1:\n            text = t[\"afmt\"]\n        else:\n            text = self.model[\"css\"]\n\n        self.tform.edit_area.setPlainText(text)\n        self.ignore_change_signals = False\n\n    def write_edits_to_template_and_redraw(self) -> None:\n        if self.ignore_change_signals:\n            return\n\n        self.change_tracker.mark_basic()\n\n        text = self.tform.edit_area.toPlainText()\n\n        if self.current_editor_index == 0:\n            self.current_template()[\"qfmt\"] = text\n        elif self.current_editor_index == 1:\n            self.current_template()[\"afmt\"] = text\n        else:\n            self.model[\"css\"] = text\n\n        self.renderPreview()\n\n    # Preview\n    ##########################################################################\n\n    _previewTimer: QTimer | None = None\n\n    def renderPreview(self) -> None:\n        # schedule a preview when timing stops\n        self.cancelPreviewTimer()\n        self._previewTimer = self.mw.progress.timer(\n            200, self._renderPreview, False, parent=self\n        )\n\n    def cancelPreviewTimer(self) -> None:\n        if self._previewTimer:\n            self._previewTimer.stop()\n            self._previewTimer = None\n\n    def _renderPreview(self) -> None:\n        self.cancelPreviewTimer()\n\n        c = self.rendered_card = self.note.ephemeral_card(\n            self.ord,\n            custom_note_type=self.model,\n            custom_template=self.current_template(),\n            fill_empty=self.fill_empty_action_toggled,\n        )\n\n        ti = self.maybeTextInput\n\n        bodyclass = theme_manager.body_classes_for_card_ord(\n            c.ord, self.night_mode_is_enabled\n        )\n\n        if self.pform.preview_front.isChecked():\n            q = ti(self.mw.prepare_card_text_for_display(c.question()))\n            q = gui_hooks.card_will_show(q, c, \"clayoutQuestion\")\n            text = q\n        else:\n            a = ti(self.mw.prepare_card_text_for_display(c.answer()), type=\"a\")\n            a = gui_hooks.card_will_show(a, c, \"clayoutAnswer\")\n            text = a\n\n        # use _showAnswer to avoid the longer delay\n        self.preview_web.eval(f\"_showAnswer({json.dumps(text)},'{bodyclass}');\")\n        self.preview_web.eval(\n            f\"_emulateMobile({json.dumps(self.mobile_emulation_enabled)});\"\n        )\n\n        if not self.have_autoplayed:\n            self.have_autoplayed = True\n\n            if c.autoplay():\n                self.preview_web.setPlaybackRequiresGesture(False)\n                if self.pform.preview_front.isChecked():\n                    audio = c.question_av_tags()\n                else:\n                    audio = c.answer_av_tags()\n            else:\n                audio = []\n                self.preview_web.setPlaybackRequiresGesture(True)\n            side = \"question\" if self.pform.preview_front.isChecked() else \"answer\"\n            gui_hooks.av_player_will_play_tags(\n                audio,\n                side,\n                self,\n            )\n            av_player.play_tags(audio)\n\n        self.updateCardNames()\n\n    def maybeTextInput(self, txt: str, type: str = \"q\") -> str:\n        if \"[[type:\" not in txt:\n            return txt\n        origLen = len(txt)\n        txt = txt.replace(\"<hr id=answer>\", \"\")\n        hadHR = origLen != len(txt)\n\n        def answerRepl(match: Match) -> str:\n            res = self.mw.col.compare_answer(\"example\", \"sample\")\n            if hadHR:\n                res = f\"<hr id=answer>{res}\"\n            return res\n\n        type_filter = r\"\\[\\[type:.+?\\]\\]\"\n        repl: str | Callable\n\n        if type == \"q\":\n            repl = \"<input id='typeans' type=text value='example' readonly='readonly'>\"\n            repl = f\"<center>{repl}</center>\"\n        else:\n            repl = answerRepl\n        out = re.sub(type_filter, repl, txt, count=1)\n\n        warning = f\"<center><b>{tr.card_templates_type_boxes_warning()}</b></center>\"\n        return re.sub(type_filter, warning, out)\n\n    # Card operations\n    ######################################################################\n\n    def onRemove(self) -> None:\n        if len(self.templates) < 2:\n            showInfo(tr.card_templates_at_least_one_card_type_is())\n            return\n\n        def get_count() -> int:\n            ord = self.current_template()[\"ord\"]\n            return self.mm.template_use_count(self.model[\"id\"], ord)\n\n        def on_done(fut: Future) -> None:\n            card_cnt = fut.result()\n\n            template = self.current_template()\n            cards = tr.card_templates_card_count(count=card_cnt)\n            msg = tr.card_templates_delete_the_as_card_type_and(\n                template=template[\"name\"],\n                # unlike most cases, 'cards' is a string in this message\n                cards=cards,  # type: ignore[arg-type]\n            )\n            if not askUser(msg):\n                return\n\n            if not self.change_tracker.mark_schema():\n                return\n\n            self.onRemoveInner(template)\n\n        self.mw.taskman.with_progress(get_count, on_done)\n\n    def onRemoveInner(self, template: dict) -> None:\n        self.mm.remove_template(self.model, template)\n\n        # ensure current ordinal is within bounds\n        idx = self.ord\n        if idx >= len(self.templates):\n            self.ord = len(self.templates) - 1\n\n        self.redraw_everything()\n\n    def onRename(self) -> None:\n        template = self.current_template()\n        name = getOnlyText(tr.actions_new_name(), default=template[\"name\"]).replace(\n            '\"', \"\"\n        )\n        if not name.strip():\n            return\n\n        template[\"name\"] = name\n        self.redraw_everything()\n\n    def onReorder(self) -> None:\n        n = len(self.templates)\n        template = self.current_template()\n        current_pos = self.templates.index(template) + 1\n        pos_txt = getOnlyText(\n            tr.card_templates_enter_new_card_position_1(val=n),\n            default=str(current_pos),\n        )\n        if not pos_txt:\n            return\n        try:\n            pos = int(pos_txt)\n        except ValueError:\n            return\n        if pos < 1 or pos > n:\n            return\n        if pos == current_pos:\n            return\n        new_idx = pos - 1\n        if not self.change_tracker.mark_schema():\n            return\n        self.mm.reposition_template(self.model, template, new_idx)\n        self.ord = new_idx\n        self.redraw_everything()\n\n    def _newCardName(self) -> str:\n        n = len(self.templates) + 1\n        while 1:\n            name = without_unicode_isolation(tr.card_templates_card(val=n))\n            if name not in [t[\"name\"] for t in self.templates]:\n                break\n            n += 1\n        return name\n\n    def onAddCard(self) -> None:\n        cnt = self.mw.col.models.use_count(self.model)\n        txt = tr.card_templates_this_will_create_card_proceed(count=cnt)\n        if cnt and not askUser(txt):\n            return\n        if not self.change_tracker.mark_schema():\n            return\n        name = self._newCardName()\n        t = self.mm.new_template(name)\n        old = self.current_template()\n        t[\"qfmt\"] = old[\"qfmt\"]\n        t[\"afmt\"] = old[\"afmt\"]\n        self.mm.add_template(self.model, t)\n        self.ord = len(self.templates) - 1\n        self.redraw_everything()\n\n    def on_restore_to_default(\n        self, force_kind: StockNotetype.Kind.V | None = None\n    ) -> None:\n        if force_kind is None and not self.model.get(\"originalStockKind\", 0):\n            SelectStockNotetype(\n                mw=self.mw,\n                on_success=lambda kind: self.on_restore_to_default(force_kind=kind),\n                parent=self,\n            )\n            return\n\n        if not askUser(\n            with_collapsed_whitespace(\n                tr.card_templates_restore_to_default_confirmation()\n            ),\n            defaultno=True,\n        ):\n            return\n\n        def on_success(changes: OpChanges) -> None:\n            self.change_tracker.set_unchanged()\n            self.close()\n            showInfo(tr.card_templates_restored_to_default(), parent=self.mw)\n\n        restore_notetype_to_stock(\n            parent=self, notetype_id=self.model[\"id\"], force_kind=force_kind\n        ).success(on_success).run_in_background()\n\n    def onFlip(self) -> None:\n        old = self.current_template()\n        self._flipQA(old, old)\n        self.redraw_everything()\n\n    def _flipQA(self, src: dict, dst: dict) -> None:\n        m = re.match(\"(?s)(.+)<hr id=answer>(.+)\", src[\"afmt\"])\n        if not m:\n            showInfo(tr.card_templates_anki_couldnt_find_the_line_between())\n            return\n        self.change_tracker.mark_basic()\n        dst[\"afmt\"] = \"{{FrontSide}}\\n\\n<hr id=answer>\\n\\n%s\" % src[\"qfmt\"]\n        dst[\"qfmt\"] = m.group(2).strip()\n\n    def onCopyMarkdown(self) -> None:\n        template = self.current_template()\n\n        def sanitizeMarkdown(md):\n            return md.replace(\"```\", \"\\\\`\\\\`\\\\`\")\n\n        markdown = (\n            f\"## Front Template\\n\"\n            \"```html\\n\"\n            f\"{sanitizeMarkdown(template['qfmt'])}\\n\"\n            \"```\\n\"\n            \"## Back Template\\n\"\n            \"```html\\n\"\n            f\"{sanitizeMarkdown(template['afmt'])}\\n\"\n            \"```\\n\"\n            \"## Styling\\n\"\n            \"```css\\n\"\n            f\"{sanitizeMarkdown(self.model['css'])}\\n\"\n            \"```\\n\"\n        )\n        clipboard = QApplication.clipboard()\n        assert clipboard is not None\n        clipboard.setText(markdown)\n        tooltip(tr.about_copied_to_clipboard())\n\n    def onMore(self) -> None:\n        m = QMenu(self)\n\n        a = m.addAction(\n            tr.actions_with_ellipsis(action=tr.card_templates_restore_to_default())\n        )\n        assert a is not None\n        qconnect(\n            a.triggered,\n            lambda: self.on_restore_to_default(),\n        )\n\n        if not self._isCloze():\n            a = m.addAction(tr.card_templates_add_card_type())\n            assert a is not None\n            qconnect(a.triggered, self.onAddCard)\n\n            a = m.addAction(tr.card_templates_remove_card_type())\n            assert a is not None\n            qconnect(a.triggered, self.onRemove)\n\n            a = m.addAction(tr.card_templates_rename_card_type())\n            assert a is not None\n            qconnect(a.triggered, self.onRename)\n\n            a = m.addAction(tr.card_templates_reposition_card_type())\n            assert a is not None\n            qconnect(a.triggered, self.onReorder)\n\n            m.addSeparator()\n\n            t = self.current_template()\n            if t[\"did\"]:\n                s = tr.card_templates_on()\n            else:\n                s = tr.card_templates_off()\n            a = m.addAction(tr.card_templates_deck_override() + s)\n            assert a is not None\n            qconnect(a.triggered, self.onTargetDeck)\n\n        a = m.addAction(tr.card_templates_copy_info())\n        assert a is not None\n        qconnect(a.triggered, self.onCopyMarkdown)\n\n        a = m.addAction(tr.card_templates_browser_appearance())\n        assert a is not None\n        qconnect(a.triggered, self.onBrowserDisplay)\n\n        m.popup(self.topAreaForm.templateOptions.mapToGlobal(QPoint(0, 0)))\n\n    def onBrowserDisplay(self) -> None:\n        d = QDialog()\n        disable_help_button(d)\n        f = aqt.forms.browserdisp.Ui_Dialog()\n        f.setupUi(d)\n        t = self.current_template()\n        f.qfmt.setText(t.get(\"bqfmt\", \"\"))\n        f.afmt.setText(t.get(\"bafmt\", \"\"))\n        if t.get(\"bfont\"):\n            f.overrideFont.setChecked(True)\n        f.font.setCurrentFont(QFont(t.get(\"bfont\") or \"Arial\"))\n        f.fontSize.setValue(t.get(\"bsize\") or 12)\n        qconnect(f.buttonBox.accepted, lambda: self.onBrowserDisplayOk(f))\n        d.exec()\n\n    def onBrowserDisplayOk(self, f: browserdisp.Ui_Dialog) -> None:\n        t = self.current_template()\n        self.change_tracker.mark_basic()\n        t[\"bqfmt\"] = f.qfmt.text().strip()\n        t[\"bafmt\"] = f.afmt.text().strip()\n        if f.overrideFont.isChecked():\n            t[\"bfont\"] = f.font.currentFont().family()\n            t[\"bsize\"] = f.fontSize.value()\n        else:\n            for key in (\"bfont\", \"bsize\"):\n                if key in t:\n                    del t[key]\n\n    def onTargetDeck(self) -> None:\n        from aqt.tagedit import TagEdit\n\n        t = self.current_template()\n        d = QDialog(self)\n        d.setWindowTitle(\"Anki\")\n        disable_help_button(d)\n        d.setMinimumWidth(400)\n        l = QVBoxLayout()\n        lab = QLabel(\n            tr.card_templates_enter_deck_to_place_new(val=\"%s\")\n            % self.current_template()[\"name\"]\n        )\n        lab.setWordWrap(True)\n        l.addWidget(lab)\n        te = TagEdit(d, type=1)\n        te.setCol(self.col)\n        l.addWidget(te)\n        if t[\"did\"]:\n            deck = self.col.decks.get(t[\"did\"])\n            assert deck is not None\n            te.setText(deck[\"name\"])\n            te.selectAll()\n        bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)\n        qconnect(bb.rejected, d.close)\n        l.addWidget(bb)\n        d.setLayout(l)\n        d.exec()\n        self.change_tracker.mark_basic()\n        if not te.text().strip():\n            t[\"did\"] = None\n        else:\n            t[\"did\"] = self.col.decks.id(te.text())\n\n    def onAddField(self) -> None:\n        diag = QDialog(self)\n        form = aqt.forms.addfield.Ui_Dialog()\n        form.setupUi(diag)\n        disable_help_button(diag)\n        fields = [f[\"name\"] for f in self.model[\"flds\"]]\n        form.fields.addItems(fields)\n        form.fields.setCurrentRow(0)\n        form.font.setCurrentFont(QFont(\"Arial\"))\n        form.size.setValue(20)\n        if not diag.exec():\n            return\n        row = form.fields.currentIndex().row()\n        if row >= 0:\n            self._addField(\n                fields[row],\n                form.font.currentFont().family(),\n                form.size.value(),\n            )\n\n    def _addField(self, field: str, font: str, size: int) -> None:\n        text = self.tform.edit_area.toPlainText()\n        text += (\n            \"\\n<div style='font-family: \\\"%s\\\"; font-size: %spx;'>{{%s}}</div>\\n\"\n            % (\n                font,\n                size,\n                field,\n            )\n        )\n        self.tform.edit_area.setPlainText(text)\n        self.change_tracker.mark_basic()\n        self.write_edits_to_template_and_redraw()\n\n    # Closing & Help\n    ######################################################################\n\n    def accept(self) -> None:\n        def on_done(changes: OpChanges) -> None:\n            tooltip(tr.card_templates_changes_saved(), parent=self.parentWidget())\n            self.cleanup()\n            gui_hooks.sidebar_should_refresh_notetypes()\n            QDialog.accept(self)\n\n        update_notetype_legacy(parent=self, notetype=self.model).success(\n            on_done\n        ).run_in_background()\n\n    def reject(self) -> None:\n        def _reject() -> None:\n            self.cleanup()\n            QDialog.reject(self)\n\n        def callback(choice: int) -> None:\n            if choice == 0:\n                self.accept()\n            elif choice == 1:\n                _reject()\n\n        if self.change_tracker.changed():\n            ask_user_dialog(\n                text=tr.card_templates_discard_changes(),\n                callback=callback,\n                buttons=[\n                    QMessageBox.StandardButton.Save,\n                    QMessageBox.StandardButton.Discard,\n                    QMessageBox.StandardButton.Cancel,\n                ],\n                default_button=2,\n                parent=self,\n            )\n        else:\n            _reject()\n\n    def cleanup(self) -> None:\n        self.cancelPreviewTimer()\n        av_player.stop_and_clear_queue()\n        saveGeom(self, \"CardLayout\")\n        saveSplitter(self.mainArea, \"CardLayoutMainArea\")\n        self.preview_web.cleanup()\n        self.preview_web = None  # type: ignore\n        self.model = None  # type: ignore\n        self.rendered_card = None  # type: ignore\n        self.mw = None  # type: ignore\n\n    def onHelp(self) -> None:\n        openHelp(HelpPage.TEMPLATES)\n\n\nclass SelectStockNotetype(QDialog):\n    def __init__(\n        self,\n        mw: AnkiQt,\n        on_success: Callable[[StockNotetype.Kind.V], None],\n        parent: QWidget,\n    ) -> None:\n        self.mw = mw\n        QDialog.__init__(self, parent, Qt.WindowType.Window)\n        self.dialog = aqt.forms.addmodel.Ui_Dialog()\n        self.dialog.setupUi(self)\n        self.setWindowTitle(\"Anki\")\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        disable_help_button(self)\n        stock_types = stdmodels.get_stock_notetypes(mw.col)\n\n        for name, func in stock_types:\n            item = QListWidgetItem(name)\n            self.dialog.models.addItem(item)\n        self.dialog.models.setCurrentRow(0)\n        # the list widget will swallow the enter key\n        s = QShortcut(QKeySequence(\"Return\"), self)\n        qconnect(s.activated, self.accept)\n        # help\n        # self.dialog.buttonBox.standardButton(QDialogButtonBox.StandardButton.Help).\n        self.on_success = on_success\n        self.show()\n\n    def reject(self) -> None:\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        kind = cast(StockNotetype.Kind.ValueType, self.dialog.models.currentRow())\n        QDialog.accept(self)\n        # On Mac, we need to allow time for the existing modal to close or\n        # Qt gets confused.\n        self.mw.progress.single_shot(100, lambda: self.on_success(kind), True)\n"
  },
  {
    "path": "qt/aqt/colors.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom _aqt.colors import *\n"
  },
  {
    "path": "qt/aqt/customstudy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.collection import Collection\nfrom anki.consts import *\nfrom anki.decks import DeckId\nfrom anki.scheduler import CustomStudyRequest\nfrom anki.scheduler.base import CustomStudyDefaults\nfrom aqt.operations import QueryOp\nfrom aqt.operations.scheduling import custom_study\nfrom aqt.qt import *\nfrom aqt.taglimit import TagLimit\nfrom aqt.utils import disable_help_button, tr\n\nRADIO_NEW = 1\nRADIO_REV = 2\nRADIO_FORGOT = 3\nRADIO_AHEAD = 4\nRADIO_PREVIEW = 5\nRADIO_CRAM = 6\n\nTYPE_NEW = 0\nTYPE_DUE = 1\nTYPE_REVIEW = 2\nTYPE_ALL = 3\n\n\nclass CustomStudy(QDialog):\n    @staticmethod\n    def fetch_data_and_show(mw: aqt.AnkiQt) -> None:\n        def fetch_data(\n            col: Collection,\n        ) -> tuple[DeckId, CustomStudyDefaults, Any]:\n            deck_id = mw.col.decks.get_current_id()\n            defaults = col.sched.custom_study_defaults(deck_id)\n            card_count = col.decks.card_count(deck_id, True)\n            return (deck_id, defaults, card_count)\n\n        def show_dialog(data: tuple[DeckId, CustomStudyDefaults, Any]) -> None:\n            deck_id, defaults, card_count = data\n            CustomStudy(\n                mw=mw, deck_id=deck_id, card_count=card_count, defaults=defaults\n            )\n\n        QueryOp(\n            parent=mw, op=fetch_data, success=show_dialog\n        ).with_progress().run_in_background()\n\n    def __init__(\n        self,\n        mw: aqt.AnkiQt,\n        deck_id: DeckId,\n        card_count: Any,\n        defaults: CustomStudyDefaults,\n    ) -> None:\n        \"Don't call this directly; use CustomStudy.fetch_data_and_show().\"\n        QDialog.__init__(self, mw)\n        self.mw = mw\n        self.deck_id = deck_id\n        self.card_count = card_count\n        self.defaults = defaults\n        self.form = aqt.forms.customstudy.Ui_Dialog()\n        self.form.setupUi(self)\n        disable_help_button(self)\n        self.setupSignals()\n        self.form.radioNew.click()\n        self.open()\n\n    def setupSignals(self) -> None:\n        f = self.form\n        qconnect(f.radioNew.clicked, lambda: self.onRadioChange(RADIO_NEW))\n        qconnect(f.radioRev.clicked, lambda: self.onRadioChange(RADIO_REV))\n        qconnect(f.radioForgot.clicked, lambda: self.onRadioChange(RADIO_FORGOT))\n        qconnect(f.radioAhead.clicked, lambda: self.onRadioChange(RADIO_AHEAD))\n        qconnect(f.radioPreview.clicked, lambda: self.onRadioChange(RADIO_PREVIEW))\n        qconnect(f.radioCram.clicked, lambda: self.onRadioChange(RADIO_CRAM))\n        qconnect(f.spin.valueChanged, self.setTextAfterSpinner)\n\n    def count_with_children(self, parent: int, children: int) -> str:\n        if children:\n            return f\"{parent} {tr.custom_study_available_child_count(children)}\"\n        else:\n            return str(parent)\n\n    def onRadioChange(self, idx: int) -> None:\n        self.radioIdx = idx\n        form = self.form\n        min_spinner_value = 1\n        max_spinner_value = DYN_MAX_SIZE\n        current_spinner_value = 1\n        title_text = \"\"\n        show_cram_type = False\n        enable_ok_button = self.card_count is not None and self.card_count > 0\n        ok = tr.custom_study_ok()\n\n        if idx == RADIO_NEW:\n            title_text = tr.custom_study_available_new_cards_2(\n                count_string=self.count_with_children(\n                    self.defaults.available_new,\n                    self.defaults.available_new_in_children,\n                ),\n            )\n            text_before_spinner = tr.custom_study_increase_todays_new_card_limit_by()\n            current_spinner_value = self.defaults.extend_new\n            min_spinner_value = -DYN_MAX_SIZE\n            enable_ok_button = True\n        elif idx == RADIO_REV:\n            title_text = tr.custom_study_available_review_cards_2(\n                count_string=self.count_with_children(\n                    self.defaults.available_review,\n                    self.defaults.available_review_in_children,\n                ),\n            )\n            text_before_spinner = tr.custom_study_increase_todays_review_limit_by()\n            current_spinner_value = self.defaults.extend_review\n            min_spinner_value = -DYN_MAX_SIZE\n            enable_ok_button = True\n        elif idx == RADIO_FORGOT:\n            text_before_spinner = tr.custom_study_review_cards_forgotten_in_last()\n            max_spinner_value = 30\n        elif idx == RADIO_AHEAD:\n            text_before_spinner = tr.custom_study_review_ahead_by()\n        elif idx == RADIO_PREVIEW:\n            text_before_spinner = tr.custom_study_preview_new_cards_added_in_the()\n            current_spinner_value = 1\n        elif idx == RADIO_CRAM:\n            text_before_spinner = tr.custom_study_select()\n            ok = tr.custom_study_choose_tags()\n            current_spinner_value = 100\n            show_cram_type = True\n        else:\n            assert 0\n\n        form.spin.setVisible(True)\n        form.cardType.setVisible(show_cram_type)\n        form.title.setText(title_text)\n        form.title.setVisible(bool(title_text))\n        form.spin.setMinimum(min_spinner_value)\n        form.spin.setMaximum(max_spinner_value)\n        if max_spinner_value > 0:\n            form.spin.setEnabled(True)\n        else:\n            form.spin.setEnabled(False)\n        form.spin.setValue(current_spinner_value)\n        form.preSpin.setText(text_before_spinner)\n        self.setTextAfterSpinner(current_spinner_value)\n\n        ok_button = form.buttonBox.button(QDialogButtonBox.StandardButton.Ok)\n        assert ok_button is not None\n        ok_button.setText(ok)\n        ok_button.setEnabled(enable_ok_button)\n\n    def setTextAfterSpinner(self, newSpinValue) -> None:\n        form = self.form\n        text_after_spinner = \"\"\n        if self.radioIdx == RADIO_NEW:\n            text_after_spinner = tr.custom_study_cards(count=newSpinValue)\n        elif self.radioIdx == RADIO_REV:\n            text_after_spinner = tr.custom_study_cards(count=newSpinValue)\n        elif self.radioIdx == RADIO_FORGOT:\n            text_after_spinner = tr.custom_study_days(count=newSpinValue)\n        elif self.radioIdx == RADIO_AHEAD:\n            text_after_spinner = tr.custom_study_days(count=newSpinValue)\n        elif self.radioIdx == RADIO_PREVIEW:\n            text_after_spinner = tr.custom_study_days(count=newSpinValue)\n        elif self.radioIdx == RADIO_CRAM:\n            text_after_spinner = tr.custom_study_cards_from_the_deck(count=newSpinValue)\n        else:\n            assert 0\n        form.postSpin.setText(text_after_spinner)\n\n    def accept(self) -> None:\n        request = CustomStudyRequest(deck_id=self.deck_id)\n        if self.radioIdx == RADIO_NEW:\n            request.new_limit_delta = self.form.spin.value()\n        elif self.radioIdx == RADIO_REV:\n            request.review_limit_delta = self.form.spin.value()\n        elif self.radioIdx == RADIO_FORGOT:\n            request.forgot_days = self.form.spin.value()\n        elif self.radioIdx == RADIO_AHEAD:\n            request.review_ahead_days = self.form.spin.value()\n        elif self.radioIdx == RADIO_PREVIEW:\n            request.preview_days = self.form.spin.value()\n        else:\n            request.cram.card_limit = self.form.spin.value()\n\n            cram_type = self.form.cardType.currentRow()\n            if cram_type == TYPE_NEW:\n                request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW\n            elif cram_type == TYPE_DUE:\n                request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_DUE\n            elif cram_type == TYPE_REVIEW:\n                request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_REVIEW\n            else:\n                request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_ALL\n\n            def on_done(include: list[str], exclude: list[str]) -> None:\n                request.cram.tags_to_include.extend(include)\n                request.cram.tags_to_exclude.extend(exclude)\n                self._create_and_close(request)\n\n            # continues in background\n            TagLimit(self, self.defaults.tags, on_done)\n            return\n\n        # other cases are synchronous\n        self._create_and_close(request)\n\n    def _create_and_close(self, request: CustomStudyRequest) -> None:\n        # keep open on failure, as the cause was most likely an empty search\n        # result, which the user can remedy\n        custom_study(parent=self, request=request).success(\n            lambda _: QDialog.accept(self)\n        ).run_in_background()\n"
  },
  {
    "path": "qt/aqt/data/web/css/addonconf.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\nbody {\n    margin: 5px;\n    font-size: 13px;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/deckbrowser.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n@use \"../../../../../ts/lib/sass/root-vars\";\n@use \"../../../../../ts/lib/sass/vars\" as *;\n@use \"../../../../../ts/lib/sass/card-counts\";\n@use \"../../../../../ts/lib/sass/elevation\" as *;\n\ntable {\n    padding: 1rem;\n\n    .fancy & {\n        border: 1px solid var(--border-subtle);\n        border-radius: var(--border-radius-medium);\n\n        @include elevation(1, $opacity-boost: -0.08);\n        &:hover {\n            @include elevation(2);\n        }\n        background: var(--canvas-glass);\n    }\n}\n\na.deck {\n    color: color(fg);\n    text-decoration: none;\n    min-width: 5em;\n    display: inline-block;\n}\n\na.deck:hover {\n    text-decoration: underline;\n}\n\nth {\n    border-bottom: 1px solid color(border-subtle);\n    padding-bottom: 5px;\n}\n\ntr.deck td {\n    padding: 1px 12px;\n    border-bottom: 1px solid var(--border-subtle);\n\n    .fancy & {\n        border: unset;\n        padding: 4px 12px;\n    }\n}\n\ntr.top-level-drag-row td {\n    border-bottom: 1px solid transparent;\n}\n\ntd {\n    white-space: nowrap;\n}\n\ntr.drag-hover td {\n    border-bottom: 1px solid color(border);\n}\n\nbody {\n    margin: 2em 1em 1em 1em;\n    -webkit-user-select: none;\n}\n\n.current,\ntr:hover:not(.top-level-drag-row) {\n    td {\n        background: color(border-subtle);\n        &:first-child {\n            border-top-left-radius: prop(border-radius-medium);\n            border-bottom-left-radius: prop(border-radius-medium);\n        }\n        &:last-child {\n            border-top-right-radius: prop(border-radius-medium);\n            border-bottom-right-radius: prop(border-radius-medium);\n        }\n        .gears {\n            visibility: visible;\n        }\n    }\n}\n[dir=\"rtl\"] {\n    .current,\n    tr:hover:not(.top-level-drag-row) {\n        td {\n            background: color(canvas-inset);\n            &:first-child {\n                border-top-left-radius: 0;\n                border-bottom-left-radius: 0;\n                border-top-right-radius: prop(border-radius-medium);\n                border-bottom-right-radius: prop(border-radius-medium);\n            }\n            &:last-child {\n                border-top-right-radius: 0;\n                border-bottom-right-radius: 0;\n                border-top-left-radius: prop(border-radius-medium);\n                border-bottom-left-radius: prop(border-radius-medium);\n            }\n        }\n    }\n}\n\n.decktd {\n    min-width: 15em;\n    max-width: calc(100vw - 300px);\n    overflow: hidden;\n}\n\n.count {\n    min-width: 4em;\n    text-align: right;\n}\n\n.optscol {\n    width: 2em;\n}\n\n.collapse {\n    color: color(fg);\n    text-decoration: none;\n    display: inline-block;\n    width: 1em;\n}\n\n.filtered {\n    color: color(fg-link) !important;\n}\n\n.gears {\n    width: 1em;\n    height: 1em;\n    opacity: 0.5;\n    padding-top: 0.2em;\n    cursor: pointer;\n    visibility: hidden;\n}\n\n.nightMode {\n    .gears {\n        filter: invert(180);\n    }\n}\n\n.callout {\n    background: color(border);\n    padding: 1em;\n    margin: 1em;\n\n    div {\n        margin: 1em;\n    }\n}\n\n#studiedToday {\n    margin: 2em 0;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/overview.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"../../../../../ts/lib/sass/root-vars\";\n@use \"../../../../../ts/lib/sass/vars\" as *;\n@use \"../../../../../ts/lib/sass/card-counts\";\n@use \"../../../../../ts/lib/sass/button-mixins\" as button;\n\n.smallLink {\n    font-size: 10px;\n}\n\nh3 {\n    margin-bottom: 0;\n}\n\n.descfont {\n    padding: 1em;\n    color: color(fg-subtle);\n}\n\n.description {\n    white-space: pre-wrap;\n}\n\n#fulldesc {\n    display: none;\n}\n\n.descmid {\n    width: 70%;\n    margin: 0 auto 0;\n    text-align: left;\n}\n\n.dyn {\n    text-align: center;\n}\n\n#study {\n    @include button.base($primary: true);\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/reviewer-bottom.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"../../../../../ts/lib/sass/root-vars\";\n@use \"../../../../../ts/lib/sass/vars\" as *;\n@use \"../../../../../ts/lib/sass/card-counts\";\n\n:root {\n    --focus-color: #{palette-of(border-focus)};\n\n    .isMac {\n        --focus-color: rgba(0 103 244 / 0.247);\n    }\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n}\n\n#middle td[align=\"center\"] {\n    padding-top: 10px;\n    position: relative;\n}\n\nbutton {\n    min-width: 60px;\n    white-space: nowrap;\n    margin: 9px;\n    position: relative;\n}\n\n.hitem {\n    margin-top: 2px;\n}\n\n.stat {\n    padding-top: 10px;\n\n    @media (max-width: 583px) {\n        display: none;\n    }\n}\n\n.stat2 {\n    padding-top: 10px;\n    font-weight: normal;\n}\n\n:focus {\n    border-color: color(border-focus);\n}\n\n.nobold,\n.stattxt {\n    position: absolute;\n    white-space: nowrap;\n    font-size: small;\n    top: -3px;\n    left: 50%;\n    transform: translate(-50%, -100%);\n    font-weight: normal;\n    display: inline-block;\n}\n\n.spacer {\n    height: 18px;\n}\n\n.spacer2 {\n    height: 16px;\n}\n\n#outer {\n    border-top: 1px solid color(border);\n    /* Better compatibility with graphics pad/touchscreen */\n    -webkit-user-select: none;\n}\n\n.nightMode {\n    #outer {\n        border-top-color: color(border-subtle);\n    }\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/toolbar-bottom.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\nbody {\n    overflow: hidden;\n}\n\n#header {\n    border-bottom: 0;\n    margin-top: 0;\n    padding: 9px;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/toolbar.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"../../../../../ts/lib/sass/root-vars\";\n@use \"../../../../../ts/lib/sass/vars\" as *;\n@use \"../../../../../ts/lib/sass/elevation\" as *;\n@use \"../../../../../ts/lib/sass/button-mixins\" as button;\n\n.header {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    align-items: start;\n    align-content: space-between;\n    body:not(.fancy) & {\n        border-bottom: 1px solid var(--border-subtle);\n    }\n}\n\n.left-tray {\n    justify-self: start;\n}\n\n.right-tray {\n    justify-self: end;\n}\n\n.left-tray,\n.right-tray {\n    align-self: start;\n    display: flex;\n    flex-direction: row;\n    align-items: start;\n}\n\n.toolbar {\n    justify-self: center;\n    white-space: nowrap;\n\n    .fancy & {\n        transition: all var(--transition) ease-in-out;\n    }\n}\n\n.hitem {\n    font-weight: bold;\n    padding: 5px 12px;\n    color: color(fg);\n    display: inline-block;\n    &:hover {\n        text-decoration: underline;\n    }\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    -webkit-user-select: none;\n    overflow: hidden;\n\n    &:not(.fancy).hidden {\n        opacity: 0;\n    }\n\n    &.fancy {\n        margin-bottom: 5px;\n\n        &.hidden {\n            transform: translateY(-100vh);\n        }\n        transition: transform var(--transition) ease-in-out;\n\n        .toolbar {\n            overflow: hidden;\n            border-bottom-left-radius: prop(border-radius-medium);\n            border-bottom-right-radius: prop(border-radius-medium);\n            @include elevation(1, $opacity-boost: -0.1);\n\n            // glass effect\n            background: var(--canvas-glass);\n            backdrop-filter: blur(var(--blur));\n        }\n\n        // elevated state (deck browser, overview)\n        &:not(.flat) .toolbar {\n            background: var(--canvas-elevated);\n            @include elevation(1);\n            &:hover {\n                @include elevation(2);\n            }\n        }\n\n        &:not(.flat) .hitem {\n            @include button.base($border: false, $with-hover: false);\n            background: var(--canvas-glass);\n            border: 1px solid transparent;\n        }\n        .hitem {\n            text-decoration: none;\n            border: 1px solid transparent;\n\n            &:hover {\n                border: 1px solid var(--border-subtle);\n            }\n            &:active {\n                background: var(--canvas-inset);\n            }\n            &:first-child {\n                padding-left: 18px;\n            }\n            &:last-child {\n                padding-right: 18px;\n            }\n        }\n    }\n}\n\n* {\n    -webkit-user-drag: none;\n}\n\n.hitem:focus {\n    outline: 0;\n}\n\n@keyframes spin {\n    0% {\n        -webkit-transform: rotate(0deg);\n    }\n    100% {\n        -webkit-transform: rotate(360deg);\n    }\n}\n\n.spin {\n    width: 16px !important;\n    animation: spin;\n    animation-duration: 2s;\n    animation-iteration-count: infinite;\n    display: inline-block;\n    visibility: visible !important;\n    animation-timing-function: linear;\n    transition: all var(--transition) ease-in;\n}\n\n#sync-spinner {\n    height: 16px;\n    margin-bottom: -3px;\n    visibility: hidden;\n    width: 0;\n}\n\n.normal-sync {\n    color: color(state-new) !important;\n}\n\n.full-sync {\n    color: color(state-learn) !important;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/css/webview.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"../../../../../ts/lib/sass/root-vars\";\n@use \"../../../../../ts/lib/sass/scrollbar\";\n@use \"../../../../../ts/lib/sass/buttons\";\n\n* {\n    // border-box would be better, but we need to\n    // keep the old behaviour for now to avoid breaking\n    // add-ons/card templates\n    box-sizing: content-box;\n}\n\nbody {\n    color: var(--fg);\n    background: var(--canvas);\n    &.fancy {\n        transition: opacity var(--transition-medium) ease-out;\n    }\n    margin: 2em;\n    overscroll-behavior: none;\n    &:not(.isMac),\n    &:not(.isMac) * {\n        @include scrollbar.custom;\n    }\n    &.no-blur * {\n        backdrop-filter: none !important;\n    }\n}\n\na {\n    color: var(--fg-link);\n    text-decoration: none;\n}\n\nh1 {\n    margin-bottom: 0.2em;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/js/deckbrowser.ts",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n$(init);\n\nfunction init() {\n    $(\"tr.deck\").draggable({\n        scroll: false,\n\n        // can't use \"helper: 'clone'\" because of a bug in jQuery 1.5\n        helper: function(_event) {\n            return $(this).clone(false);\n        },\n        delay: 200,\n        opacity: 0.7,\n    });\n    $(\"tr.deck\").droppable({\n        drop: handleDropEvent,\n        hoverClass: \"drag-hover\",\n    });\n    $(\"tr.top-level-drag-row\").droppable({\n        drop: handleDropEvent,\n        hoverClass: \"drag-hover\",\n    });\n}\n\nfunction handleDropEvent(event, ui) {\n    const draggedDeckId = ui.draggable.attr(\"id\");\n    const ontoDeckId = $(this).attr(\"id\") || \"\";\n\n    pycmd(\"drag:\" + draggedDeckId + \",\" + ontoDeckId);\n}\n"
  },
  {
    "path": "qt/aqt/data/web/js/pycmd.d.ts",
    "content": "declare function pycmd(cmd: string, result_callback?: (arg: unknown) => void): unknown;\n"
  },
  {
    "path": "qt/aqt/data/web/js/reviewer-bottom.ts",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n/* eslint\n@typescript-eslint/no-unused-vars: \"off\",\n*/\n\nlet time: number; // set in python code\nlet timerStopped = false;\n\nlet maxTime = 0;\n\nfunction updateTime(): void {\n    const timeNode = document.getElementById(\"time\");\n    if (maxTime === 0) {\n        timeNode.textContent = \"\";\n        return;\n    }\n    time = Math.min(maxTime, time);\n    const m = Math.floor(time / 60);\n    const s = time % 60;\n    const sStr = String(s).padStart(2, \"0\");\n    const timeString = `${m}:${sStr}`;\n\n    if (maxTime === time) {\n        timeNode.innerHTML = `<font color=red>${timeString}</font>`;\n    } else {\n        timeNode.textContent = timeString;\n    }\n}\n\nlet intervalId: number | undefined;\n\nfunction showQuestion(txt: string, maxTime_: number): void {\n    showAnswer(txt);\n    time = 0;\n    maxTime = maxTime_;\n    updateTime();\n\n    if (intervalId !== undefined) {\n        clearInterval(intervalId);\n    }\n\n    intervalId = setInterval(function() {\n        if (!timerStopped) {\n            time += 1;\n            updateTime();\n        }\n    }, 1000);\n}\n\nfunction showAnswer(txt: string, stopTimer = false): void {\n    document.getElementById(\"middle\").innerHTML = txt;\n    timerStopped = stopTimer;\n}\n\nfunction selectedAnswerButton(): string {\n    const node = document.activeElement as HTMLElement;\n    if (!node) {\n        return;\n    }\n    return node.dataset.ease;\n}\n"
  },
  {
    "path": "qt/aqt/data/web/js/toolbar.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-unused-vars: \"off\",\n*/\n\nenum SyncState {\n    NoChanges = 0,\n    Normal,\n    Full,\n}\n\nfunction updateSyncColor(state: SyncState) {\n    const elem = document.getElementById(\"sync\");\n    switch (state) {\n        case SyncState.NoChanges:\n            elem.classList.remove(\"full-sync\", \"normal-sync\");\n            break;\n        case SyncState.Normal:\n            elem.classList.add(\"normal-sync\");\n            elem.classList.remove(\"full-sync\");\n            break;\n        case SyncState.Full:\n            elem.classList.add(\"full-sync\");\n            elem.classList.remove(\"normal-sync\");\n            break;\n    }\n}\n\n// Dealing with legacy add-ons that used CSS to absolutely position\n// themselves at toolbar edges\n\nfunction isAbsolutelyPositioned(node: Node): boolean {\n    if (!(node instanceof HTMLElement)) {\n        return false;\n    }\n    return getComputedStyle(node).position === \"absolute\";\n}\n\nfunction isLegacyAddonElement(node: Node): boolean {\n    if (isAbsolutelyPositioned(node)) {\n        return true;\n    }\n    for (const child of node.childNodes) {\n        if (isAbsolutelyPositioned(child)) {\n            return true;\n        }\n    }\n    return false;\n}\n\nfunction getElementDimensions(element: HTMLElement): [number, number] {\n    const widths = [element.offsetWidth];\n    const heights = [element.offsetHeight];\n    // Some add-ons inject spans or anchors into the toolbar whose dimensions,\n    // as reported by the properties above are zero, but still occupy space due\n    // to their child elements:\n    for (const child of element.childNodes) {\n        if (!(child instanceof HTMLElement)) {\n            continue;\n        }\n        widths.push(child.offsetWidth);\n        heights.push(child.offsetHeight);\n    }\n    return [Math.max(...widths), Math.max(...heights)];\n}\n\nfunction moveLegacyAddonsToTray() {\n    const rightTray = document.getElementsByClassName(\"right-tray\")[0];\n    const toolbarChildren = document.querySelectorAll<HTMLElement>(\".toolbar > *\");\n    const legacyAddonElements: HTMLElement[] = Array.from(toolbarChildren)\n        .reverse() // restore original add-on load order\n        .filter(isLegacyAddonElement);\n\n    for (const element of legacyAddonElements) {\n        const wrapperElement = document.createElement(\"div\");\n        const dimensions = getElementDimensions(element);\n        element.style.right = \"0px\"; // remove manual padding\n        wrapperElement.append(element);\n        wrapperElement.style.cssText = `\\\nwidth: ${dimensions[0]}px; height: ${dimensions[1]}}px;\nmargin-left: 5px; margin-right: 5px; position: relative;`;\n        wrapperElement.className = \"tray-item tray-item-legacy\";\n        rightTray.append(wrapperElement);\n    }\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", moveLegacyAddonsToTray);\n"
  },
  {
    "path": "qt/aqt/data/web/js/tsconfig.json",
    "content": "{\n    \"extends\": \"../../../../../ts/tsconfig_legacy.json\",\n    \"include\": [\"*.ts\"],\n    \"references\": [],\n    \"compilerOptions\": {\n        \"target\": \"es6\",\n        \"module\": \"commonjs\",\n        \"lib\": [\"es2019\", \"dom\", \"dom.iterable\"],\n        \"types\": [\"jquery\", \"jqueryui\"],\n        \"strict\": true,\n        \"isolatedModules\": false,\n        \"noImplicitAny\": false,\n        \"strictNullChecks\": false,\n        \"strictPropertyInitialization\": false,\n        \"noImplicitThis\": false,\n        \"esModuleInterop\": true\n    }\n}\n"
  },
  {
    "path": "qt/aqt/data/web/js/vendor/plot.js",
    "content": "/* Javascript plotting library for jQuery, version 0.8.3.\n\nCopyright (c) 2007-2014 IOLA and Ole Laursen.\nLicensed under the MIT license.\n\n*/\n(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i<c.length;++i)o[c.charAt(i)]+=d;return o.normalize()};o.scale=function(c,f){for(var i=0;i<c.length;++i)o[c.charAt(i)]*=f;return o.normalize()};o.toString=function(){if(o.a>=1){return\"rgb(\"+[o.r,o.g,o.b].join(\",\")+\")\"}else{return\"rgba(\"+[o.r,o.g,o.b,o.a].join(\",\")+\")\"}};o.normalize=function(){function clamp(min,value,max){return value<min?min:value>max?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=\"\"&&c!=\"transparent\")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),\"body\"));if(c==\"rgba(0, 0, 0, 0)\")c=\"transparent\";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\\(\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*\\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\\(\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*,\\s*([0-9]{1,3})\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\s*\\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\\(\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*\\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\\(\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\%\\s*,\\s*([0-9]+(?:\\.[0-9]+)?)\\s*\\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name==\"transparent\")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function($){var hasOwnProperty=Object.prototype.hasOwnProperty;if(!$.fn.detach){$.fn.detach=function(){return this.each(function(){if(this.parentNode){this.parentNode.removeChild(this)}})}}function Canvas(cls,container){var element=container.children(\".\"+cls)[0];if(element==null){element=document.createElement(\"canvas\");element.className=cls;$(element).css({direction:\"ltr\",position:\"absolute\",left:0,top:0}).appendTo(container);if(!element.getContext){if(window.G_vmlCanvasManager){element=window.G_vmlCanvasManager.initElement(element)}else{throw new Error(\"Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.\")}}}this.element=element;var context=this.context=element.getContext(\"2d\");var devicePixelRatio=window.devicePixelRatio||1,backingStoreRatio=context.webkitBackingStorePixelRatio||context.mozBackingStorePixelRatio||context.msBackingStorePixelRatio||context.oBackingStorePixelRatio||context.backingStorePixelRatio||1;this.pixelRatio=devicePixelRatio/backingStoreRatio;this.resize(container.width(),container.height());this.textContainer=null;this.text={};this._textCache={}}Canvas.prototype.resize=function(width,height){if(width<=0||height<=0){throw new Error(\"Invalid dimensions for plot, width = \"+width+\", height = \"+height)}var element=this.element,context=this.context,pixelRatio=this.pixelRatio;if(this.width!=width){element.width=width*pixelRatio;element.style.width=width+\"px\";this.width=width}if(this.height!=height){element.height=height*pixelRatio;element.style.height=height+\"px\";this.height=height}context.restore();context.save();context.scale(pixelRatio,pixelRatio)};Canvas.prototype.clear=function(){this.context.clearRect(0,0,this.width,this.height)};Canvas.prototype.render=function(){var cache=this._textCache;for(var layerKey in cache){if(hasOwnProperty.call(cache,layerKey)){var layer=this.getTextLayer(layerKey),layerCache=cache[layerKey];layer.hide();for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){if(position.active){if(!position.rendered){layer.append(position.element);position.rendered=true}}else{positions.splice(i--,1);if(position.rendered){position.element.detach()}}}if(positions.length==0){delete styleCache[key]}}}}}layer.show()}}};Canvas.prototype.getTextLayer=function(classes){var layer=this.text[classes];if(layer==null){if(this.textContainer==null){this.textContainer=$(\"<div class='flot-text'></div>\").css({position:\"absolute\",top:0,left:0,bottom:0,right:0,\"font-size\":\"smaller\",color:\"#545454\"}).insertAfter(this.element)}layer=this.text[classes]=$(\"<div></div>\").addClass(classes).css({position:\"absolute\",top:0,left:0,bottom:0,right:0}).appendTo(this.textContainer)}return layer};Canvas.prototype.getTextInfo=function(layer,text,font,angle,width){var textStyle,layerCache,styleCache,info;text=\"\"+text;if(typeof font===\"object\"){textStyle=font.style+\" \"+font.variant+\" \"+font.weight+\" \"+font.size+\"px/\"+font.lineHeight+\"px \"+font.family}else{textStyle=font}layerCache=this._textCache[layer];if(layerCache==null){layerCache=this._textCache[layer]={}}styleCache=layerCache[textStyle];if(styleCache==null){styleCache=layerCache[textStyle]={}}info=styleCache[text];if(info==null){var element=$(\"<div></div>\").html(text).css({position:\"absolute\",\"max-width\":width,top:-9999}).appendTo(this.getTextLayer(layer));if(typeof font===\"object\"){element.css({font:textStyle,color:font.color})}else if(typeof font===\"string\"){element.addClass(font)}info=styleCache[text]={width:element.outerWidth(true),height:element.outerHeight(true),element:element,positions:[]};element.detach()}return info};Canvas.prototype.addText=function(layer,x,y,text,font,angle,width,halign,valign){var info=this.getTextInfo(layer,text,font,angle,width),positions=info.positions;if(halign==\"center\"){x-=info.width/2}else if(halign==\"right\"){x-=info.width}if(valign==\"middle\"){y-=info.height/2}else if(valign==\"bottom\"){y-=info.height}for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=true;return}}position={active:true,rendered:false,element:positions.length?info.element.clone():info.element,x:x,y:y};positions.push(position);position.element.css({top:Math.round(y),left:Math.round(x),\"text-align\":halign})};Canvas.prototype.removeText=function(layer,x,y,text,font,angle){if(text==null){var layerCache=this._textCache[layer];if(layerCache!=null){for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){position.active=false}}}}}}}else{var positions=this.getTextInfo(layer,text,font,angle).positions;for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=false}}}};function Plot(placeholder,data_,options_,plugins){var series=[],options={colors:[\"#edc240\",\"#afd8f8\",\"#cb4b4b\",\"#4da74d\",\"#9440ed\"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:\"#ccc\",container:null,position:\"ne\",margin:5,backgroundColor:null,backgroundOpacity:.85,sorted:null},xaxis:{show:null,position:\"bottom\",mode:null,font:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null},yaxis:{autoscaleMargin:.02,position:\"left\"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:\"#ffffff\",symbol:\"circle\"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:\"left\",horizontal:false,zero:true},shadowSize:3,highlightColor:null},grid:{show:true,aboveData:false,color:\"#545454\",backgroundColor:null,borderColor:null,tickColor:null,margin:0,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:\"#f4f4f4\",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},interaction:{redrawOverlayInterval:1e3/60},hooks:{}},surface=null,overlay=null,eventHolder=null,ctx=null,octx=null,xaxes=[],yaxes=[],plotOffset={left:0,right:0,top:0,bottom:0},plotWidth=0,plotHeight=0,hooks={processOptions:[],processRawData:[],processDatapoints:[],processOffset:[],drawBackground:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},plot=this;plot.setData=setData;plot.setupGrid=setupGrid;plot.draw=draw;plot.getPlaceholder=function(){return placeholder};plot.getCanvas=function(){return surface.element};plot.getPlotOffset=function(){return plotOffset};plot.width=function(){return plotWidth};plot.height=function(){return plotHeight};plot.offset=function(){var o=eventHolder.offset();o.left+=plotOffset.left;o.top+=plotOffset.top;return o};plot.getData=function(){return series};plot.getAxes=function(){var res={},i;$.each(xaxes.concat(yaxes),function(_,axis){if(axis)res[axis.direction+(axis.n!=1?axis.n:\"\")+\"axis\"]=axis});return res};plot.getXAxes=function(){return xaxes};plot.getYAxes=function(){return yaxes};plot.c2p=canvasToAxisCoords;plot.p2c=axisToCanvasCoords;plot.getOptions=function(){return options};plot.highlight=highlight;plot.unhighlight=unhighlight;plot.triggerRedrawOverlay=triggerRedrawOverlay;plot.pointOffset=function(point){return{left:parseInt(xaxes[axisNumber(point,\"x\")-1].p2c(+point.x)+plotOffset.left,10),top:parseInt(yaxes[axisNumber(point,\"y\")-1].p2c(+point.y)+plotOffset.top,10)}};plot.shutdown=shutdown;plot.destroy=function(){shutdown();placeholder.removeData(\"plot\").empty();series=[];options=null;surface=null;overlay=null;eventHolder=null;ctx=null;octx=null;xaxes=[];yaxes=[];hooks=null;highlights=[];plot=null};plot.resize=function(){var width=placeholder.width(),height=placeholder.height();surface.resize(width,height);overlay.resize(width,height)};plot.hooks=hooks;initPlugins(plot);parseOptions(options_);setupCanvases();setData(data_);setupGrid();draw();bindEvents();function executeHooks(hook,args){args=[plot].concat(args);for(var i=0;i<hook.length;++i)hook[i].apply(this,args)}function initPlugins(){var classes={Canvas:Canvas};for(var i=0;i<plugins.length;++i){var p=plugins[i];p.init(plot,classes);if(p.options)$.extend(true,options,p.options)}}function parseOptions(opts){$.extend(true,options,opts);if(opts&&opts.colors){options.colors=opts.colors}if(options.xaxis.color==null)options.xaxis.color=$.color.parse(options.grid.color).scale(\"a\",.22).toString();if(options.yaxis.color==null)options.yaxis.color=$.color.parse(options.grid.color).scale(\"a\",.22).toString();if(options.xaxis.tickColor==null)options.xaxis.tickColor=options.grid.tickColor||options.xaxis.color;if(options.yaxis.tickColor==null)options.yaxis.tickColor=options.grid.tickColor||options.yaxis.color;if(options.grid.borderColor==null)options.grid.borderColor=options.grid.color;if(options.grid.tickColor==null)options.grid.tickColor=$.color.parse(options.grid.color).scale(\"a\",.22).toString();var i,axisOptions,axisCount,fontSize=placeholder.css(\"font-size\"),fontSizeDefault=fontSize?+fontSize.replace(\"px\",\"\"):13,fontDefaults={style:placeholder.css(\"font-style\"),size:Math.round(.8*fontSizeDefault),variant:placeholder.css(\"font-variant\"),weight:placeholder.css(\"font-weight\"),family:placeholder.css(\"font-family\")};axisCount=options.xaxes.length||1;for(i=0;i<axisCount;++i){axisOptions=options.xaxes[i];if(axisOptions&&!axisOptions.tickColor){axisOptions.tickColor=axisOptions.color}axisOptions=$.extend(true,{},options.xaxis,axisOptions);options.xaxes[i]=axisOptions;if(axisOptions.font){axisOptions.font=$.extend({},fontDefaults,axisOptions.font);if(!axisOptions.font.color){axisOptions.font.color=axisOptions.color}if(!axisOptions.font.lineHeight){axisOptions.font.lineHeight=Math.round(axisOptions.font.size*1.15)}}}axisCount=options.yaxes.length||1;for(i=0;i<axisCount;++i){axisOptions=options.yaxes[i];if(axisOptions&&!axisOptions.tickColor){axisOptions.tickColor=axisOptions.color}axisOptions=$.extend(true,{},options.yaxis,axisOptions);options.yaxes[i]=axisOptions;if(axisOptions.font){axisOptions.font=$.extend({},fontDefaults,axisOptions.font);if(!axisOptions.font.color){axisOptions.font.color=axisOptions.color}if(!axisOptions.font.lineHeight){axisOptions.font.lineHeight=Math.round(axisOptions.font.size*1.15)}}}if(options.xaxis.noTicks&&options.xaxis.ticks==null)options.xaxis.ticks=options.xaxis.noTicks;if(options.yaxis.noTicks&&options.yaxis.ticks==null)options.yaxis.ticks=options.yaxis.noTicks;if(options.x2axis){options.xaxes[1]=$.extend(true,{},options.xaxis,options.x2axis);options.xaxes[1].position=\"top\";if(options.x2axis.min==null){options.xaxes[1].min=null}if(options.x2axis.max==null){options.xaxes[1].max=null}}if(options.y2axis){options.yaxes[1]=$.extend(true,{},options.yaxis,options.y2axis);options.yaxes[1].position=\"right\";if(options.y2axis.min==null){options.yaxes[1].min=null}if(options.y2axis.max==null){options.yaxes[1].max=null}}if(options.grid.coloredAreas)options.grid.markings=options.grid.coloredAreas;if(options.grid.coloredAreasColor)options.grid.markingsColor=options.grid.coloredAreasColor;if(options.lines)$.extend(true,options.series.lines,options.lines);if(options.points)$.extend(true,options.series.points,options.points);if(options.bars)$.extend(true,options.series.bars,options.bars);if(options.shadowSize!=null)options.series.shadowSize=options.shadowSize;if(options.highlightColor!=null)options.series.highlightColor=options.highlightColor;for(i=0;i<options.xaxes.length;++i)getOrCreateAxis(xaxes,i+1).options=options.xaxes[i];for(i=0;i<options.yaxes.length;++i)getOrCreateAxis(yaxes,i+1).options=options.yaxes[i];for(var n in hooks)if(options.hooks[n]&&options.hooks[n].length)hooks[n]=hooks[n].concat(options.hooks[n]);executeHooks(hooks.processOptions,[options])}function setData(d){series=parseData(d);fillInSeriesOptions();processData()}function parseData(d){var res=[];for(var i=0;i<d.length;++i){var s=$.extend(true,{},options.series);if(d[i].data!=null){s.data=d[i].data;delete d[i].data;$.extend(true,s,d[i]);d[i].data=s.data}else s.data=d[i];res.push(s)}return res}function axisNumber(obj,coord){var a=obj[coord+\"axis\"];if(typeof a==\"object\")a=a.n;if(typeof a!=\"number\")a=1;return a}function allAxes(){return $.grep(xaxes.concat(yaxes),function(a){return a})}function canvasToAxisCoords(pos){var res={},i,axis;for(i=0;i<xaxes.length;++i){axis=xaxes[i];if(axis&&axis.used)res[\"x\"+axis.n]=axis.c2p(pos.left)}for(i=0;i<yaxes.length;++i){axis=yaxes[i];if(axis&&axis.used)res[\"y\"+axis.n]=axis.c2p(pos.top)}if(res.x1!==undefined)res.x=res.x1;if(res.y1!==undefined)res.y=res.y1;return res}function axisToCanvasCoords(pos){var res={},i,axis,key;for(i=0;i<xaxes.length;++i){axis=xaxes[i];if(axis&&axis.used){key=\"x\"+axis.n;if(pos[key]==null&&axis.n==1)key=\"x\";if(pos[key]!=null){res.left=axis.p2c(pos[key]);break}}}for(i=0;i<yaxes.length;++i){axis=yaxes[i];if(axis&&axis.used){key=\"y\"+axis.n;if(pos[key]==null&&axis.n==1)key=\"y\";if(pos[key]!=null){res.top=axis.p2c(pos[key]);break}}}return res}function getOrCreateAxis(axes,number){if(!axes[number-1])axes[number-1]={n:number,direction:axes==xaxes?\"x\":\"y\",options:$.extend(true,{},axes==xaxes?options.xaxis:options.yaxis)};return axes[number-1]}function fillInSeriesOptions(){var neededColors=series.length,maxIndex=-1,i;for(i=0;i<series.length;++i){var sc=series[i].color;if(sc!=null){neededColors--;if(typeof sc==\"number\"&&sc>maxIndex){maxIndex=sc}}}if(neededColors<=maxIndex){neededColors=maxIndex+1}var c,colors=[],colorPool=options.colors,colorPoolSize=colorPool.length,variation=0;for(i=0;i<neededColors;i++){c=$.color.parse(colorPool[i%colorPoolSize]||\"#666\");if(i%colorPoolSize==0&&i){if(variation>=0){if(variation<.5){variation=-variation-.2}else variation=0}else variation=-variation}colors[i]=c.scale(\"rgb\",1+variation)}var colori=0,s;for(i=0;i<series.length;++i){s=series[i];if(s.color==null){s.color=colors[colori].toString();++colori}else if(typeof s.color==\"number\")s.color=colors[s.color].toString();if(s.lines.show==null){var v,show=true;for(v in s)if(s[v]&&s[v].show){show=false;break}if(show)s.lines.show=true}if(s.lines.zero==null){s.lines.zero=!!s.lines.fill}s.xaxis=getOrCreateAxis(xaxes,axisNumber(s,\"x\"));s.yaxis=getOrCreateAxis(yaxes,axisNumber(s,\"y\"))}}function processData(){var topSentry=Number.POSITIVE_INFINITY,bottomSentry=Number.NEGATIVE_INFINITY,fakeInfinity=Number.MAX_VALUE,i,j,k,m,length,s,points,ps,x,y,axis,val,f,p,data,format;function updateAxis(axis,min,max){if(min<axis.datamin&&min!=-fakeInfinity)axis.datamin=min;if(max>axis.datamax&&max!=fakeInfinity)axis.datamax=max}$.each(allAxes(),function(_,axis){axis.datamin=topSentry;axis.datamax=bottomSentry;axis.used=false});for(i=0;i<series.length;++i){s=series[i];s.datapoints={points:[]};executeHooks(hooks.processRawData,[s,s.data,s.datapoints])}for(i=0;i<series.length;++i){s=series[i];data=s.data;format=s.datapoints.format;if(!format){format=[];format.push({x:true,number:true,required:true});format.push({y:true,number:true,required:true});if(s.bars.show||s.lines.show&&s.lines.fill){var autoscale=!!(s.bars.show&&s.bars.zero||s.lines.show&&s.lines.zero);format.push({y:true,number:true,required:false,defaultValue:0,autoscale:autoscale});if(s.bars.horizontal){delete format[format.length-1].y;format[format.length-1].x=true}}s.datapoints.format=format}if(s.datapoints.pointsize!=null)continue;s.datapoints.pointsize=format.length;ps=s.datapoints.pointsize;points=s.datapoints.points;var insertSteps=s.lines.show&&s.lines.steps;s.xaxis.used=s.yaxis.used=true;for(j=k=0;j<data.length;++j,k+=ps){p=data[j];var nullify=p==null;if(!nullify){for(m=0;m<ps;++m){val=p[m];f=format[m];if(f){if(f.number&&val!=null){val=+val;if(isNaN(val))val=null;else if(val==Infinity)val=fakeInfinity;else if(val==-Infinity)val=-fakeInfinity}if(val==null){if(f.required)nullify=true;if(f.defaultValue!=null)val=f.defaultValue}}points[k+m]=val}}if(nullify){for(m=0;m<ps;++m){val=points[k+m];if(val!=null){f=format[m];if(f.autoscale!==false){if(f.x){updateAxis(s.xaxis,val,val)}if(f.y){updateAxis(s.yaxis,val,val)}}}points[k+m]=null}}else{if(insertSteps&&k>0&&points[k-ps]!=null&&points[k-ps]!=points[k]&&points[k-ps+1]!=points[k+1]){for(m=0;m<ps;++m)points[k+ps+m]=points[k+m];points[k+1]=points[k-ps+1];k+=ps}}}}for(i=0;i<series.length;++i){s=series[i];executeHooks(hooks.processDatapoints,[s,s.datapoints])}for(i=0;i<series.length;++i){s=series[i];points=s.datapoints.points;ps=s.datapoints.pointsize;format=s.datapoints.format;var xmin=topSentry,ymin=topSentry,xmax=bottomSentry,ymax=bottomSentry;for(j=0;j<points.length;j+=ps){if(points[j]==null)continue;for(m=0;m<ps;++m){val=points[j+m];f=format[m];if(!f||f.autoscale===false||val==fakeInfinity||val==-fakeInfinity)continue;if(f.x){if(val<xmin)xmin=val;if(val>xmax)xmax=val}if(f.y){if(val<ymin)ymin=val;if(val>ymax)ymax=val}}}if(s.bars.show){var delta;switch(s.bars.align){case\"left\":delta=0;break;case\"right\":delta=-s.bars.barWidth;break;default:delta=-s.bars.barWidth/2}if(s.bars.horizontal){ymin+=delta;ymax+=delta+s.bars.barWidth}else{xmin+=delta;xmax+=delta+s.bars.barWidth}}updateAxis(s.xaxis,xmin,xmax);updateAxis(s.yaxis,ymin,ymax)}$.each(allAxes(),function(_,axis){if(axis.datamin==topSentry)axis.datamin=null;if(axis.datamax==bottomSentry)axis.datamax=null})}function setupCanvases(){placeholder.css(\"padding\",0).children().filter(function(){return!$(this).hasClass(\"flot-overlay\")&&!$(this).hasClass(\"flot-base\")}).remove();if(placeholder.css(\"position\")==\"static\")placeholder.css(\"position\",\"relative\");surface=new Canvas(\"flot-base\",placeholder);overlay=new Canvas(\"flot-overlay\",placeholder);ctx=surface.context;octx=overlay.context;eventHolder=$(overlay.element).unbind();var existing=placeholder.data(\"plot\");if(existing){existing.shutdown();overlay.clear()}placeholder.data(\"plot\",plot)}function bindEvents(){if(options.grid.hoverable){eventHolder.mousemove(onMouseMove);eventHolder.bind(\"mouseleave\",onMouseLeave)}if(options.grid.clickable)eventHolder.click(onClick);executeHooks(hooks.bindEvents,[eventHolder])}function shutdown(){if(redrawTimeout)clearTimeout(redrawTimeout);eventHolder.unbind(\"mousemove\",onMouseMove);eventHolder.unbind(\"mouseleave\",onMouseLeave);eventHolder.unbind(\"click\",onClick);executeHooks(hooks.shutdown,[eventHolder])}function setTransformationHelpers(axis){function identity(x){return x}var s,m,t=axis.options.transform||identity,it=axis.options.inverseTransform;if(axis.direction==\"x\"){s=axis.scale=plotWidth/Math.abs(t(axis.max)-t(axis.min));m=Math.min(t(axis.max),t(axis.min))}else{s=axis.scale=plotHeight/Math.abs(t(axis.max)-t(axis.min));s=-s;m=Math.max(t(axis.max),t(axis.min))}if(t==identity)axis.p2c=function(p){return(p-m)*s};else axis.p2c=function(p){return(t(p)-m)*s};if(!it)axis.c2p=function(c){return m+c/s};else axis.c2p=function(c){return it(m+c/s)}}function measureTickLabels(axis){var opts=axis.options,ticks=axis.ticks||[],labelWidth=opts.labelWidth||0,labelHeight=opts.labelHeight||0,maxWidth=labelWidth||(axis.direction==\"x\"?Math.floor(surface.width/(ticks.length||1)):null),legacyStyles=axis.direction+\"Axis \"+axis.direction+axis.n+\"Axis\",layer=\"flot-\"+axis.direction+\"-axis flot-\"+axis.direction+axis.n+\"-axis \"+legacyStyles,font=opts.font||\"flot-tick-label tickLabel\";for(var i=0;i<ticks.length;++i){var t=ticks[i];if(!t.label)continue;var info=surface.getTextInfo(layer,t.label,font,null,maxWidth);labelWidth=Math.max(labelWidth,info.width);labelHeight=Math.max(labelHeight,info.height)}axis.labelWidth=opts.labelWidth||labelWidth;axis.labelHeight=opts.labelHeight||labelHeight}function allocateAxisBoxFirstPhase(axis){var lw=axis.labelWidth,lh=axis.labelHeight,pos=axis.options.position,isXAxis=axis.direction===\"x\",tickLength=axis.options.tickLength,axisMargin=options.grid.axisMargin,padding=options.grid.labelMargin,innermost=true,outermost=true,first=true,found=false;$.each(isXAxis?xaxes:yaxes,function(i,a){if(a&&(a.show||a.reserveSpace)){if(a===axis){found=true}else if(a.options.position===pos){if(found){outermost=false}else{innermost=false}}if(!found){first=false}}});if(outermost){axisMargin=0}if(tickLength==null){tickLength=first?\"full\":5}if(!isNaN(+tickLength))padding+=+tickLength;if(isXAxis){lh+=padding;if(pos==\"bottom\"){plotOffset.bottom+=lh+axisMargin;axis.box={top:surface.height-plotOffset.bottom,height:lh}}else{axis.box={top:plotOffset.top+axisMargin,height:lh};plotOffset.top+=lh+axisMargin}}else{lw+=padding;if(pos==\"left\"){axis.box={left:plotOffset.left+axisMargin,width:lw};plotOffset.left+=lw+axisMargin}else{plotOffset.right+=lw+axisMargin;axis.box={left:surface.width-plotOffset.right,width:lw}}}axis.position=pos;axis.tickLength=tickLength;axis.box.padding=padding;axis.innermost=innermost}function allocateAxisBoxSecondPhase(axis){if(axis.direction==\"x\"){axis.box.left=plotOffset.left-axis.labelWidth/2;axis.box.width=surface.width-plotOffset.left-plotOffset.right+axis.labelWidth}else{axis.box.top=plotOffset.top-axis.labelHeight/2;axis.box.height=surface.height-plotOffset.bottom-plotOffset.top+axis.labelHeight}}function adjustLayoutForThingsStickingOut(){var minMargin=options.grid.minBorderMargin,axis,i;if(minMargin==null){minMargin=0;for(i=0;i<series.length;++i)minMargin=Math.max(minMargin,2*(series[i].points.radius+series[i].points.lineWidth/2))}var margins={left:minMargin,right:minMargin,top:minMargin,bottom:minMargin};$.each(allAxes(),function(_,axis){if(axis.reserveSpace&&axis.ticks&&axis.ticks.length){if(axis.direction===\"x\"){margins.left=Math.max(margins.left,axis.labelWidth/2);margins.right=Math.max(margins.right,axis.labelWidth/2)}else{margins.bottom=Math.max(margins.bottom,axis.labelHeight/2);margins.top=Math.max(margins.top,axis.labelHeight/2)}}});plotOffset.left=Math.ceil(Math.max(margins.left,plotOffset.left));plotOffset.right=Math.ceil(Math.max(margins.right,plotOffset.right));plotOffset.top=Math.ceil(Math.max(margins.top,plotOffset.top));plotOffset.bottom=Math.ceil(Math.max(margins.bottom,plotOffset.bottom))}function setupGrid(){var i,axes=allAxes(),showGrid=options.grid.show;for(var a in plotOffset){var margin=options.grid.margin||0;plotOffset[a]=typeof margin==\"number\"?margin:margin[a]||0}executeHooks(hooks.processOffset,[plotOffset]);for(var a in plotOffset){if(typeof options.grid.borderWidth==\"object\"){plotOffset[a]+=showGrid?options.grid.borderWidth[a]:0}else{plotOffset[a]+=showGrid?options.grid.borderWidth:0}}$.each(axes,function(_,axis){var axisOpts=axis.options;axis.show=axisOpts.show==null?axis.used:axisOpts.show;axis.reserveSpace=axisOpts.reserveSpace==null?axis.show:axisOpts.reserveSpace;setRange(axis)});if(showGrid){var allocatedAxes=$.grep(axes,function(axis){return axis.show||axis.reserveSpace});$.each(allocatedAxes,function(_,axis){setupTickGeneration(axis);setTicks(axis);snapRangeToTicks(axis,axis.ticks);measureTickLabels(axis)});for(i=allocatedAxes.length-1;i>=0;--i)allocateAxisBoxFirstPhase(allocatedAxes[i]);adjustLayoutForThingsStickingOut();$.each(allocatedAxes,function(_,axis){allocateAxisBoxSecondPhase(axis)})}plotWidth=surface.width-plotOffset.left-plotOffset.right;plotHeight=surface.height-plotOffset.bottom-plotOffset.top;$.each(axes,function(_,axis){setTransformationHelpers(axis)});if(showGrid){drawAxisLabels()}insertLegend()}function setRange(axis){var opts=axis.options,min=+(opts.min!=null?opts.min:axis.datamin),max=+(opts.max!=null?opts.max:axis.datamax),delta=max-min;if(delta==0){var widen=max==0?1:.01;if(opts.min==null)min-=widen;if(opts.max==null||opts.min!=null)max+=widen}else{var margin=opts.autoscaleMargin;if(margin!=null){if(opts.min==null){min-=delta*margin;if(min<0&&axis.datamin!=null&&axis.datamin>=0)min=0}if(opts.max==null){max+=delta*margin;if(max>0&&axis.datamax!=null&&axis.datamax<=0)max=0}}}axis.min=min;axis.max=max}function setupTickGeneration(axis){var opts=axis.options;var noTicks;if(typeof opts.ticks==\"number\"&&opts.ticks>0)noTicks=opts.ticks;else noTicks=.3*Math.sqrt(axis.direction==\"x\"?surface.width:surface.height);var delta=(axis.max-axis.min)/noTicks,dec=-Math.floor(Math.log(delta)/Math.LN10),maxDec=opts.tickDecimals;if(maxDec!=null&&dec>maxDec){dec=maxDec}var magn=Math.pow(10,-dec),norm=delta/magn,size;if(norm<1.5){size=1}else if(norm<3){size=2;if(norm>2.25&&(maxDec==null||dec+1<=maxDec)){size=2.5;++dec}}else if(norm<7.5){size=5}else{size=10}size*=magn;if(opts.minTickSize!=null&&size<opts.minTickSize){size=opts.minTickSize}axis.delta=delta;axis.tickDecimals=Math.max(0,maxDec!=null?maxDec:dec);axis.tickSize=opts.tickSize||size;if(opts.mode==\"time\"&&!axis.tickGenerator){throw new Error(\"Time mode requires the flot.time plugin.\")}if(!axis.tickGenerator){axis.tickGenerator=function(axis){var ticks=[],start=floorInBase(axis.min,axis.tickSize),i=0,v=Number.NaN,prev;do{prev=v;v=start+i*axis.tickSize;ticks.push(v);++i}while(v<axis.max&&v!=prev);return ticks};axis.tickFormatter=function(value,axis){var factor=axis.tickDecimals?Math.pow(10,axis.tickDecimals):1;var formatted=\"\"+Math.round(value*factor)/factor;if(axis.tickDecimals!=null){var decimal=formatted.indexOf(\".\");var precision=decimal==-1?0:formatted.length-decimal-1;if(precision<axis.tickDecimals){return(precision?formatted:formatted+\".\")+(\"\"+factor).substr(1,axis.tickDecimals-precision)}}return formatted}}if($.isFunction(opts.tickFormatter))axis.tickFormatter=function(v,axis){return\"\"+opts.tickFormatter(v,axis)};if(opts.alignTicksWithAxis!=null){var otherAxis=(axis.direction==\"x\"?xaxes:yaxes)[opts.alignTicksWithAxis-1];if(otherAxis&&otherAxis.used&&otherAxis!=axis){var niceTicks=axis.tickGenerator(axis);if(niceTicks.length>0){if(opts.min==null)axis.min=Math.min(axis.min,niceTicks[0]);if(opts.max==null&&niceTicks.length>1)axis.max=Math.max(axis.max,niceTicks[niceTicks.length-1])}axis.tickGenerator=function(axis){var ticks=[],v,i;for(i=0;i<otherAxis.ticks.length;++i){v=(otherAxis.ticks[i].v-otherAxis.min)/(otherAxis.max-otherAxis.min);v=axis.min+v*(axis.max-axis.min);ticks.push(v)}return ticks};if(!axis.mode&&opts.tickDecimals==null){var extraDec=Math.max(0,-Math.floor(Math.log(axis.delta)/Math.LN10)+1),ts=axis.tickGenerator(axis);if(!(ts.length>1&&/\\..*0$/.test((ts[1]-ts[0]).toFixed(extraDec))))axis.tickDecimals=extraDec}}}}function setTicks(axis){var oticks=axis.options.ticks,ticks=[];if(oticks==null||typeof oticks==\"number\"&&oticks>0)ticks=axis.tickGenerator(axis);else if(oticks){if($.isFunction(oticks))ticks=oticks(axis);else ticks=oticks}var i,v;axis.ticks=[];for(i=0;i<ticks.length;++i){var label=null;var t=ticks[i];if(typeof t==\"object\"){v=+t[0];if(t.length>1)label=t[1]}else v=+t;if(label==null)label=axis.tickFormatter(v,axis);if(!isNaN(v))axis.ticks.push({v:v,label:label})}}function snapRangeToTicks(axis,ticks){if(axis.options.autoscaleMargin&&ticks.length>0){if(axis.options.min==null)axis.min=Math.min(axis.min,ticks[0].v);if(axis.options.max==null&&ticks.length>1)axis.max=Math.max(axis.max,ticks[ticks.length-1].v)}}function draw(){surface.clear();executeHooks(hooks.drawBackground,[ctx]);var grid=options.grid;if(grid.show&&grid.backgroundColor)drawBackground();if(grid.show&&!grid.aboveData){drawGrid()}for(var i=0;i<series.length;++i){executeHooks(hooks.drawSeries,[ctx,series[i]]);drawSeries(series[i])}executeHooks(hooks.draw,[ctx]);if(grid.show&&grid.aboveData){drawGrid()}surface.render();triggerRedrawOverlay()}function extractRange(ranges,coord){var axis,from,to,key,axes=allAxes();for(var i=0;i<axes.length;++i){axis=axes[i];if(axis.direction==coord){key=coord+axis.n+\"axis\";if(!ranges[key]&&axis.n==1)key=coord+\"axis\";if(ranges[key]){from=ranges[key].from;to=ranges[key].to;break}}}if(!ranges[key]){axis=coord==\"x\"?xaxes[0]:yaxes[0];from=ranges[coord+\"1\"];to=ranges[coord+\"2\"]}if(from!=null&&to!=null&&from>to){var tmp=from;from=to;to=tmp}return{from:from,to:to,axis:axis}}function drawBackground(){ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.fillStyle=getColorOrGradient(options.grid.backgroundColor,plotHeight,0,\"rgba(255, 255, 255, 0)\");ctx.fillRect(0,0,plotWidth,plotHeight);ctx.restore()}function drawGrid(){var i,axes,bw,bc;ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var markings=options.grid.markings;if(markings){if($.isFunction(markings)){axes=plot.getAxes();axes.xmin=axes.xaxis.min;axes.xmax=axes.xaxis.max;axes.ymin=axes.yaxis.min;axes.ymax=axes.yaxis.max;markings=markings(axes)}for(i=0;i<markings.length;++i){var m=markings[i],xrange=extractRange(m,\"x\"),yrange=extractRange(m,\"y\");if(xrange.from==null)xrange.from=xrange.axis.min;if(xrange.to==null)xrange.to=xrange.axis.max;\nif(yrange.from==null)yrange.from=yrange.axis.min;if(yrange.to==null)yrange.to=yrange.axis.max;if(xrange.to<xrange.axis.min||xrange.from>xrange.axis.max||yrange.to<yrange.axis.min||yrange.from>yrange.axis.max)continue;xrange.from=Math.max(xrange.from,xrange.axis.min);xrange.to=Math.min(xrange.to,xrange.axis.max);yrange.from=Math.max(yrange.from,yrange.axis.min);yrange.to=Math.min(yrange.to,yrange.axis.max);var xequal=xrange.from===xrange.to,yequal=yrange.from===yrange.to;if(xequal&&yequal){continue}xrange.from=Math.floor(xrange.axis.p2c(xrange.from));xrange.to=Math.floor(xrange.axis.p2c(xrange.to));yrange.from=Math.floor(yrange.axis.p2c(yrange.from));yrange.to=Math.floor(yrange.axis.p2c(yrange.to));if(xequal||yequal){var lineWidth=m.lineWidth||options.grid.markingsLineWidth,subPixel=lineWidth%2?.5:0;ctx.beginPath();ctx.strokeStyle=m.color||options.grid.markingsColor;ctx.lineWidth=lineWidth;if(xequal){ctx.moveTo(xrange.to+subPixel,yrange.from);ctx.lineTo(xrange.to+subPixel,yrange.to)}else{ctx.moveTo(xrange.from,yrange.to+subPixel);ctx.lineTo(xrange.to,yrange.to+subPixel)}ctx.stroke()}else{ctx.fillStyle=m.color||options.grid.markingsColor;ctx.fillRect(xrange.from,yrange.to,xrange.to-xrange.from,yrange.from-yrange.to)}}}axes=allAxes();bw=options.grid.borderWidth;for(var j=0;j<axes.length;++j){var axis=axes[j],box=axis.box,t=axis.tickLength,x,y,xoff,yoff;if(!axis.show||axis.ticks.length==0)continue;ctx.lineWidth=1;if(axis.direction==\"x\"){x=0;if(t==\"full\")y=axis.position==\"top\"?0:plotHeight;else y=box.top-plotOffset.top+(axis.position==\"top\"?box.height:0)}else{y=0;if(t==\"full\")x=axis.position==\"left\"?0:plotWidth;else x=box.left-plotOffset.left+(axis.position==\"left\"?box.width:0)}if(!axis.innermost){ctx.strokeStyle=axis.options.color;ctx.beginPath();xoff=yoff=0;if(axis.direction==\"x\")xoff=plotWidth+1;else yoff=plotHeight+1;if(ctx.lineWidth==1){if(axis.direction==\"x\"){y=Math.floor(y)+.5}else{x=Math.floor(x)+.5}}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff);ctx.stroke()}ctx.strokeStyle=axis.options.tickColor;ctx.beginPath();for(i=0;i<axis.ticks.length;++i){var v=axis.ticks[i].v;xoff=yoff=0;if(isNaN(v)||v<axis.min||v>axis.max||t==\"full\"&&(typeof bw==\"object\"&&bw[axis.position]>0||bw>0)&&(v==axis.min||v==axis.max))continue;if(axis.direction==\"x\"){x=axis.p2c(v);yoff=t==\"full\"?-plotHeight:t;if(axis.position==\"top\")yoff=-yoff}else{y=axis.p2c(v);xoff=t==\"full\"?-plotWidth:t;if(axis.position==\"left\")xoff=-xoff}if(ctx.lineWidth==1){if(axis.direction==\"x\")x=Math.floor(x)+.5;else y=Math.floor(y)+.5}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff)}ctx.stroke()}if(bw){bc=options.grid.borderColor;if(typeof bw==\"object\"||typeof bc==\"object\"){if(typeof bw!==\"object\"){bw={top:bw,right:bw,bottom:bw,left:bw}}if(typeof bc!==\"object\"){bc={top:bc,right:bc,bottom:bc,left:bc}}if(bw.top>0){ctx.strokeStyle=bc.top;ctx.lineWidth=bw.top;ctx.beginPath();ctx.moveTo(0-bw.left,0-bw.top/2);ctx.lineTo(plotWidth,0-bw.top/2);ctx.stroke()}if(bw.right>0){ctx.strokeStyle=bc.right;ctx.lineWidth=bw.right;ctx.beginPath();ctx.moveTo(plotWidth+bw.right/2,0-bw.top);ctx.lineTo(plotWidth+bw.right/2,plotHeight);ctx.stroke()}if(bw.bottom>0){ctx.strokeStyle=bc.bottom;ctx.lineWidth=bw.bottom;ctx.beginPath();ctx.moveTo(plotWidth+bw.right,plotHeight+bw.bottom/2);ctx.lineTo(0,plotHeight+bw.bottom/2);ctx.stroke()}if(bw.left>0){ctx.strokeStyle=bc.left;ctx.lineWidth=bw.left;ctx.beginPath();ctx.moveTo(0-bw.left/2,plotHeight+bw.bottom);ctx.lineTo(0-bw.left/2,0);ctx.stroke()}}else{ctx.lineWidth=bw;ctx.strokeStyle=options.grid.borderColor;ctx.strokeRect(-bw/2,-bw/2,plotWidth+bw,plotHeight+bw)}}ctx.restore()}function drawAxisLabels(){$.each(allAxes(),function(_,axis){var box=axis.box,legacyStyles=axis.direction+\"Axis \"+axis.direction+axis.n+\"Axis\",layer=\"flot-\"+axis.direction+\"-axis flot-\"+axis.direction+axis.n+\"-axis \"+legacyStyles,font=axis.options.font||\"flot-tick-label tickLabel\",tick,x,y,halign,valign;surface.removeText(layer);if(!axis.show||axis.ticks.length==0)return;for(var i=0;i<axis.ticks.length;++i){tick=axis.ticks[i];if(!tick.label||tick.v<axis.min||tick.v>axis.max)continue;if(axis.direction==\"x\"){halign=\"center\";x=plotOffset.left+axis.p2c(tick.v);if(axis.position==\"bottom\"){y=box.top+box.padding}else{y=box.top+box.height-box.padding;valign=\"bottom\"}}else{valign=\"middle\";y=plotOffset.top+axis.p2c(tick.v);if(axis.position==\"left\"){x=box.left+box.width-box.padding;halign=\"right\"}else{x=box.left+box.padding}}surface.addText(layer,x,y,tick.label,font,null,null,halign,valign)}})}function drawSeries(series){if(series.lines.show)drawSeriesLines(series);if(series.bars.show)drawSeriesBars(series);if(series.points.show)drawSeriesPoints(series)}function drawSeriesLines(series){function plotLine(datapoints,xoffset,yoffset,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,prevx=null,prevy=null;ctx.beginPath();for(var i=ps;i<points.length;i+=ps){var x1=points[i-ps],y1=points[i-ps+1],x2=points[i],y2=points[i+1];if(x1==null||x2==null)continue;if(y1<=y2&&y1<axisy.min){if(y2<axisy.min)continue;x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2<axisy.min){if(y1<axisy.min)continue;x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max){if(y2>axisy.max)continue;x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max){if(y1>axisy.max)continue;x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1<=x2&&x1<axisx.min){if(x2<axisx.min)continue;y1=(axisx.min-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.min}else if(x2<=x1&&x2<axisx.min){if(x1<axisx.min)continue;y2=(axisx.min-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.min}if(x1>=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(x1!=prevx||y1!=prevy)ctx.moveTo(axisx.p2c(x1)+xoffset,axisy.p2c(y1)+yoffset);prevx=x2;prevy=y2;ctx.lineTo(axisx.p2c(x2)+xoffset,axisy.p2c(y2)+yoffset)}ctx.stroke()}function plotLineArea(datapoints,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,bottom=Math.min(Math.max(0,axisy.min),axisy.max),i=0,top,areaOpen=false,ypos=1,segmentStart=0,segmentEnd=0;while(true){if(ps>0&&i>points.length+ps)break;i+=ps;var x1=points[i-ps],y1=points[i-ps+ypos],x2=points[i],y2=points[i+ypos];if(areaOpen){if(ps>0&&x1!=null&&x2==null){segmentEnd=i;ps=-ps;ypos=2;continue}if(ps<0&&i==segmentStart+ps){ctx.fill();areaOpen=false;ps=-ps;ypos=1;i=segmentStart=segmentEnd+ps;continue}}if(x1==null||x2==null)continue;if(x1<=x2&&x1<axisx.min){if(x2<axisx.min)continue;y1=(axisx.min-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.min}else if(x2<=x1&&x2<axisx.min){if(x1<axisx.min)continue;y2=(axisx.min-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.min}if(x1>=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(!areaOpen){ctx.beginPath();ctx.moveTo(axisx.p2c(x1),axisy.p2c(bottom));areaOpen=true}if(y1>=axisy.max&&y2>=axisy.max){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.max));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.max));continue}else if(y1<=axisy.min&&y2<=axisy.min){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.min));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.min));continue}var x1old=x1,x2old=x2;if(y1<=y2&&y1<axisy.min&&y2>=axisy.min){x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2<axisy.min&&y1>=axisy.min){x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max&&y2<=axisy.max){x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max&&y1<=axisy.max){x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1!=x1old){ctx.lineTo(axisx.p2c(x1old),axisy.p2c(y1))}ctx.lineTo(axisx.p2c(x1),axisy.p2c(y1));ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));if(x2!=x2old){ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));ctx.lineTo(axisx.p2c(x2old),axisy.p2c(y2))}}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineJoin=\"round\";var lw=series.lines.lineWidth,sw=series.shadowSize;if(lw>0&&sw>0){ctx.lineWidth=sw;ctx.strokeStyle=\"rgba(0,0,0,0.1)\";var angle=Math.PI/18;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/2),Math.cos(angle)*(lw/2+sw/2),series.xaxis,series.yaxis);ctx.lineWidth=sw/2;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/4),Math.cos(angle)*(lw/2+sw/4),series.xaxis,series.yaxis)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;var fillStyle=getFillStyle(series.lines,series.color,0,plotHeight);if(fillStyle){ctx.fillStyle=fillStyle;plotLineArea(series.datapoints,series.xaxis,series.yaxis)}if(lw>0)plotLine(series.datapoints,0,0,series.xaxis,series.yaxis);ctx.restore()}function drawSeriesPoints(series){function plotPoints(datapoints,radius,fillStyle,offset,shadow,axisx,axisy,symbol){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i<points.length;i+=ps){var x=points[i],y=points[i+1];if(x==null||x<axisx.min||x>axisx.max||y<axisy.min||y>axisy.max)continue;ctx.beginPath();x=axisx.p2c(x);y=axisy.p2c(y)+offset;if(symbol==\"circle\")ctx.arc(x,y,radius,0,shadow?Math.PI:Math.PI*2,false);else symbol(ctx,x,y,radius,shadow);ctx.closePath();if(fillStyle){ctx.fillStyle=fillStyle;ctx.fill()}ctx.stroke()}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var lw=series.points.lineWidth,sw=series.shadowSize,radius=series.points.radius,symbol=series.points.symbol;if(lw==0)lw=1e-4;if(lw>0&&sw>0){var w=sw/2;ctx.lineWidth=w;ctx.strokeStyle=\"rgba(0,0,0,0.1)\";plotPoints(series.datapoints,radius,null,w+w/2,true,series.xaxis,series.yaxis,symbol);ctx.strokeStyle=\"rgba(0,0,0,0.2)\";plotPoints(series.datapoints,radius,null,w/2,true,series.xaxis,series.yaxis,symbol)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;plotPoints(series.datapoints,radius,getFillStyle(series.points,series.color),0,false,series.xaxis,series.yaxis,symbol);ctx.restore()}function drawBar(x,y,b,barLeft,barRight,fillStyleCallback,axisx,axisy,c,horizontal,lineWidth){var left,right,bottom,top,drawLeft,drawRight,drawTop,drawBottom,tmp;if(horizontal){drawBottom=drawRight=drawTop=true;drawLeft=false;left=b;right=x;top=y+barLeft;bottom=y+barRight;if(right<left){tmp=right;right=left;left=tmp;drawLeft=true;drawRight=false}}else{drawLeft=drawRight=drawTop=true;drawBottom=false;left=x+barLeft;right=x+barRight;bottom=b;top=y;if(top<bottom){tmp=top;top=bottom;bottom=tmp;drawBottom=true;drawTop=false}}if(right<axisx.min||left>axisx.max||top<axisy.min||bottom>axisy.max)return;if(left<axisx.min){left=axisx.min;drawLeft=false}if(right>axisx.max){right=axisx.max;drawRight=false}if(bottom<axisy.min){bottom=axisy.min;drawBottom=false}if(top>axisy.max){top=axisy.max;drawTop=false}left=axisx.p2c(left);bottom=axisy.p2c(bottom);right=axisx.p2c(right);top=axisy.p2c(top);if(fillStyleCallback){c.fillStyle=fillStyleCallback(bottom,top);c.fillRect(left,top,right-left,bottom-top)}if(lineWidth>0&&(drawLeft||drawRight||drawTop||drawBottom)){c.beginPath();c.moveTo(left,bottom);if(drawLeft)c.lineTo(left,top);else c.moveTo(left,top);if(drawTop)c.lineTo(right,top);else c.moveTo(right,top);if(drawRight)c.lineTo(right,bottom);else c.moveTo(right,bottom);if(drawBottom)c.lineTo(left,bottom);else c.moveTo(left,bottom);c.stroke()}}function drawSeriesBars(series){function plotBars(datapoints,barLeft,barRight,fillStyleCallback,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i<points.length;i+=ps){if(points[i]==null)continue;drawBar(points[i],points[i+1],points[i+2],barLeft,barRight,fillStyleCallback,axisx,axisy,ctx,series.bars.horizontal,series.bars.lineWidth)}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineWidth=series.bars.lineWidth;ctx.strokeStyle=series.color;var barLeft;switch(series.bars.align){case\"left\":barLeft=0;break;case\"right\":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}var fillStyleCallback=series.bars.fill?function(bottom,top){return getFillStyle(series.bars,series.color,bottom,top)}:null;plotBars(series.datapoints,barLeft,barLeft+series.bars.barWidth,fillStyleCallback,series.xaxis,series.yaxis);ctx.restore()}function getFillStyle(filloptions,seriesColor,bottom,top){var fill=filloptions.fill;if(!fill)return null;if(filloptions.fillColor)return getColorOrGradient(filloptions.fillColor,bottom,top,seriesColor);var c=$.color.parse(seriesColor);c.a=typeof fill==\"number\"?fill:.4;c.normalize();return c.toString()}function insertLegend(){if(options.legend.container!=null){$(options.legend.container).html(\"\")}else{placeholder.find(\".legend\").remove()}if(!options.legend.show){return}var fragments=[],entries=[],rowStarted=false,lf=options.legend.labelFormatter,s,label;for(var i=0;i<series.length;++i){s=series[i];if(s.label){label=lf?lf(s.label,s):s.label;if(label){entries.push({label:label,color:s.color})}}}if(options.legend.sorted){if($.isFunction(options.legend.sorted)){entries.sort(options.legend.sorted)}else if(options.legend.sorted==\"reverse\"){entries.reverse()}else{var ascending=options.legend.sorted!=\"descending\";entries.sort(function(a,b){return a.label==b.label?0:a.label<b.label!=ascending?1:-1})}}for(var i=0;i<entries.length;++i){var entry=entries[i];if(i%options.legend.noColumns==0){if(rowStarted)fragments.push(\"</tr>\");fragments.push(\"<tr>\");rowStarted=true}fragments.push('<td class=\"legendColorBox\"><div style=\"border:1px solid '+options.legend.labelBoxBorderColor+';padding:1px\"><div style=\"width:4px;height:0;border:5px solid '+entry.color+';overflow:hidden\"></div></div></td>'+'<td class=\"legendLabel\">'+entry.label+\"</td>\")}if(rowStarted)fragments.push(\"</tr>\");if(fragments.length==0)return;var table='<table style=\"font-size:smaller;color:'+options.grid.color+'\">'+fragments.join(\"\")+\"</table>\";if(options.legend.container!=null)$(options.legend.container).html(table);else{var pos=\"\",p=options.legend.position,m=options.legend.margin;if(m[0]==null)m=[m,m];if(p.charAt(0)==\"n\")pos+=\"top:\"+(m[1]+plotOffset.top)+\"px;\";else if(p.charAt(0)==\"s\")pos+=\"bottom:\"+(m[1]+plotOffset.bottom)+\"px;\";if(p.charAt(1)==\"e\")pos+=\"right:\"+(m[0]+plotOffset.right)+\"px;\";else if(p.charAt(1)==\"w\")pos+=\"left:\"+(m[0]+plotOffset.left)+\"px;\";var legend=$('<div class=\"legend\">'+table.replace('style=\"','style=\"position:absolute;'+pos+\";\")+\"</div>\").appendTo(placeholder);if(options.legend.backgroundOpacity!=0){var c=options.legend.backgroundColor;if(c==null){c=options.grid.backgroundColor;if(c&&typeof c==\"string\")c=$.color.parse(c);else c=$.color.extract(legend,\"background-color\");c.a=1;c=c.toString()}var div=legend.children();$('<div style=\"position:absolute;width:'+div.width()+\"px;height:\"+div.height()+\"px;\"+pos+\"background-color:\"+c+';\"> </div>').prependTo(legend).css(\"opacity\",options.legend.backgroundOpacity)}}}var highlights=[],redrawTimeout=null;function findNearbyItem(mouseX,mouseY,seriesFilter){var maxDistance=options.grid.mouseActiveRadius,smallestDistance=maxDistance*maxDistance+1,item=null,foundPoint=false,i,j,ps;for(i=series.length-1;i>=0;--i){if(!seriesFilter(series[i]))continue;var s=series[i],axisx=s.xaxis,axisy=s.yaxis,points=s.datapoints.points,mx=axisx.c2p(mouseX),my=axisy.c2p(mouseY),maxx=maxDistance/axisx.scale,maxy=maxDistance/axisy.scale;ps=s.datapoints.pointsize;if(axisx.options.inverseTransform)maxx=Number.MAX_VALUE;if(axisy.options.inverseTransform)maxy=Number.MAX_VALUE;if(s.lines.show||s.points.show){for(j=0;j<points.length;j+=ps){var x=points[j],y=points[j+1];if(x==null)continue;if(x-mx>maxx||x-mx<-maxx||y-my>maxy||y-my<-maxy)continue;var dx=Math.abs(axisx.p2c(x)-mouseX),dy=Math.abs(axisy.p2c(y)-mouseY),dist=dx*dx+dy*dy;if(dist<smallestDistance){smallestDistance=dist;item=[i,j/ps]}}}if(s.bars.show&&!item){var barLeft,barRight;switch(s.bars.align){case\"left\":barLeft=0;break;case\"right\":barLeft=-s.bars.barWidth;break;default:barLeft=-s.bars.barWidth/2}barRight=barLeft+s.bars.barWidth;for(j=0;j<points.length;j+=ps){var x=points[j],y=points[j+1],b=points[j+2];if(x==null)continue;if(series[i].bars.horizontal?mx<=Math.max(b,x)&&mx>=Math.min(b,x)&&my>=y+barLeft&&my<=y+barRight:mx>=x+barLeft&&mx<=x+barRight&&my>=Math.min(b,y)&&my<=Math.max(b,y))item=[i,j/ps]}}}if(item){i=item[0];j=item[1];ps=series[i].datapoints.pointsize;return{datapoint:series[i].datapoints.points.slice(j*ps,(j+1)*ps),dataIndex:j,series:series[i],seriesIndex:i}}return null}function onMouseMove(e){if(options.grid.hoverable)triggerClickHoverEvent(\"plothover\",e,function(s){return s[\"hoverable\"]!=false})}function onMouseLeave(e){if(options.grid.hoverable)triggerClickHoverEvent(\"plothover\",e,function(s){return false})}function onClick(e){triggerClickHoverEvent(\"plotclick\",e,function(s){return s[\"clickable\"]!=false})}function triggerClickHoverEvent(eventname,event,seriesFilter){var offset=eventHolder.offset(),canvasX=event.pageX-offset.left-plotOffset.left,canvasY=event.pageY-offset.top-plotOffset.top,pos=canvasToAxisCoords({left:canvasX,top:canvasY});pos.pageX=event.pageX;pos.pageY=event.pageY;var item=findNearbyItem(canvasX,canvasY,seriesFilter);if(item){item.pageX=parseInt(item.series.xaxis.p2c(item.datapoint[0])+offset.left+plotOffset.left,10);item.pageY=parseInt(item.series.yaxis.p2c(item.datapoint[1])+offset.top+plotOffset.top,10)}if(options.grid.autoHighlight){for(var i=0;i<highlights.length;++i){var h=highlights[i];if(h.auto==eventname&&!(item&&h.series==item.series&&h.point[0]==item.datapoint[0]&&h.point[1]==item.datapoint[1]))unhighlight(h.series,h.point)}if(item)highlight(item.series,item.datapoint,eventname)}placeholder.trigger(eventname,[pos,item])}function triggerRedrawOverlay(){var t=options.interaction.redrawOverlayInterval;if(t==-1){drawOverlay();return}if(!redrawTimeout)redrawTimeout=setTimeout(drawOverlay,t)}function drawOverlay(){redrawTimeout=null;octx.save();overlay.clear();octx.translate(plotOffset.left,plotOffset.top);var i,hi;for(i=0;i<highlights.length;++i){hi=highlights[i];if(hi.series.bars.show)drawBarHighlight(hi.series,hi.point);else drawPointHighlight(hi.series,hi.point)}octx.restore();executeHooks(hooks.drawOverlay,[octx])}function highlight(s,point,auto){if(typeof s==\"number\")s=series[s];if(typeof point==\"number\"){var ps=s.datapoints.pointsize;point=s.datapoints.points.slice(ps*point,ps*(point+1))}var i=indexOfHighlight(s,point);if(i==-1){highlights.push({series:s,point:point,auto:auto});triggerRedrawOverlay()}else if(!auto)highlights[i].auto=false}function unhighlight(s,point){if(s==null&&point==null){highlights=[];triggerRedrawOverlay();return}if(typeof s==\"number\")s=series[s];if(typeof point==\"number\"){var ps=s.datapoints.pointsize;point=s.datapoints.points.slice(ps*point,ps*(point+1))}var i=indexOfHighlight(s,point);if(i!=-1){highlights.splice(i,1);triggerRedrawOverlay()}}function indexOfHighlight(s,p){for(var i=0;i<highlights.length;++i){var h=highlights[i];if(h.series==s&&h.point[0]==p[0]&&h.point[1]==p[1])return i}return-1}function drawPointHighlight(series,point){var x=point[0],y=point[1],axisx=series.xaxis,axisy=series.yaxis,highlightColor=typeof series.highlightColor===\"string\"?series.highlightColor:$.color.parse(series.color).scale(\"a\",.5).toString();if(x<axisx.min||x>axisx.max||y<axisy.min||y>axisy.max)return;var pointRadius=series.points.radius+series.points.lineWidth/2;octx.lineWidth=pointRadius;octx.strokeStyle=highlightColor;var radius=1.5*pointRadius;x=axisx.p2c(x);y=axisy.p2c(y);octx.beginPath();if(series.points.symbol==\"circle\")octx.arc(x,y,radius,0,2*Math.PI,false);else series.points.symbol(octx,x,y,radius,false);octx.closePath();octx.stroke()}function drawBarHighlight(series,point){var highlightColor=typeof series.highlightColor===\"string\"?series.highlightColor:$.color.parse(series.color).scale(\"a\",.5).toString(),fillStyle=highlightColor,barLeft;switch(series.bars.align){case\"left\":barLeft=0;break;case\"right\":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}octx.lineWidth=series.bars.lineWidth;octx.strokeStyle=highlightColor;drawBar(point[0],point[1],point[2]||0,barLeft,barLeft+series.bars.barWidth,function(){return fillStyle},series.xaxis,series.yaxis,octx,series.bars.horizontal,series.bars.lineWidth)}function getColorOrGradient(spec,bottom,top,defaultColor){if(typeof spec==\"string\")return spec;else{var gradient=ctx.createLinearGradient(0,top,0,bottom);for(var i=0,l=spec.colors.length;i<l;++i){var c=spec.colors[i];if(typeof c!=\"string\"){var co=$.color.parse(defaultColor);if(c.brightness!=null)co=co.scale(\"rgb\",c.brightness);if(c.opacity!=null)co.a*=c.opacity;c=co.toString()}gradient.addColorStop(i/(l-1),c)}return gradient}}}$.plot=function(placeholder,data,options){var plot=new Plot($(placeholder),data,options,$.plot.plugins);return plot};$.plot.version=\"0.8.3\";$.plot.plugins=[];$.fn.plot=function(data,options){return this.each(function(){$.plot(this,data,options)})};function floorInBase(n,base){return base*Math.floor(n/base)}})(jQuery);\n\n/* Javascript plotting library for jQuery, version 0.8.3.\n\nCopyright (c) 2007-2014 IOLA and Ole Laursen.\nLicensed under the MIT license.\n\n*/\n(function($){var options={series:{stack:null}};function init(plot){function findMatchingSeries(s,allseries){var res=null;for(var i=0;i<allseries.length;++i){if(s==allseries[i])break;if(allseries[i].stack==s.stack)res=allseries[i]}return res}function stackData(plot,s,datapoints){if(s.stack==null||s.stack===false)return;var other=findMatchingSeries(s,plot.getData());if(!other)return;var ps=datapoints.pointsize,points=datapoints.points,otherps=other.datapoints.pointsize,otherpoints=other.datapoints.points,newpoints=[],px,py,intery,qx,qy,bottom,withlines=s.lines.show,horizontal=s.bars.horizontal,withbottom=ps>2&&(horizontal?datapoints.format[2].x:datapoints.format[2].y),withsteps=withlines&&s.lines.steps,fromgap=true,keyOffset=horizontal?1:0,accumulateOffset=horizontal?0:1,i=0,j=0,l,m;while(true){if(i>=points.length)break;l=newpoints.length;if(points[i]==null){for(m=0;m<ps;++m)newpoints.push(points[i+m]);i+=ps}else if(j>=otherpoints.length){if(!withlines){for(m=0;m<ps;++m)newpoints.push(points[i+m])}i+=ps}else if(otherpoints[j]==null){for(m=0;m<ps;++m)newpoints.push(null);fromgap=true;j+=otherps}else{px=points[i+keyOffset];py=points[i+accumulateOffset];qx=otherpoints[j+keyOffset];qy=otherpoints[j+accumulateOffset];bottom=0;if(px==qx){for(m=0;m<ps;++m)newpoints.push(points[i+m]);newpoints[l+accumulateOffset]+=qy;bottom=qy;i+=ps;j+=otherps}else if(px>qx){if(withlines&&i>0&&points[i-ps]!=null){intery=py+(points[i-ps+accumulateOffset]-py)*(qx-px)/(points[i-ps+keyOffset]-px);newpoints.push(qx);newpoints.push(intery+qy);for(m=2;m<ps;++m)newpoints.push(points[i+m]);bottom=qy}j+=otherps}else{if(fromgap&&withlines){i+=ps;continue}for(m=0;m<ps;++m)newpoints.push(points[i+m]);if(withlines&&j>0&&otherpoints[j-otherps]!=null)bottom=qy+(otherpoints[j-otherps+accumulateOffset]-qy)*(px-qx)/(otherpoints[j-otherps+keyOffset]-qx);newpoints[l+accumulateOffset]+=bottom;i+=ps}fromgap=false;if(l!=newpoints.length&&withbottom)newpoints[l+2]+=bottom}if(withsteps&&l!=newpoints.length&&l>0&&newpoints[l]!=null&&newpoints[l]!=newpoints[l-ps]&&newpoints[l+1]!=newpoints[l-ps+1]){for(m=0;m<ps;++m)newpoints[l+ps+m]=newpoints[l+m];newpoints[l+1]=newpoints[l-ps+1]}}datapoints.points=newpoints}plot.hooks.processDatapoints.push(stackData)}$.plot.plugins.push({init:init,options:options,name:\"stack\",version:\"1.2\"})})(jQuery);\n\n/* Javascript plotting library for jQuery, version 0.8.3.\n\nCopyright (c) 2007-2014 IOLA and Ole Laursen.\nLicensed under the MIT license.\n\n*/\n(function($){var REDRAW_ATTEMPTS=10;var REDRAW_SHRINK=.95;function init(plot){var canvas=null,target=null,options=null,maxRadius=null,centerLeft=null,centerTop=null,processed=false,ctx=null;var highlights=[];plot.hooks.processOptions.push(function(plot,options){if(options.series.pie.show){options.grid.show=false;if(options.series.pie.label.show==\"auto\"){if(options.legend.show){options.series.pie.label.show=false}else{options.series.pie.label.show=true}}if(options.series.pie.radius==\"auto\"){if(options.series.pie.label.show){options.series.pie.radius=3/4}else{options.series.pie.radius=1}}if(options.series.pie.tilt>1){options.series.pie.tilt=1}else if(options.series.pie.tilt<0){options.series.pie.tilt=0}}});plot.hooks.bindEvents.push(function(plot,eventHolder){var options=plot.getOptions();if(options.series.pie.show){if(options.grid.hoverable){eventHolder.unbind(\"mousemove\").mousemove(onMouseMove)}if(options.grid.clickable){eventHolder.unbind(\"click\").click(onClick)}}});plot.hooks.processDatapoints.push(function(plot,series,data,datapoints){var options=plot.getOptions();if(options.series.pie.show){processDatapoints(plot,series,data,datapoints)}});plot.hooks.drawOverlay.push(function(plot,octx){var options=plot.getOptions();if(options.series.pie.show){drawOverlay(plot,octx)}});plot.hooks.draw.push(function(plot,newCtx){var options=plot.getOptions();if(options.series.pie.show){draw(plot,newCtx)}});function processDatapoints(plot,series,datapoints){if(!processed){processed=true;canvas=plot.getCanvas();target=$(canvas).parent();options=plot.getOptions();plot.setData(combine(plot.getData()))}}function combine(data){var total=0,combined=0,numCombined=0,color=options.series.pie.combine.color,newdata=[];for(var i=0;i<data.length;++i){var value=data[i].data;if($.isArray(value)&&value.length==1){value=value[0]}if($.isArray(value)){if(!isNaN(parseFloat(value[1]))&&isFinite(value[1])){value[1]=+value[1]}else{value[1]=0}}else if(!isNaN(parseFloat(value))&&isFinite(value)){value=[1,+value]}else{value=[1,0]}data[i].data=[value]}for(var i=0;i<data.length;++i){total+=data[i].data[0][1]}for(var i=0;i<data.length;++i){var value=data[i].data[0][1];if(value/total<=options.series.pie.combine.threshold){combined+=value;numCombined++;if(!color){color=data[i].color}}}for(var i=0;i<data.length;++i){var value=data[i].data[0][1];if(numCombined<2||value/total>options.series.pie.combine.threshold){newdata.push($.extend(data[i],{data:[[1,value]],color:data[i].color,label:data[i].label,angle:value*Math.PI*2/total,percent:value/(total/100)}))}}if(numCombined>1){newdata.push({data:[[1,combined]],color:color,label:options.series.pie.combine.label,angle:combined*Math.PI*2/total,percent:combined/(total/100)})}return newdata}function draw(plot,newCtx){if(!target){return}var canvasWidth=plot.getPlaceholder().width(),canvasHeight=plot.getPlaceholder().height(),legendWidth=target.children().filter(\".legend\").children().width()||0;ctx=newCtx;processed=false;maxRadius=Math.min(canvasWidth,canvasHeight/options.series.pie.tilt)/2;centerTop=canvasHeight/2+options.series.pie.offset.top;centerLeft=canvasWidth/2;if(options.series.pie.offset.left==\"auto\"){if(options.legend.position.match(\"w\")){centerLeft+=legendWidth/2}else{centerLeft-=legendWidth/2}if(centerLeft<maxRadius){centerLeft=maxRadius}else if(centerLeft>canvasWidth-maxRadius){centerLeft=canvasWidth-maxRadius}}else{centerLeft+=options.series.pie.offset.left}var slices=plot.getData(),attempts=0;do{if(attempts>0){maxRadius*=REDRAW_SHRINK}attempts+=1;clear();if(options.series.pie.tilt<=.8){drawShadow()}}while(!drawPie()&&attempts<REDRAW_ATTEMPTS);if(attempts>=REDRAW_ATTEMPTS){clear();target.prepend(\"<div class='error'>Could not draw pie with labels contained inside canvas</div>\")}if(plot.setSeries&&plot.insertLegend){plot.setSeries(slices);plot.insertLegend()}function clear(){ctx.clearRect(0,0,canvasWidth,canvasHeight);target.children().filter(\".pieLabel, .pieLabelBackground\").remove()}function drawShadow(){var shadowLeft=options.series.pie.shadow.left;var shadowTop=options.series.pie.shadow.top;var edge=10;var alpha=options.series.pie.shadow.alpha;var radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius;if(radius>=canvasWidth/2-shadowLeft||radius*options.series.pie.tilt>=canvasHeight/2-shadowTop||radius<=edge){return}ctx.save();ctx.translate(shadowLeft,shadowTop);ctx.globalAlpha=alpha;ctx.fillStyle=\"#000\";ctx.translate(centerLeft,centerTop);ctx.scale(1,options.series.pie.tilt);for(var i=1;i<=edge;i++){ctx.beginPath();ctx.arc(0,0,radius,0,Math.PI*2,false);ctx.fill();radius-=i}ctx.restore()}function drawPie(){var startAngle=Math.PI*options.series.pie.startAngle;var radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius;ctx.save();ctx.translate(centerLeft,centerTop);ctx.scale(1,options.series.pie.tilt);ctx.save();var currentAngle=startAngle;for(var i=0;i<slices.length;++i){slices[i].startAngle=currentAngle;drawSlice(slices[i].angle,slices[i].color,true)}ctx.restore();if(options.series.pie.stroke.width>0){ctx.save();ctx.lineWidth=options.series.pie.stroke.width;currentAngle=startAngle;for(var i=0;i<slices.length;++i){drawSlice(slices[i].angle,options.series.pie.stroke.color,false)}ctx.restore()}drawDonutHole(ctx);ctx.restore();if(options.series.pie.label.show){return drawLabels()}else return true;function drawSlice(angle,color,fill){if(angle<=0||isNaN(angle)){return}if(fill){ctx.fillStyle=color}else{ctx.strokeStyle=color;ctx.lineJoin=\"round\"}ctx.beginPath();if(Math.abs(angle-Math.PI*2)>1e-9){ctx.moveTo(0,0)}ctx.arc(0,0,radius,currentAngle,currentAngle+angle/2,false);ctx.arc(0,0,radius,currentAngle+angle/2,currentAngle+angle,false);ctx.closePath();currentAngle+=angle;if(fill){ctx.fill()}else{ctx.stroke()}}function drawLabels(){var currentAngle=startAngle;var radius=options.series.pie.label.radius>1?options.series.pie.label.radius:maxRadius*options.series.pie.label.radius;for(var i=0;i<slices.length;++i){if(slices[i].percent>=options.series.pie.label.threshold*100){if(!drawLabel(slices[i],currentAngle,i)){return false}}currentAngle+=slices[i].angle}return true;function drawLabel(slice,startAngle,index){if(slice.data[0][1]==0){return true}var lf=options.legend.labelFormatter,text,plf=options.series.pie.label.formatter;if(lf){text=lf(slice.label,slice)}else{text=slice.label}if(plf){text=plf(text,slice)}var halfAngle=(startAngle+slice.angle+startAngle)/2;var x=centerLeft+Math.round(Math.cos(halfAngle)*radius);var y=centerTop+Math.round(Math.sin(halfAngle)*radius)*options.series.pie.tilt;var html=\"<span class='pieLabel' id='pieLabel\"+index+\"' style='position:absolute;top:\"+y+\"px;left:\"+x+\"px;'>\"+text+\"</span>\";target.append(html);var label=target.children(\"#pieLabel\"+index);var labelTop=y-label.height()/2;var labelLeft=x-label.width()/2;label.css(\"top\",labelTop);label.css(\"left\",labelLeft);if(0-labelTop>0||0-labelLeft>0||canvasHeight-(labelTop+label.height())<0||canvasWidth-(labelLeft+label.width())<0){return false}if(options.series.pie.label.background.opacity!=0){var c=options.series.pie.label.background.color;if(c==null){c=slice.color}var pos=\"top:\"+labelTop+\"px;left:\"+labelLeft+\"px;\";$(\"<div class='pieLabelBackground' style='position:absolute;width:\"+label.width()+\"px;height:\"+label.height()+\"px;\"+pos+\"background-color:\"+c+\";'></div>\").css(\"opacity\",options.series.pie.label.background.opacity).insertBefore(label)}return true}}}}function drawDonutHole(layer){if(options.series.pie.innerRadius>0){layer.save();var innerRadius=options.series.pie.innerRadius>1?options.series.pie.innerRadius:maxRadius*options.series.pie.innerRadius;layer.globalCompositeOperation=\"destination-out\";layer.beginPath();layer.fillStyle=options.series.pie.stroke.color;layer.arc(0,0,innerRadius,0,Math.PI*2,false);layer.fill();layer.closePath();layer.restore();layer.save();layer.beginPath();layer.strokeStyle=options.series.pie.stroke.color;layer.arc(0,0,innerRadius,0,Math.PI*2,false);layer.stroke();layer.closePath();layer.restore()}}function isPointInPoly(poly,pt){for(var c=false,i=-1,l=poly.length,j=l-1;++i<l;j=i)(poly[i][1]<=pt[1]&&pt[1]<poly[j][1]||poly[j][1]<=pt[1]&&pt[1]<poly[i][1])&&pt[0]<(poly[j][0]-poly[i][0])*(pt[1]-poly[i][1])/(poly[j][1]-poly[i][1])+poly[i][0]&&(c=!c);return c}function findNearbySlice(mouseX,mouseY){var slices=plot.getData(),options=plot.getOptions(),radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius,x,y;for(var i=0;i<slices.length;++i){var s=slices[i];if(s.pie.show){ctx.save();ctx.beginPath();ctx.moveTo(0,0);ctx.arc(0,0,radius,s.startAngle,s.startAngle+s.angle/2,false);ctx.arc(0,0,radius,s.startAngle+s.angle/2,s.startAngle+s.angle,false);ctx.closePath();x=mouseX-centerLeft;y=mouseY-centerTop;if(ctx.isPointInPath){if(ctx.isPointInPath(mouseX-centerLeft,mouseY-centerTop)){ctx.restore();return{datapoint:[s.percent,s.data],dataIndex:0,series:s,seriesIndex:i}}}else{var p1X=radius*Math.cos(s.startAngle),p1Y=radius*Math.sin(s.startAngle),p2X=radius*Math.cos(s.startAngle+s.angle/4),p2Y=radius*Math.sin(s.startAngle+s.angle/4),p3X=radius*Math.cos(s.startAngle+s.angle/2),p3Y=radius*Math.sin(s.startAngle+s.angle/2),p4X=radius*Math.cos(s.startAngle+s.angle/1.5),p4Y=radius*Math.sin(s.startAngle+s.angle/1.5),p5X=radius*Math.cos(s.startAngle+s.angle),p5Y=radius*Math.sin(s.startAngle+s.angle),arrPoly=[[0,0],[p1X,p1Y],[p2X,p2Y],[p3X,p3Y],[p4X,p4Y],[p5X,p5Y]],arrPoint=[x,y];if(isPointInPoly(arrPoly,arrPoint)){ctx.restore();return{datapoint:[s.percent,s.data],dataIndex:0,series:s,seriesIndex:i}}}ctx.restore()}}return null}function onMouseMove(e){triggerClickHoverEvent(\"plothover\",e)}function onClick(e){triggerClickHoverEvent(\"plotclick\",e)}function triggerClickHoverEvent(eventname,e){var offset=plot.offset();var canvasX=parseInt(e.pageX-offset.left);var canvasY=parseInt(e.pageY-offset.top);var item=findNearbySlice(canvasX,canvasY);if(options.grid.autoHighlight){for(var i=0;i<highlights.length;++i){var h=highlights[i];if(h.auto==eventname&&!(item&&h.series==item.series)){unhighlight(h.series)}}}if(item){highlight(item.series,eventname)}var pos={pageX:e.pageX,pageY:e.pageY};target.trigger(eventname,[pos,item])}function highlight(s,auto){var i=indexOfHighlight(s);if(i==-1){highlights.push({series:s,auto:auto});plot.triggerRedrawOverlay()}else if(!auto){highlights[i].auto=false}}function unhighlight(s){if(s==null){highlights=[];plot.triggerRedrawOverlay()}var i=indexOfHighlight(s);if(i!=-1){highlights.splice(i,1);plot.triggerRedrawOverlay()}}function indexOfHighlight(s){for(var i=0;i<highlights.length;++i){var h=highlights[i];if(h.series==s)return i}return-1}function drawOverlay(plot,octx){var options=plot.getOptions();var radius=options.series.pie.radius>1?options.series.pie.radius:maxRadius*options.series.pie.radius;octx.save();octx.translate(centerLeft,centerTop);octx.scale(1,options.series.pie.tilt);for(var i=0;i<highlights.length;++i){drawHighlight(highlights[i].series)}drawDonutHole(octx);octx.restore();function drawHighlight(series){if(series.angle<=0||isNaN(series.angle)){return}octx.fillStyle=\"rgba(255, 255, 255, \"+options.series.pie.highlight.opacity+\")\";octx.beginPath();if(Math.abs(series.angle-Math.PI*2)>1e-9){octx.moveTo(0,0)}octx.arc(0,0,radius,series.startAngle,series.startAngle+series.angle/2,false);octx.arc(0,0,radius,series.startAngle+series.angle/2,series.startAngle+series.angle,false);octx.closePath();octx.fill()}}}var options={series:{pie:{show:false,radius:\"auto\",innerRadius:0,startAngle:3/2,tilt:1,shadow:{left:5,top:15,alpha:.02},offset:{top:0,left:\"auto\"},stroke:{color:\"#fff\",width:1},label:{show:\"auto\",formatter:function(label,slice){return\"<div style='font-size:x-small;text-align:center;padding:2px;color:\"+slice.color+\";'>\"+label+\"<br/>\"+Math.round(slice.percent)+\"%</div>\"},radius:1,background:{color:null,opacity:0},threshold:0},combine:{threshold:-1,color:null,label:\"Other\"},highlight:{opacity:.5}}}};$.plot.plugins.push({init:init,options:options,name:\"pie\",version:\"1.1\"})})(jQuery);\n"
  },
  {
    "path": "qt/aqt/data/web/js/webview.ts",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n// prevent backspace key from going back a page\ndocument.addEventListener(\"keydown\", function(evt: KeyboardEvent) {\n    if (evt.keyCode !== 8) {\n        return;\n    }\n    let isText = 0;\n    const node = evt.target as Element;\n    const nn = node.nodeName;\n    if (nn === \"INPUT\" || nn === \"TEXTAREA\") {\n        isText = 1;\n    } else if (nn === \"DIV\" && (node as HTMLDivElement).contentEditable) {\n        isText = 1;\n    }\n    if (!isText) {\n        evt.preventDefault();\n    }\n});\n"
  },
  {
    "path": "qt/aqt/dbcheck.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom concurrent.futures import Future\n\nimport aqt\nimport aqt.main\nfrom aqt.qt import *\nfrom aqt.utils import showText, tooltip\n\n\ndef on_progress(mw: aqt.main.AnkiQt) -> None:\n    progress = mw.col.latest_progress()\n    if not progress.HasField(\"database_check\"):\n        return\n    dbprogress = progress.database_check\n    mw.progress.update(\n        process=False,\n        label=dbprogress.stage,\n        value=dbprogress.stage_current,\n        max=dbprogress.stage_total,\n    )\n\n\ndef check_db(mw: aqt.AnkiQt) -> None:\n    def on_timer() -> None:\n        on_progress(mw)\n\n    timer = QTimer(mw)\n    qconnect(timer.timeout, on_timer)\n    timer.start(100)\n\n    def do_check() -> tuple[str, bool]:\n        mw.create_backup_now()\n        return mw.col.fix_integrity()\n\n    def on_future_done(fut: Future) -> None:\n        timer.stop()\n        ret, ok = fut.result()\n\n        if not ok:\n            showText(ret, parent=mw)\n        else:\n            tooltip(ret, parent=mw)\n\n        # if an error has directed the user to check the database,\n        # silently clean up any broken reset hooks which distract from\n        # the underlying issue\n        n = 0\n        while n < 10:\n            try:\n                mw.reset()\n                break\n            except Exception as e:\n                print(\"swallowed exception in reset hook:\", e)\n                n += 1\n                continue\n\n    mw.taskman.with_progress(do_check, on_future_done)\n"
  },
  {
    "path": "qt/aqt/debug_console.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport sys\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom functools import partial\nfrom pathlib import Path\nfrom typing import TextIO, cast\n\nimport anki.cards\nimport aqt\nimport aqt.forms\nfrom aqt import gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import (\n    disable_help_button,\n    restoreGeom,\n    restoreSplitter,\n    saveGeom,\n    saveSplitter,\n    send_to_trash,\n    tr,\n)\n\n\ndef show_debug_console() -> None:\n    assert aqt.mw\n    console = DebugConsole(aqt.mw)\n    gui_hooks.debug_console_will_show(console)\n    console.show()\n\n\nSCRIPT_FOLDER = \"debug_scripts\"\nUNSAVED_SCRIPT = \"Unsaved script\"\n\n\n@dataclass\nclass Action:\n    name: str\n    shortcut: str\n    action: Callable[[], None]\n\n\nclass DebugConsole(QDialog):\n    silentlyClose = True\n    _last_index = 0\n\n    def __init__(self, parent: QWidget) -> None:\n        self._buffers: dict[int, str] = {}\n        super().__init__(parent)\n        self._setup_ui()\n        disable_help_button(self)\n        restoreGeom(self, \"DebugConsoleWindow\")\n        restoreSplitter(self.frm.splitter, \"DebugConsoleWindow\")\n\n    def _setup_ui(self):\n        self.frm = aqt.forms.debug.Ui_Dialog()\n        self.frm.setupUi(self)\n        self._text: QPlainTextEdit = self.frm.text\n        self._log: QPlainTextEdit = self.frm.log\n        self._script: QComboBox = self.frm.script\n        self._setup_text_edits()\n        self._setup_scripts()\n        self._setup_actions()\n        self._setup_context_menu()\n        qconnect(self.frm.widgetsButton.clicked, self._on_widgetGallery)\n        qconnect(self._script.currentIndexChanged, self._on_script_change)\n\n    def _setup_text_edits(self):\n        font = QFont(\"Consolas\")\n        if not font.exactMatch():\n            font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)\n        font.setPointSize(self._text.font().pointSize() + 1)\n        self._text.setFont(font)\n        self._log.setFont(font)\n\n    def _setup_scripts(self) -> None:\n        self._dir = Path(aqt.mw.pm.base).joinpath(SCRIPT_FOLDER)\n        self._dir.mkdir(exist_ok=True)\n        self._script.addItem(UNSAVED_SCRIPT)\n        self._script.addItems(os.listdir(self._dir))\n\n    def _setup_actions(self) -> None:\n        for action in self._actions():\n            qconnect(\n                QShortcut(QKeySequence(action.shortcut), self).activated, action.action\n            )\n\n    def _actions(self):\n        return [\n            Action(\"Execute\", \"ctrl+return\", self.onDebugRet),\n            Action(\"Execute and print\", \"ctrl+shift+return\", self.onDebugPrint),\n            Action(\"Clear log\", \"ctrl+l\", self._log.clear),\n            Action(\"Clear code\", \"ctrl+shift+l\", self._text.clear),\n            Action(\"Save script\", \"ctrl+s\", self._save_script),\n            Action(\"Open script\", \"ctrl+o\", self._open_script),\n            Action(\"Delete script\", \"ctrl+d\", self._delete_script),\n        ]\n\n    def reject(self) -> None:\n        super().reject()\n        saveSplitter(self.frm.splitter, \"DebugConsoleWindow\")\n        saveGeom(self, \"DebugConsoleWindow\")\n\n    def _on_script_change(self, new_index: int) -> None:\n        self._buffers[self._last_index] = self._text.toPlainText()\n        self._text.setPlainText(self._get_script(new_index) or \"\")\n        self._last_index = new_index\n\n    def _get_script(self, idx: int) -> str | None:\n        if script := self._buffers.get(idx, \"\"):\n            return script\n        if path := self._get_item(idx):\n            return path.read_text(encoding=\"utf8\")\n        return None\n\n    def _get_item(self, idx: int) -> Path | None:\n        if not idx:\n            return None\n        path = Path(self._script.itemText(idx))\n        return path if path.is_absolute() else self._dir.joinpath(path)\n\n    def _get_index(self, path: Path) -> int:\n        return self._script.findText(self._path_to_item(path))\n\n    def _path_to_item(self, path: Path) -> str:\n        return path.name if path.is_relative_to(self._dir) else str(path)\n\n    def _current_script_path(self) -> Path | None:\n        return self._get_item(self._script.currentIndex())\n\n    def _save_script(self) -> None:\n        if not (path := self._current_script_path()):\n            new_file = QFileDialog.getSaveFileName(\n                self, directory=str(self._dir), filter=\"Python file (*.py)\"\n            )[0]\n            if not new_file:\n                return\n            path = Path(new_file)\n\n        path.write_text(self._text.toPlainText(), encoding=\"utf8\")\n\n        item = self._path_to_item(path)\n        if (idx := self._get_index(path)) == -1:\n            self._script.addItem(item)\n            idx = self._script.count() - 1\n        # update existing buffer, so text edit doesn't change when index changes\n        self._buffers[idx] = self._text.toPlainText()\n        self._script.setCurrentIndex(idx)\n\n    def _open_script(self) -> None:\n        file = QFileDialog.getOpenFileName(\n            self, directory=str(self._dir), filter=\"Python file (*.py)\"\n        )[0]\n        if not file:\n            return\n\n        path = Path(file)\n        item = self._path_to_item(path)\n        if (idx := self._get_index(path)) == -1:\n            self._script.addItem(item)\n            idx = self._script.count() - 1\n        elif idx in self._buffers:\n            del self._buffers[idx]\n\n        if idx == self._script.currentIndex():\n            self._text.setPlainText(path.read_text(encoding=\"utf8\"))\n        else:\n            self._script.setCurrentIndex(idx)\n\n    def _delete_script(self) -> None:\n        if not (path := self._current_script_path()):\n            return\n        send_to_trash(path)\n        deleted_idx = self._script.currentIndex()\n        self._script.setCurrentIndex(0)\n        self._script.removeItem(deleted_idx)\n        self._drop_buffer_and_shift_keys(deleted_idx)\n\n    def _drop_buffer_and_shift_keys(self, idx: int) -> None:\n        def shift(old_idx: int) -> int:\n            return old_idx - 1 if old_idx > idx else old_idx\n\n        self._buffers = {shift(i): val for i, val in self._buffers.items() if i != idx}\n\n    def _setup_context_menu(self) -> None:\n        for text_edit in (self._log, self._text):\n            text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n            qconnect(\n                text_edit.customContextMenuRequested,\n                partial(self._on_context_menu, text_edit),\n            )\n\n    def _on_context_menu(self, text_edit: QPlainTextEdit) -> None:\n        menu = text_edit.createStandardContextMenu()\n        assert menu is not None\n        menu.addSeparator()\n        for action in self._actions():\n            entry = menu.addAction(action.name)\n            entry.setShortcut(QKeySequence(action.shortcut))\n            qconnect(entry.triggered, action.action)\n        menu.exec(QCursor.pos())\n\n    def _on_widgetGallery(self) -> None:\n        from aqt.widgetgallery import WidgetGallery\n\n        self.widgetGallery = WidgetGallery(self)\n        self.widgetGallery.show()\n\n    def _captureOutput(self, on: bool) -> None:\n        mw2 = self\n\n        class Stream:\n            def write(self, data: str) -> None:\n                mw2._output += data\n\n        if on:\n            self._output = \"\"\n            self._oldStderr = sys.stderr\n            self._oldStdout = sys.stdout\n            s = cast(TextIO, Stream())\n            sys.stderr = s\n            sys.stdout = s\n        else:\n            sys.stderr = self._oldStderr\n            sys.stdout = self._oldStdout\n\n    def _card_repr(self, card: anki.cards.Card | None) -> None:\n        import copy\n        import pprint\n\n        if not card:\n            print(\"no card\")\n            return\n\n        print(\"Front:\", card.question())\n        print(\"\\n\")\n        print(\"Back:\", card.answer())\n\n        print(\"\\nNote:\")\n        note = copy.copy(card.note())\n        for k, v in note.items():\n            print(f\"- {k}:\", v)\n\n        print(\"\\n\")\n        del note.fields\n        del note._fmap\n        pprint.pprint(note.__dict__)\n\n        print(\"\\nCard:\")\n        c = copy.copy(card)\n        c._render_output = None\n        pprint.pprint(c.__dict__)\n\n    def _debugCard(self) -> anki.cards.Card | None:\n        assert aqt.mw\n        card = aqt.mw.reviewer.card\n        self._card_repr(card)\n        return card\n\n    def _debugBrowserCard(self) -> anki.cards.Card | None:\n        card = aqt.dialogs._dialogs[\"Browser\"][1].card\n        self._card_repr(card)\n        return card\n\n    def onDebugPrint(self) -> None:\n        cursor = self._text.textCursor()\n        position = cursor.position()\n        cursor.select(QTextCursor.SelectionType.LineUnderCursor)\n        line = cursor.selectedText()\n        whitespace, stripped = _split_off_leading_whitespace(line)\n        pfx, sfx = \"pp(\", \")\"\n        if not stripped.startswith(pfx):\n            line = f\"{whitespace}{pfx}{stripped}{sfx}\"\n            cursor.insertText(line)\n            cursor.setPosition(position + len(pfx))\n            self._text.setTextCursor(cursor)\n        self.onDebugRet()\n\n    def onDebugRet(self) -> None:\n        import pprint\n        import traceback\n\n        text = self._text.toPlainText()\n        vars = {\n            \"card\": self._debugCard,\n            \"bcard\": self._debugBrowserCard,\n            \"mw\": aqt.mw,\n            \"pp\": pprint.pprint,\n        }\n        self._captureOutput(True)\n        try:\n            exec(text, vars)\n        except Exception:\n            self._output += traceback.format_exc()\n        self._captureOutput(False)\n        buf = \"\"\n        for c, line in enumerate(text.strip().split(\"\\n\")):\n            if c == 0:\n                buf += f\">>> {line}\\n\"\n            else:\n                buf += f\"... {line}\\n\"\n        try:\n            to_append = buf + (self._output or \"<no output>\")\n            to_append = gui_hooks.debug_console_did_evaluate_python(\n                to_append, text, self.frm\n            )\n            self._log.appendPlainText(to_append)\n        except UnicodeDecodeError:\n            to_append = tr.qt_misc_non_unicode_text()\n            to_append = gui_hooks.debug_console_did_evaluate_python(\n                to_append, text, self.frm\n            )\n            self._log.appendPlainText(to_append)\n        slider = self._log.verticalScrollBar()\n        assert slider is not None\n        slider.setValue(slider.maximum())\n        self._log.ensureCursorVisible()\n\n\ndef _split_off_leading_whitespace(text: str) -> tuple[str, str]:\n    stripped = text.lstrip()\n    whitespace = text[: len(text) - len(stripped)]\n    return whitespace, stripped\n"
  },
  {
    "path": "qt/aqt/deckbrowser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport html\nfrom copy import deepcopy\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport aqt\nimport aqt.operations\nfrom anki.collection import Collection, OpChanges\nfrom anki.decks import DeckCollapseScope, DeckId, DeckTreeNode\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.deckoptions import display_options_for_deck_id\nfrom aqt.operations import QueryOp\nfrom aqt.operations.deck import (\n    add_deck_dialog,\n    remove_decks,\n    rename_deck,\n    reparent_decks,\n    set_current_deck,\n    set_deck_collapsed,\n)\nfrom aqt.qt import *\nfrom aqt.sound import av_player\nfrom aqt.toolbar import BottomBar\nfrom aqt.utils import getOnlyText, openLink, shortcut, showInfo, tr\n\n\nclass DeckBrowserBottomBar:\n    def __init__(self, deck_browser: DeckBrowser) -> None:\n        self.deck_browser = deck_browser\n\n\n@dataclass\nclass RenderData:\n    \"\"\"Data from collection that is required to show the page.\"\"\"\n\n    tree: DeckTreeNode\n    current_deck_id: DeckId\n    studied_today: str\n    sched_upgrade_required: bool\n\n\n@dataclass\nclass DeckBrowserContent:\n    \"\"\"Stores sections of HTML content that the deck browser will be\n    populated with.\n\n    Attributes:\n        tree {str} -- HTML of the deck tree section\n        stats {str} -- HTML of the stats section\n    \"\"\"\n\n    tree: str\n    stats: str\n\n\n@dataclass\nclass RenderDeckNodeContext:\n    current_deck_id: DeckId\n\n\nclass DeckBrowser:\n    _render_data: RenderData\n\n    def __init__(self, mw: AnkiQt) -> None:\n        self.mw = mw\n        self.web = mw.web\n        self.bottom = BottomBar(mw, mw.bottomWeb)\n        self.scrollPos = QPoint(0, 0)\n        self._refresh_needed = False\n\n    def show(self) -> None:\n        av_player.stop_and_clear_queue()\n        self.web.set_bridge_command(self._linkHandler, self)\n        # redraw top bar for theme change\n        self.mw.toolbar.redraw()\n        self.refresh()\n\n    def refresh(self) -> None:\n        self._renderPage()\n        self._refresh_needed = False\n\n    def refresh_if_needed(self) -> None:\n        if self._refresh_needed:\n            self.refresh()\n\n    def op_executed(\n        self, changes: OpChanges, handler: object | None, focused: bool\n    ) -> bool:\n        if changes.study_queues and handler is not self:\n            self._refresh_needed = True\n\n        if focused:\n            self.refresh_if_needed()\n\n        return self._refresh_needed\n\n    # Event handlers\n    ##########################################################################\n\n    def _linkHandler(self, url: str) -> Any:\n        if \":\" in url:\n            (cmd, arg) = url.split(\":\", 1)\n        else:\n            cmd = url\n            arg = \"\"\n        if cmd == \"open\":\n            self.set_current_deck(DeckId(int(arg)))\n        elif cmd == \"opts\":\n            self._showOptions(arg)\n        elif cmd == \"shared\":\n            self._onShared()\n        elif cmd == \"import\":\n            self.mw.onImport()\n        elif cmd == \"create\":\n            self._on_create()\n        elif cmd == \"drag\":\n            source, target = arg.split(\",\")\n            self._handle_drag_and_drop(DeckId(int(source)), DeckId(int(target or 0)))\n        elif cmd == \"collapse\":\n            self._collapse(DeckId(int(arg)))\n        elif cmd == \"v2upgrade\":\n            self._confirm_upgrade()\n        elif cmd == \"v2upgradeinfo\":\n            if self.mw.col.sched_ver() == 1:\n                openLink(\"https://faqs.ankiweb.net/the-anki-2.1-scheduler.html\")\n            else:\n                openLink(\"https://faqs.ankiweb.net/the-2021-scheduler.html\")\n        elif cmd == \"select\":\n            set_current_deck(\n                parent=self.mw, deck_id=DeckId(int(arg))\n            ).run_in_background()\n        return False\n\n    def set_current_deck(self, deck_id: DeckId) -> None:\n        set_current_deck(parent=self.mw, deck_id=deck_id).success(\n            lambda _: self.mw.onOverview()\n        ).run_in_background(initiator=self)\n\n    # HTML generation\n    ##########################################################################\n\n    _body = \"\"\"\n<center>\n<table cellspacing=0 cellpadding=3>\n%(tree)s\n</table>\n\n<br>\n%(stats)s\n</center>\n\"\"\"\n\n    def _renderPage(self, reuse: bool = False) -> None:\n        if not reuse:\n\n            def get_data(col: Collection) -> RenderData:\n                return RenderData(\n                    tree=col.sched.deck_due_tree(),\n                    current_deck_id=col.decks.get_current_id(),\n                    studied_today=col.studied_today(),\n                    sched_upgrade_required=not col.v3_scheduler(),\n                )\n\n            def success(output: RenderData) -> None:\n                self._render_data = output\n                self.__renderPage(None)\n\n            QueryOp(\n                parent=self.mw,\n                op=get_data,\n                success=success,\n            ).run_in_background()\n        else:\n            self.web.evalWithCallback(\"window.pageYOffset\", self.__renderPage)\n\n    def __renderPage(self, offset: int | None) -> None:\n        data = self._render_data\n        content = DeckBrowserContent(\n            tree=self._renderDeckTree(data.tree),\n            stats=self._renderStats(),\n        )\n        gui_hooks.deck_browser_will_render_content(self, content)\n        self.web.stdHtml(\n            self._v1_upgrade_message(data.sched_upgrade_required)\n            + self._body % content.__dict__,\n            css=[\"css/deckbrowser.css\"],\n            js=[\n                \"js/vendor/jquery.min.js\",\n                \"js/vendor/jquery-ui.min.js\",\n                \"js/deckbrowser.js\",\n            ],\n            context=self,\n        )\n        self._drawButtons()\n        if offset is not None:\n            self._scrollToOffset(offset)\n        gui_hooks.deck_browser_did_render(self)\n\n    def _scrollToOffset(self, offset: int) -> None:\n        self.web.eval(\"window.scrollTo(0, %d, 'instant');\" % offset)\n\n    def _renderStats(self) -> str:\n        return '<div id=\"studiedToday\"><span>{}</span></div>'.format(\n            self._render_data.studied_today\n        )\n\n    def _renderDeckTree(self, top: DeckTreeNode) -> str:\n        buf = \"\"\"\n<tr><th colspan=5 align=start>{}</th>\n<th class=count>{}</th>\n<th class=count>{}</th>\n<th class=count>{}</th>\n<th class=optscol></th></tr>\"\"\".format(\n            tr.decks_deck(),\n            tr.actions_new(),\n            tr.decks_learn_header(),\n            tr.decks_review_header(),\n        )\n        buf += self._topLevelDragRow()\n\n        ctx = RenderDeckNodeContext(current_deck_id=self._render_data.current_deck_id)\n\n        for child in top.children:\n            buf += self._render_deck_node(child, ctx)\n\n        return buf\n\n    def _render_deck_node(self, node: DeckTreeNode, ctx: RenderDeckNodeContext) -> str:\n        if node.collapsed:\n            prefix = \"+\"\n        else:\n            prefix = \"−\"\n\n        def indent() -> str:\n            return \"&nbsp;\" * 6 * (node.level - 1)\n\n        if node.deck_id == ctx.current_deck_id:\n            klass = \"deck current\"\n        else:\n            klass = \"deck\"\n\n        buf = (\n            \"<tr class='%s' id='%d' onclick='if(event.shiftKey) return pycmd(\\\"select:%d\\\")'>\"\n            % (\n                klass,\n                node.deck_id,\n                node.deck_id,\n            )\n        )\n        # deck link\n        if node.children:\n            collapse = (\n                \"<a class=collapse href=# onclick='return pycmd(\\\"collapse:%d\\\")'>%s</a>\"\n                % (node.deck_id, prefix)\n            )\n        else:\n            collapse = \"<span class=collapse></span>\"\n        if node.filtered:\n            extraclass = \"filtered\"\n        else:\n            extraclass = \"\"\n        buf += \"\"\"\n\n        <td class=decktd colspan=5>%s%s<a class=\"deck %s\"\n        href=# onclick=\"return pycmd('open:%d')\">%s</a></td>\"\"\" % (\n            indent(),\n            collapse,\n            extraclass,\n            node.deck_id,\n            html.escape(node.name),\n        )\n\n        # due counts\n        def nonzeroColour(cnt: int, klass: str) -> str:\n            if not cnt:\n                klass = \"zero-count\"\n            return f'<span class=\"{klass}\">{cnt}</span>'\n\n        review = nonzeroColour(node.review_count, \"review-count\")\n        learn = nonzeroColour(node.learn_count, \"learn-count\")\n\n        buf += (\"<td align=end>%s</td>\" * 3) % (\n            nonzeroColour(node.new_count, \"new-count\"),\n            learn,\n            review,\n        )\n        # options\n        buf += (\n            \"<td align=center class=opts><a onclick='return pycmd(\\\"opts:%d\\\");'>\"\n            \"<img src='/_anki/imgs/gears.svg' class=gears></a></td></tr>\" % node.deck_id\n        )\n        # children\n        if not node.collapsed:\n            for child in node.children:\n                buf += self._render_deck_node(child, ctx)\n        return buf\n\n    def _topLevelDragRow(self) -> str:\n        return \"<tr class='top-level-drag-row'><td colspan='6'>&nbsp;</td></tr>\"\n\n    # Options\n    ##########################################################################\n\n    def _showOptions(self, did: str) -> None:\n        m = QMenu(self.mw)\n        a = m.addAction(tr.actions_rename())\n        assert a is not None\n        qconnect(a.triggered, lambda b, did=did: self._rename(DeckId(int(did))))\n        a = m.addAction(tr.actions_options())\n        assert a is not None\n        qconnect(a.triggered, lambda b, did=did: self._options(DeckId(int(did))))\n        a = m.addAction(tr.actions_export())\n        assert a is not None\n        qconnect(a.triggered, lambda b, did=did: self._export(DeckId(int(did))))\n        a = m.addAction(tr.actions_delete())\n        assert a is not None\n        qconnect(a.triggered, lambda b, did=did: self._delete(DeckId(int(did))))\n        gui_hooks.deck_browser_will_show_options_menu(m, int(did))\n        m.popup(QCursor.pos())\n\n    def _export(self, did: DeckId) -> None:\n        self.mw.onExport(did=did)\n\n    def _rename(self, did: DeckId) -> None:\n        def prompt(name: str) -> None:\n            new_name = getOnlyText(\n                tr.decks_new_deck_name(), default=name, title=tr.actions_rename()\n            )\n            if not new_name or new_name == name:\n                return\n            else:\n                rename_deck(\n                    parent=self.mw, deck_id=did, new_name=new_name\n                ).run_in_background()\n\n        QueryOp(\n            parent=self.mw, op=lambda col: col.decks.name(did), success=prompt\n        ).run_in_background()\n\n    def _options(self, did: DeckId) -> None:\n        display_options_for_deck_id(did)\n\n    def _collapse(self, did: DeckId) -> None:\n        node = self.mw.col.decks.find_deck_in_tree(self._render_data.tree, did)\n        if node:\n            node.collapsed = not node.collapsed\n            set_deck_collapsed(\n                parent=self.mw,\n                deck_id=did,\n                collapsed=node.collapsed,\n                scope=DeckCollapseScope.REVIEWER,\n            ).run_in_background()\n            self._renderPage(reuse=True)\n\n    def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None:\n        reparent_decks(\n            parent=self.mw, deck_ids=[source], new_parent=target\n        ).run_in_background()\n\n    def _delete(self, did: DeckId) -> None:\n        deck = self.mw.col.decks.find_deck_in_tree(self._render_data.tree, did)\n        assert deck is not None\n        deck_name = deck.name\n        remove_decks(\n            parent=self.mw, deck_ids=[did], deck_name=deck_name\n        ).run_in_background()\n\n    # Top buttons\n    ######################################################################\n\n    drawLinks = [\n        [\"\", \"shared\", tr.decks_get_shared()],\n        [\"\", \"create\", tr.decks_create_deck()],\n        [\"Ctrl+Shift+I\", \"import\", tr.decks_import_file()],\n    ]\n\n    def _drawButtons(self) -> None:\n        buf = \"\"\n        drawLinks = deepcopy(self.drawLinks)\n        for b in drawLinks:\n            if b[0]:\n                b[0] = tr.actions_shortcut_key(val=shortcut(b[0]))\n            buf += \"\"\"\n<button title='%s' onclick='pycmd(\\\"%s\\\");'>%s</button>\"\"\" % tuple(b)\n        self.bottom.draw(\n            buf=buf,\n            link_handler=self._linkHandler,\n            web_context=DeckBrowserBottomBar(self),\n        )\n\n    def _onShared(self) -> None:\n        openLink(f\"{aqt.appShared}decks/\")\n\n    def _on_create(self) -> None:\n        if op := add_deck_dialog(\n            parent=self.mw, default_text=self.mw.col.decks.current()[\"name\"]\n        ):\n            op.run_in_background()\n\n    ######################################################################\n\n    def _v1_upgrade_message(self, required: bool) -> str:\n        if not required:\n            return \"\"\n\n        update_required = tr.scheduling_update_required().replace(\"V2\", \"v3\")\n\n        return f\"\"\"\n<center>\n<div class=callout>\n    <div>\n      {update_required}\n    </div>\n    <div>\n      <button onclick='pycmd(\"v2upgrade\")'>\n        {tr.scheduling_update_button()}\n      </button>\n      <button onclick='pycmd(\"v2upgradeinfo\")'>\n        {tr.scheduling_update_more_info_button()}\n      </button>\n    </div>\n</div>\n</center>\n\"\"\"\n\n    def _confirm_upgrade(self) -> None:\n        if self.mw.col.sched_ver() == 1:\n            self.mw.col.mod_schema(check=True)\n            self.mw.col.upgrade_to_v2_scheduler()\n        self.mw.col.set_v3_scheduler(True)\n\n        showInfo(tr.scheduling_update_done())\n        self.refresh()\n"
  },
  {
    "path": "qt/aqt/deckchooser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nfrom anki.collection import OpChanges\nfrom anki.decks import DEFAULT_DECK_ID, DeckId\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.utils import HelpPage, shortcut, tr\n\n\nclass DeckChooser(QHBoxLayout):\n    def __init__(\n        self,\n        mw: AnkiQt,\n        widget: QWidget,\n        label: bool = True,\n        starting_deck_id: DeckId | None = None,\n        on_deck_changed: Callable[[int], None] | None = None,\n        dyn: bool = False,\n    ) -> None:\n        QHBoxLayout.__init__(self)\n        self._widget = widget  # type: ignore\n        self.mw = mw\n        self.dyn = dyn\n        self._setup_ui(show_label=label)\n\n        self._selected_deck_id = DeckId(0)\n        # default to current deck if starting id not provided\n        if starting_deck_id is None:\n            starting_deck_id = DeckId(self.mw.col.get_config(\"curDeck\", default=1) or 1)\n        self.selected_deck_id = starting_deck_id\n        self.on_deck_changed = on_deck_changed\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n\n    def _setup_ui(self, show_label: bool) -> None:\n        self.setContentsMargins(0, 0, 0, 0)\n        self.setSpacing(8)\n\n        # text label before button?\n        if show_label:\n            self.deckLabel = QLabel(tr.decks_deck())\n            self.addWidget(self.deckLabel)\n\n        # decks box\n        self.deck = QPushButton()\n        qconnect(self.deck.clicked, self.choose_deck)\n        self.deck.setAutoDefault(False)\n        self.deck.setToolTip(shortcut(tr.qt_misc_target_deck_ctrlandd()))\n        qconnect(\n            QShortcut(QKeySequence(\"Ctrl+D\"), self._widget).activated, self.choose_deck\n        )\n        sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0))\n        self.deck.setSizePolicy(sizePolicy)\n        self.addWidget(self.deck)\n\n        self._widget.setLayout(self)\n\n    def selected_deck_name(self) -> str:\n        return (\n            self.mw.col.decks.name_if_exists(self.selected_deck_id) or \"missing default\"\n        )\n\n    @property\n    def selected_deck_id(self) -> DeckId:\n        self._ensure_selected_deck_valid()\n\n        return self._selected_deck_id\n\n    @selected_deck_id.setter\n    def selected_deck_id(self, id: DeckId) -> None:\n        if id != self._selected_deck_id:\n            self._selected_deck_id = id\n            self._ensure_selected_deck_valid()\n            self._update_button_label()\n\n    def _ensure_selected_deck_valid(self) -> None:\n        deck = self.mw.col.decks.get(self._selected_deck_id, default=False)\n        if not deck or (not self.dyn and deck[\"dyn\"]):\n            self.selected_deck_id = DEFAULT_DECK_ID\n\n    def _update_button_label(self) -> None:\n        if not sip.isdeleted(self.deck):\n            self.deck.setText(self.selected_deck_name().replace(\"&\", \"&&\"))\n\n    def show(self) -> None:\n        self._widget.show()  # type: ignore\n\n    def hide(self) -> None:\n        self._widget.hide()  # type: ignore\n\n    def choose_deck(self) -> None:\n        from aqt.studydeck import StudyDeck\n\n        current = self.selected_deck_name()\n\n        def callback(ret: StudyDeck) -> None:\n            if not ret.name:\n                return\n            deck = self.mw.col.decks.by_name(ret.name)\n            assert deck is not None\n            new_selected_deck_id = deck[\"id\"]\n            if self.selected_deck_id != new_selected_deck_id:\n                self.selected_deck_id = new_selected_deck_id\n                if func := self.on_deck_changed:\n                    func(new_selected_deck_id)\n\n        StudyDeck(\n            self.mw,\n            current=current,\n            accept=tr.actions_choose(),\n            title=tr.qt_misc_choose_deck(),\n            help=HelpPage.EDITING,\n            cancel=True,\n            parent=self._widget,\n            geomKey=\"selectDeck\",\n            callback=callback,\n            dyn=self.dyn,\n        )\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        if changes.deck:\n            self._update_button_label()\n\n    def cleanup(self) -> None:\n        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)\n\n    # legacy\n\n    onDeckChange = choose_deck\n    deckName = selected_deck_name\n\n    def selectedId(self) -> DeckId:\n        return self.selected_deck_id\n"
  },
  {
    "path": "qt/aqt/deckconf.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom operator import itemgetter\nfrom typing import Any\n\nimport aqt\nimport aqt.forms\nfrom anki.consts import NEW_CARDS_RANDOM\nfrom anki.decks import DeckConfigDict\nfrom anki.lang import without_unicode_isolation\nfrom aqt import gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import (\n    HelpPage,\n    askUser,\n    disable_help_button,\n    getOnlyText,\n    openHelp,\n    restoreGeom,\n    saveGeom,\n    showInfo,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass DeckConf(QDialog):\n    def __init__(self, mw: aqt.AnkiQt, deck: dict) -> None:\n        QDialog.__init__(self, mw)\n        self.mw = mw\n        self.deck = deck\n        self.childDids = [d[1] for d in self.mw.col.decks.children(self.deck[\"id\"])]\n        self._origNewOrder = None\n        self.form = aqt.forms.dconf.Ui_Dialog()\n        self.form.setupUi(self)\n        gui_hooks.deck_conf_did_setup_ui_form(self)\n        self.setupCombos()\n        self.setupConfs()\n        qconnect(\n            self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.DECK_OPTIONS)\n        )\n        qconnect(self.form.confOpts.clicked, self.confOpts)\n        qconnect(\n            self.form.buttonBox.button(\n                QDialogButtonBox.StandardButton.RestoreDefaults\n            ).clicked,\n            self.onRestore,\n        )\n        self.setWindowTitle(\n            without_unicode_isolation(tr.actions_options_for(val=self.deck[\"name\"]))\n        )\n        disable_help_button(self)\n        # qt doesn't size properly with altered fonts otherwise\n        restoreGeom(self, \"deckconf\", adjustSize=True)\n        gui_hooks.deck_conf_will_show(self)\n        self.open()\n        saveGeom(self, \"deckconf\")\n\n    def setupCombos(self) -> None:\n        import anki.consts as cs\n\n        f = self.form\n        f.newOrder.addItems(list(cs.new_card_order_labels(self.mw.col).values()))\n        qconnect(f.newOrder.currentIndexChanged, self.onNewOrderChanged)\n\n    # Conf list\n    ######################################################################\n\n    def setupConfs(self) -> None:\n        qconnect(self.form.dconf.currentIndexChanged, self.onConfChange)\n        self.conf: DeckConfigDict | None = None\n        self.loadConfs()\n\n    def loadConfs(self) -> None:\n        current = self.deck[\"conf\"]\n        self.confList = self.mw.col.decks.all_config()\n        self.confList.sort(key=itemgetter(\"name\"))\n        startOn = 0\n        self.ignoreConfChange = True\n        self.form.dconf.clear()\n        for idx, conf in enumerate(self.confList):\n            self.form.dconf.addItem(conf[\"name\"])\n            if str(conf[\"id\"]) == str(current):\n                startOn = idx\n        self.ignoreConfChange = False\n        self.form.dconf.setCurrentIndex(startOn)\n        if self._origNewOrder is None:\n            self._origNewOrder = self.confList[startOn][\"new\"][\"order\"]\n        self.onConfChange(startOn)\n\n    def confOpts(self) -> None:\n        m = QMenu(self.mw)\n        a = m.addAction(tr.actions_add())\n        qconnect(a.triggered, self.addGroup)\n        a = m.addAction(tr.actions_delete())\n        qconnect(a.triggered, self.remGroup)\n        a = m.addAction(tr.actions_rename())\n        qconnect(a.triggered, self.renameGroup)\n        a = m.addAction(tr.scheduling_set_for_all_subdecks())\n        qconnect(a.triggered, self.setChildren)\n        if not self.childDids:\n            a.setEnabled(False)\n        m.exec(QCursor.pos())\n\n    def onConfChange(self, idx: int) -> None:\n        if self.ignoreConfChange:\n            return\n        if self.conf:\n            self.saveConf()\n        conf = self.confList[idx]\n        self.deck[\"conf\"] = conf[\"id\"]\n        self.mw.col.decks.save(self.deck)\n        self.loadConf()\n        cnt = len(self.mw.col.decks.decks_using_config(conf))\n        if cnt > 1:\n            txt = tr.scheduling_your_changes_will_affect_multiple_decks()\n        else:\n            txt = \"\"\n        self.form.count.setText(txt)\n\n    def addGroup(self) -> None:\n        name = getOnlyText(tr.scheduling_new_options_group_name())\n        if not name:\n            return\n\n        # first, save currently entered data to current conf\n        self.saveConf()\n        # then clone the conf\n        id = self.mw.col.decks.add_config_returning_id(name, clone_from=self.conf)\n        gui_hooks.deck_conf_did_add_config(self, self.deck, self.conf, name, id)\n        # set the deck to the new conf\n        self.deck[\"conf\"] = id\n        # then reload the conf list\n        self.loadConfs()\n\n    def remGroup(self) -> None:\n        if int(self.conf[\"id\"]) == 1:\n            showInfo(tr.scheduling_the_default_configuration_cant_be_removed(), self)\n        else:\n            gui_hooks.deck_conf_will_remove_config(self, self.deck, self.conf)\n            self.mw.col.mod_schema(check=True)\n            self.mw.col.decks.remove_config(self.conf[\"id\"])\n            self.conf = None\n            self.deck[\"conf\"] = 1\n            self.loadConfs()\n\n    def renameGroup(self) -> None:\n        old = self.conf[\"name\"]\n        name = getOnlyText(tr.actions_new_name(), default=old)\n        if not name or name == old:\n            return\n\n        gui_hooks.deck_conf_will_rename_config(self, self.deck, self.conf, name)\n        self.conf[\"name\"] = name\n        self.saveConf()\n        self.loadConfs()\n\n    def setChildren(self) -> None:\n        if not askUser(tr.scheduling_set_all_decks_below_to(val=self.deck[\"name\"])):\n            return\n        for did in self.childDids:\n            deck = self.mw.col.decks.get(did)\n            if deck[\"dyn\"]:\n                continue\n            deck[\"conf\"] = self.deck[\"conf\"]\n            self.mw.col.decks.save(deck)\n        tooltip(tr.scheduling_deck_updated(count=len(self.childDids)))\n\n    # Loading\n    ##################################################\n\n    def listToUser(self, l: list[int | float]) -> str:\n        def num_to_user(n: int | float) -> str:\n            if n == round(n):\n                return str(int(n))\n            else:\n                return str(n)\n\n        return \" \".join(map(num_to_user, l))\n\n    def parentLimText(self, type: str = \"new\") -> str:\n        # top level?\n        if \"::\" not in self.deck[\"name\"]:\n            return \"\"\n        lim = -1\n        for d in self.mw.col.decks.parents(self.deck[\"id\"]):\n            c = self.mw.col.decks.config_dict_for_deck_id(d[\"id\"])\n            x = c[type][\"perDay\"]\n            if lim == -1:\n                lim = x\n            else:\n                lim = min(x, lim)\n        return tr.scheduling_parent_limit(val=lim)\n\n    def loadConf(self) -> None:\n        self.conf = self.mw.col.decks.config_dict_for_deck_id(self.deck[\"id\"])\n        # new\n        c = self.conf[\"new\"]\n        f = self.form\n        f.lrnSteps.setText(self.listToUser(c[\"delays\"]))\n        f.lrnGradInt.setValue(c[\"ints\"][0])\n        f.lrnEasyInt.setValue(c[\"ints\"][1])\n        f.lrnFactor.setValue(int(c[\"initialFactor\"] / 10.0))\n        f.newOrder.setCurrentIndex(c[\"order\"])\n        f.newPerDay.setValue(c[\"perDay\"])\n        f.bury.setChecked(c.get(\"bury\", True))\n        f.newplim.setText(self.parentLimText(\"new\"))\n        # rev\n        c = self.conf[\"rev\"]\n        f.revPerDay.setValue(c[\"perDay\"])\n        f.easyBonus.setValue(int(c[\"ease4\"] * 100))\n        f.fi1.setValue(c[\"ivlFct\"] * 100)\n        f.maxIvl.setValue(c[\"maxIvl\"])\n        f.revplim.setText(self.parentLimText(\"rev\"))\n        f.buryRev.setChecked(c.get(\"bury\", True))\n        f.hardFactor.setValue(int(c.get(\"hardFactor\", 1.2) * 100))\n        # lapse\n        c = self.conf[\"lapse\"]\n        f.lapSteps.setText(self.listToUser(c[\"delays\"]))\n        f.lapMult.setValue(int(c[\"mult\"] * 100))\n        f.lapMinInt.setValue(c[\"minInt\"])\n        f.leechThreshold.setValue(c[\"leechFails\"])\n        f.leechAction.setCurrentIndex(c[\"leechAction\"])\n        # general\n        c = self.conf\n        f.maxTaken.setValue(c[\"maxTaken\"])\n        f.showTimer.setChecked(c.get(\"timer\", 0))\n        f.autoplaySounds.setChecked(c[\"autoplay\"])\n        f.replayQuestion.setChecked(c.get(\"replayq\", True))\n        gui_hooks.deck_conf_did_load_config(self, self.deck, self.conf)\n\n    def onRestore(self) -> None:\n        self.mw.progress.start()\n        self.mw.col.decks.restore_to_default(self.conf)\n        self.mw.progress.finish()\n        self.loadConf()\n\n    # New order\n    ##################################################\n\n    def onNewOrderChanged(self, new: bool) -> None:\n        old = self.conf[\"new\"][\"order\"]\n        if old == new:\n            return\n        self.conf[\"new\"][\"order\"] = new\n        self.mw.progress.start()\n        self.mw.col.sched.resort_conf(self.conf)\n        self.mw.progress.finish()\n\n    # Saving\n    ##################################################\n\n    def updateList(self, conf: Any, key: str, w: QLineEdit, minSize: int = 1) -> None:\n        items = str(w.text()).split(\" \")\n        ret = []\n        for item in items:\n            if not item:\n                continue\n            try:\n                i = float(item)\n                if i <= 0:\n                    raise Exception(\"0 invalid\")\n                if i == int(i):\n                    i = int(i)\n                ret.append(i)\n            except Exception:\n                # invalid, don't update\n                showWarning(tr.scheduling_steps_must_be_numbers())\n                return\n        if len(ret) < minSize:\n            showWarning(tr.scheduling_at_least_one_step_is_required())\n            return\n        conf[key] = ret\n\n    def saveConf(self) -> None:\n        # new\n        c = self.conf[\"new\"]\n        f = self.form\n        self.updateList(c, \"delays\", f.lrnSteps)\n        c[\"ints\"][0] = f.lrnGradInt.value()\n        c[\"ints\"][1] = f.lrnEasyInt.value()\n        c[\"initialFactor\"] = f.lrnFactor.value() * 10\n        c[\"order\"] = f.newOrder.currentIndex()\n        c[\"perDay\"] = f.newPerDay.value()\n        c[\"bury\"] = f.bury.isChecked()\n        if self._origNewOrder != c[\"order\"]:\n            # order of current deck has changed, so have to resort\n            if c[\"order\"] == NEW_CARDS_RANDOM:\n                self.mw.col.sched.randomize_cards(self.deck[\"id\"])\n            else:\n                self.mw.col.sched.order_cards(self.deck[\"id\"])\n        # rev\n        c = self.conf[\"rev\"]\n        c[\"perDay\"] = f.revPerDay.value()\n        c[\"ease4\"] = f.easyBonus.value() / 100.0\n        c[\"ivlFct\"] = f.fi1.value() / 100.0\n        c[\"maxIvl\"] = f.maxIvl.value()\n        c[\"bury\"] = f.buryRev.isChecked()\n        c[\"hardFactor\"] = f.hardFactor.value() / 100.0\n        # lapse\n        c = self.conf[\"lapse\"]\n        self.updateList(c, \"delays\", f.lapSteps, minSize=0)\n        c[\"mult\"] = f.lapMult.value() / 100.0\n        c[\"minInt\"] = f.lapMinInt.value()\n        c[\"leechFails\"] = f.leechThreshold.value()\n        c[\"leechAction\"] = f.leechAction.currentIndex()\n        # general\n        c = self.conf\n        c[\"maxTaken\"] = f.maxTaken.value()\n        c[\"timer\"] = f.showTimer.isChecked() and 1 or 0\n        c[\"autoplay\"] = f.autoplaySounds.isChecked()\n        c[\"replayq\"] = f.replayQuestion.isChecked()\n        gui_hooks.deck_conf_will_save_config(self, self.deck, self.conf)\n        self.mw.col.decks.save(self.deck)\n        self.mw.col.decks.save(self.conf)\n\n    def reject(self) -> None:\n        self.accept()\n\n    def accept(self) -> None:\n        self.saveConf()\n        self.mw.reset()\n        QDialog.accept(self)\n"
  },
  {
    "path": "qt/aqt/deckdescription.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.main\nimport aqt.operations\nfrom anki.decks import DeckDict\nfrom aqt.operations import QueryOp\nfrom aqt.operations.deck import update_deck_dict\nfrom aqt.qt import *\nfrom aqt.utils import disable_help_button, restoreGeom, saveGeom, tr\n\n\nclass DeckDescriptionDialog(QDialog):\n    TITLE = \"deckDescription\"\n    silentlyClose = True\n\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n\n        # set on success\n        self.deck: DeckDict\n\n        QueryOp(\n            parent=self.mw,\n            op=lambda col: col.decks.current(),\n            success=self._setup_and_show,\n        ).run_in_background()\n\n    def _setup_and_show(self, deck: DeckDict) -> None:\n        if deck[\"dyn\"]:\n            return\n\n        self.deck = deck\n        self._setup_ui()\n        self.show()\n\n    def _setup_ui(self) -> None:\n        self.setWindowTitle(tr.scheduling_description())\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.setMinimumWidth(400)\n        disable_help_button(self)\n        restoreGeom(self, self.TITLE)\n\n        box = QVBoxLayout()\n\n        self.enable_markdown = QCheckBox(tr.deck_config_description_new_handling())\n        self.enable_markdown.setToolTip(tr.deck_config_description_new_handling_hint())\n        self.enable_markdown.setChecked(self.deck.get(\"md\", False))\n        box.addWidget(self.enable_markdown)\n\n        self.description = QPlainTextEdit()\n        self.description.setPlainText(self.deck.get(\"desc\", \"\"))\n        box.addWidget(self.description)\n\n        button_box = QDialogButtonBox()\n        ok = button_box.addButton(QDialogButtonBox.StandardButton.Ok)\n        assert ok is not None\n        qconnect(ok.clicked, self.save_and_accept)\n        box.addWidget(button_box)\n\n        self.setLayout(box)\n        self.show()\n\n    def save_and_accept(self) -> None:\n        self.deck[\"desc\"] = self.description.toPlainText()\n        self.deck[\"md\"] = self.enable_markdown.isChecked()\n\n        update_deck_dict(parent=self, deck=self.deck).success(\n            lambda _: self.accept()\n        ).run_in_background()\n\n    def accept(self) -> None:\n        saveGeom(self, self.TITLE)\n        QDialog.accept(self)\n"
  },
  {
    "path": "qt/aqt/deckoptions.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.deckconf\nimport aqt.main\nfrom anki.cards import Card\nfrom anki.decks import DeckDict, DeckId\nfrom anki.lang import without_unicode_isolation\nfrom aqt import gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import (\n    KeyboardModifiersPressed,\n    disable_help_button,\n    restoreGeom,\n    saveGeom,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\nclass DeckOptionsDialog(QDialog):\n    \"The new deck configuration screen.\"\n\n    TITLE = \"deckOptions\"\n    silentlyClose = True\n\n    def __init__(self, mw: aqt.main.AnkiQt, deck: DeckDict) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n        self._deck = deck\n        self._close_event_has_cleaned_up = False\n        self._ready = False\n        self._setup_ui()\n\n    def _setup_ui(self) -> None:\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.setMinimumWidth(400)\n        disable_help_button(self)\n        restoreGeom(self, self.TITLE, default_size=(800, 800))\n\n        self.web = AnkiWebView(kind=AnkiWebViewKind.DECK_OPTIONS)\n        self.web.load_sveltekit_page(f\"deck-options/{self._deck['id']}\")\n        layout = QVBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.addWidget(self.web)\n        self.setLayout(layout)\n        self.show()\n        self.web.hide_while_preserving_layout()\n        self.setWindowTitle(\n            without_unicode_isolation(tr.actions_options_for(val=self._deck[\"name\"]))\n        )\n\n    def set_ready(self):\n        self._ready = True\n        gui_hooks.deck_options_did_load(self)\n\n    def closeEvent(self, evt: QCloseEvent | None) -> None:\n        if self._close_event_has_cleaned_up or not self._ready:\n            return super().closeEvent(evt)\n        assert evt is not None\n        evt.ignore()\n        self.web.eval(\"anki.deckOptionsPendingChanges();\")\n\n    def require_close(self):\n        \"\"\"Close. Ensure the closeEvent is not ignored.\"\"\"\n        self._close_event_has_cleaned_up = True\n        self.close()\n\n    def reject(self) -> None:\n        self.mw.col.set_wants_abort()\n        self.web.cleanup()\n        self.web = None  # type: ignore\n        saveGeom(self, self.TITLE)\n        QDialog.reject(self)\n\n\ndef confirm_deck_then_display_options(active_card: Card | None = None) -> None:\n    decks = [aqt.mw.col.decks.current()]\n    if card := active_card:\n        if card.odid and card.odid != decks[0][\"id\"]:\n            deck = aqt.mw.col.decks.get(card.odid)\n            assert deck is not None\n            decks.append(deck)\n\n        if not any(d[\"id\"] == card.did for d in decks):\n            deck = aqt.mw.col.decks.get(card.did)\n            assert deck is not None\n            decks.append(deck)\n\n    if len(decks) == 1:\n        display_options_for_deck(decks[0])\n    else:\n        decks.sort(key=lambda x: x[\"dyn\"])\n        _deck_prompt_dialog(decks)\n\n\ndef _deck_prompt_dialog(decks: list[DeckDict]) -> None:\n    diag = QDialog(aqt.mw.app.activeWindow())\n    diag.setWindowTitle(\"Anki\")\n    box = QVBoxLayout()\n    box.addWidget(QLabel(tr.deck_config_which_deck()))\n    for deck in decks:\n        button = QPushButton(deck[\"name\"])\n        qconnect(button.clicked, diag.close)\n        qconnect(button.clicked, lambda _, deck=deck: display_options_for_deck(deck))\n        box.addWidget(button)\n    button = QPushButton(tr.actions_cancel())\n    qconnect(button.clicked, diag.close)\n    box.addWidget(button)\n    diag.setLayout(box)\n    diag.open()\n\n\ndef display_options_for_deck_id(deck_id: DeckId) -> None:\n    deck = aqt.mw.col.decks.get(deck_id)\n    assert deck is not None\n    display_options_for_deck(deck)\n\n\ndef display_options_for_deck(deck: DeckDict) -> None:\n    if not deck[\"dyn\"]:\n        if KeyboardModifiersPressed().shift or not aqt.mw.col.v3_scheduler():\n            deck_legacy = aqt.mw.col.decks.get(DeckId(deck[\"id\"]))\n            assert deck_legacy is not None\n            aqt.deckconf.DeckConf(aqt.mw, deck_legacy)\n        else:\n            DeckOptionsDialog(aqt.mw, deck)\n    else:\n        aqt.dialogs.open(\"FilteredDeckConfigDialog\", aqt.mw, deck_id=deck[\"id\"])\n"
  },
  {
    "path": "qt/aqt/editcurrent.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nimport aqt.editor\nfrom anki.collection import OpChanges\nfrom anki.errors import NotFoundError\nfrom aqt import gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import restoreGeom, saveGeom, tr\n\n\nclass EditCurrent(QMainWindow):\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        super().__init__(None, Qt.WindowType.Window)\n        self.mw = mw\n        self.form = aqt.forms.editcurrent.Ui_Dialog()\n        self.form.setupUi(self)\n        self.setWindowTitle(tr.editing_edit_current())\n        self.setMinimumHeight(400)\n        self.setMinimumWidth(250)\n        if not is_mac:\n            self.setMenuBar(None)\n        self.editor = aqt.editor.Editor(\n            self.mw,\n            self.form.fieldsArea,\n            self,\n            editor_mode=aqt.editor.EditorMode.EDIT_CURRENT,\n        )\n        assert self.mw.reviewer.card is not None\n        self.editor.card = self.mw.reviewer.card\n        self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0)\n        restoreGeom(self, \"editcurrent\")\n        close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)\n        assert close_button is not None\n        close_button.setShortcut(QKeySequence(\"Ctrl+Return\"))\n        # qt5.14+ doesn't handle numpad enter on Windows\n        self.compat_add_shorcut = QShortcut(QKeySequence(\"Ctrl+Enter\"), self)\n        qconnect(self.compat_add_shorcut.activated, close_button.click)\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n        self.show()\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        if changes.note_text and handler is not self.editor:\n            # reload note\n            note = self.editor.note\n            try:\n                assert note is not None\n                note.load()\n            except NotFoundError:\n                # note's been deleted\n                self.cleanup()\n                self.close()\n                return\n\n            self.editor.set_note(note)\n\n    def cleanup(self) -> None:\n        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)\n        self.editor.cleanup()\n        saveGeom(self, \"editcurrent\")\n        aqt.dialogs.markClosed(\"EditCurrent\")\n\n    def reopen(self, mw: aqt.AnkiQt) -> None:\n        if card := self.mw.reviewer.card:\n            self.editor.card = card\n            self.editor.set_note(card.note())\n\n    def closeEvent(self, evt: QCloseEvent | None) -> None:\n        self.editor.call_after_note_saved(self.cleanup)\n\n    def _saveAndClose(self) -> None:\n        self.cleanup()\n        self.mw.deferred_delete_and_garbage_collect(self)\n        self.close()\n\n    def closeWithCallback(self, onsuccess: Callable[[], None]) -> None:\n        def callback() -> None:\n            self._saveAndClose()\n            onsuccess()\n\n        self.editor.call_after_note_saved(callback)\n\n    onReset = on_operation_did_execute\n"
  },
  {
    "path": "qt/aqt/editor.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport base64\nimport functools\nimport html\nimport itertools\nimport json\nimport mimetypes\nimport os\nimport re\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nimport warnings\nfrom collections.abc import Callable\nfrom enum import Enum\nfrom random import randrange\nfrom typing import Any, Iterable, Match, cast\n\nimport bs4\nimport requests\nfrom bs4 import BeautifulSoup\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nimport aqt.sound\nfrom anki._legacy import deprecated\nfrom anki.cards import Card\nfrom anki.collection import Config, SearchNode\nfrom anki.consts import MODEL_CLOZE\nfrom anki.hooks import runFilter\nfrom anki.httpclient import HttpClient\nfrom anki.models import NotetypeDict, NotetypeId, StockNotetype\nfrom anki.notes import Note, NoteFieldsCheckResult, NoteId\nfrom anki.utils import checksum, is_lin, is_win, namedtmp\nfrom aqt import AnkiQt, colors, gui_hooks\nfrom aqt.operations import QueryOp\nfrom aqt.operations.note import update_note\nfrom aqt.operations.notetype import update_notetype_legacy\nfrom aqt.qt import *\nfrom aqt.sound import av_player\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    HelpPage,\n    KeyboardModifiersPressed,\n    disable_help_button,\n    getFile,\n    openFolder,\n    openHelp,\n    qtMenuShortcutWorkaround,\n    restoreGeom,\n    saveGeom,\n    shortcut,\n    show_in_folder,\n    showInfo,\n    showWarning,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\npics = (\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"ico\", \"avif\")\naudio = (\n    \"3gp\",\n    \"aac\",\n    \"avi\",\n    \"flac\",\n    \"flv\",\n    \"m4a\",\n    \"mkv\",\n    \"mov\",\n    \"mp3\",\n    \"mp4\",\n    \"mpeg\",\n    \"mpg\",\n    \"oga\",\n    \"ogg\",\n    \"ogv\",\n    \"ogx\",\n    \"opus\",\n    \"spx\",\n    \"swf\",\n    \"wav\",\n    \"webm\",\n)\n\n\nclass EditorMode(Enum):\n    ADD_CARDS = 0\n    EDIT_CURRENT = 1\n    BROWSER = 2\n\n\nclass EditorState(Enum):\n    \"\"\"\n    Current input state of the editing UI.\n    \"\"\"\n\n    INITIAL = -1\n    FIELDS = 0\n    IO_PICKER = 1\n    IO_MASKS = 2\n    IO_FIELDS = 3\n\n\nclass Editor:\n    \"\"\"The screen that embeds an editing widget should listen for changes via\n    the `operation_did_execute` hook, and call set_note() when the editor needs\n    redrawing.\n\n    The editor will cause that hook to be fired when it saves changes. To avoid\n    an unwanted refresh, the parent widget should check if handler\n    corresponds to this editor instance, and ignore the change if it does.\n    \"\"\"\n\n    def __init__(\n        self,\n        mw: AnkiQt,\n        widget: QWidget,\n        parentWindow: QWidget,\n        addMode: bool | None = None,\n        *,\n        editor_mode: EditorMode = EditorMode.EDIT_CURRENT,\n    ) -> None:\n        self.mw = mw\n        self.widget = widget\n        self.parentWindow = parentWindow\n        self.note: Note | None = None\n        # legacy argument provided?\n        if addMode is not None:\n            editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT\n        self.addMode = editor_mode is EditorMode.ADD_CARDS\n        self.editorMode = editor_mode\n        self.currentField: int | None = None\n        # Similar to currentField, but not set to None on a blur. May be\n        # outside the bounds of the current notetype.\n        self.last_field_index: int | None = None\n        # used when creating a copy of an existing note\n        self.orig_note_id: NoteId | None = None\n        # current card, for card layout\n        self.card: Card | None = None\n        self.state: EditorState = EditorState.INITIAL\n        # used for the io mask editor's context menu\n        self.last_io_image_path: str | None = None\n        self._init_links()\n        self.setupOuter()\n        self.add_webview()\n        self.setupWeb()\n        self.setupShortcuts()\n        self.setupColourPalette()\n        gui_hooks.editor_did_init(self)\n\n    # Initial setup\n    ############################################################\n\n    def setupOuter(self) -> None:\n        l = QVBoxLayout()\n        l.setContentsMargins(0, 0, 0, 0)\n        l.setSpacing(0)\n        self.widget.setLayout(l)\n        self.outerLayout = l\n\n    def add_webview(self) -> None:\n        self.web = EditorWebView(self.widget, self)\n        self.web.set_bridge_command(self.onBridgeCmd, self)\n        self.outerLayout.addWidget(self.web, 1)\n\n    def setupWeb(self) -> None:\n        if self.editorMode == EditorMode.ADD_CARDS:\n            mode = \"add\"\n        elif self.editorMode == EditorMode.BROWSER:\n            mode = \"browse\"\n        else:\n            mode = \"review\"\n\n        # then load page\n        self.web.stdHtml(\n            \"\",\n            css=[\"css/editor.css\"],\n            js=[\n                \"js/mathjax.js\",\n                \"js/editor.js\",\n            ],\n            context=self,\n            default_css=False,\n        )\n        self.web.eval(f\"setupEditor('{mode}')\")\n        self.web.show()\n\n        lefttopbtns: list[str] = []\n        gui_hooks.editor_did_init_left_buttons(lefttopbtns, self)\n\n        lefttopbtns_defs = [\n            f\"uiPromise.then((noteEditor) => noteEditor.toolbar.notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));\"\n            for button in lefttopbtns\n        ]\n        lefttopbtns_js = \"\\n\".join(lefttopbtns_defs)\n\n        righttopbtns: list[str] = []\n        gui_hooks.editor_did_init_buttons(righttopbtns, self)\n        # legacy filter\n        righttopbtns = runFilter(\"setupEditorButtons\", righttopbtns, self)\n\n        righttopbtns_defs = \", \".join([json.dumps(button) for button in righttopbtns])\n        righttopbtns_js = (\n            f\"\"\"\nrequire(\"anki/ui\").loaded.then(() => require(\"anki/NoteEditor\").instances[0].toolbar.toolbar.append({{\n    component: editorToolbar.AddonButtons,\n    id: \"addons\",\n    props: {{ buttons: [ {righttopbtns_defs} ] }},\n}}));\n\"\"\"\n            if len(righttopbtns) > 0\n            else \"\"\n        )\n\n        self.web.eval(f\"{lefttopbtns_js} {righttopbtns_js}\")\n\n    # Top buttons\n    ######################################################################\n\n    def resourceToData(self, path: str) -> str:\n        \"\"\"Convert a file (specified by a path) into a data URI.\"\"\"\n        if not os.path.exists(path):\n            raise FileNotFoundError\n        mime, _ = mimetypes.guess_type(path)\n        with open(path, \"rb\") as fp:\n            data = fp.read()\n            data64 = b\"\".join(base64.encodebytes(data).splitlines())\n            return f\"data:{mime};base64,{data64.decode('ascii')}\"\n\n    def addButton(\n        self,\n        icon: str | None,\n        cmd: str,\n        func: Callable[[Editor], None],\n        tip: str = \"\",\n        label: str = \"\",\n        id: str | None = None,\n        toggleable: bool = False,\n        keys: str | None = None,\n        disables: bool = True,\n        rightside: bool = True,\n    ) -> str:\n        \"\"\"Assign func to bridge cmd, register shortcut, return button\"\"\"\n\n        def wrapped_func(editor: Editor) -> None:\n            self.call_after_note_saved(functools.partial(func, editor), keepFocus=True)\n\n        self._links[cmd] = wrapped_func\n\n        if keys:\n\n            def on_activated() -> None:\n                wrapped_func(self)\n\n            if toggleable:\n                # generate a random id for triggering toggle\n                id = id or str(randrange(1_000_000))\n\n                def on_hotkey() -> None:\n                    on_activated()\n                    self.web.eval(\n                        f'toggleEditorButton(document.getElementById(\"{id}\"));'\n                    )\n\n            else:\n                on_hotkey = on_activated\n\n            QShortcut(  # type: ignore\n                QKeySequence(keys),\n                self.widget,\n                activated=on_hotkey,\n            )\n\n        btn = self._addButton(\n            icon,\n            cmd,\n            tip=tip,\n            label=label,\n            id=id,\n            toggleable=toggleable,\n            disables=disables,\n            rightside=rightside,\n        )\n        return btn\n\n    def _addButton(\n        self,\n        icon: str | None,\n        cmd: str,\n        tip: str = \"\",\n        label: str = \"\",\n        id: str | None = None,\n        toggleable: bool = False,\n        disables: bool = True,\n        rightside: bool = True,\n    ) -> str:\n        title_attribute = tip\n\n        if icon:\n            if icon.startswith(\"qrc:/\"):\n                iconstr = icon\n            elif os.path.isabs(icon):\n                iconstr = self.resourceToData(icon)\n            else:\n                iconstr = f\"/_anki/imgs/{icon}.png\"\n            image_element = f'<img class=\"topbut\" src=\"{iconstr}\">'\n        else:\n            image_element = \"\"\n\n        if not label and icon:\n            label_element = \"\"\n        elif label:\n            label_element = label\n        else:\n            label_element = cmd\n\n        title_attribute = shortcut(title_attribute)\n        id_attribute_assignment = f\"id={id}\" if id else \"\"\n        class_attribute = \"linkb\" if rightside else \"rounded\"\n        if not disables:\n            class_attribute += \" perm\"\n\n        return f\"\"\"<button tabindex=-1\n                        {id_attribute_assignment}\n                        class=\"anki-addon-button {class_attribute}\"\n                        type=\"button\"\n                        title=\"{title_attribute}\"\n                        data-cantoggle=\"{int(toggleable)}\"\n                        data-command=\"{cmd}\"\n                >\n                    {image_element}\n                    {label_element}\n                </button>\"\"\"\n\n    def setupShortcuts(self) -> None:\n        # if a third element is provided, enable shortcut even when no field selected\n        cuts: list[tuple] = []\n        gui_hooks.editor_did_init_shortcuts(cuts, self)\n        for row in cuts:\n            if len(row) == 2:\n                keys, fn = row\n                fn = self._addFocusCheck(fn)\n            else:\n                keys, fn, _ = row\n            QShortcut(QKeySequence(keys), self.widget, activated=fn)  # type: ignore\n\n    def setupColourPalette(self) -> None:\n        if not (colors := self.mw.col.get_config(\"customColorPickerPalette\")):\n            return\n        for i, colour in enumerate(colors[: QColorDialog.customCount()]):\n            if not QColor.isValidColorName(colour):\n                continue\n            QColorDialog.setCustomColor(i, QColor.fromString(colour))\n\n    def _addFocusCheck(self, fn: Callable) -> Callable:\n        def checkFocus() -> None:\n            if self.currentField is None:\n                return\n            fn()\n\n        return checkFocus\n\n    def onFields(self) -> None:\n        self.call_after_note_saved(self._onFields)\n\n    def _onFields(self) -> None:\n        from aqt.fields import FieldDialog\n\n        FieldDialog(self.mw, self.note_type(), parent=self.parentWindow)\n\n    def onCardLayout(self) -> None:\n        self.call_after_note_saved(self._onCardLayout)\n\n    def _onCardLayout(self) -> None:\n        from aqt.clayout import CardLayout\n\n        if self.card:\n            ord = self.card.ord\n        else:\n            ord = 0\n\n        assert self.note is not None\n        CardLayout(\n            self.mw,\n            self.note,\n            ord=ord,\n            parent=self.parentWindow,\n            fill_empty=False,\n        )\n        if is_win:\n            self.parentWindow.activateWindow()\n\n    # JS->Python bridge\n    ######################################################################\n\n    def onBridgeCmd(self, cmd: str) -> Any:\n        if not self.note:\n            # shutdown\n            return\n\n        # focus lost or key/button pressed?\n        if cmd.startswith(\"blur\") or cmd.startswith(\"key\"):\n            (type, ord_str, nid_str, txt) = cmd.split(\":\", 3)\n            ord = int(ord_str)\n            try:\n                nid = int(nid_str)\n            except ValueError:\n                nid = 0\n            if nid != self.note.id:\n                print(\"ignored late blur\")\n                return\n\n            try:\n                self.note.fields[ord] = self.mungeHTML(txt)\n            except IndexError:\n                print(\"ignored late blur after notetype change\")\n                return\n\n            if not self.addMode:\n                self._save_current_note()\n            if type == \"blur\":\n                self.currentField = None\n                # run any filters\n                if gui_hooks.editor_did_unfocus_field(False, self.note, ord):\n                    # something updated the note; update it after a subsequent focus\n                    # event has had time to fire\n                    self.mw.progress.timer(\n                        100, self.loadNoteKeepingFocus, False, parent=self.widget\n                    )\n                else:\n                    self._check_and_update_duplicate_display_async()\n            else:\n                gui_hooks.editor_did_fire_typing_timer(self.note)\n                self._check_and_update_duplicate_display_async()\n\n        # focused into field?\n        elif cmd.startswith(\"focus\"):\n            (type, num) = cmd.split(\":\", 1)\n            self.last_field_index = self.currentField = int(num)\n            gui_hooks.editor_did_focus_field(self.note, self.currentField)\n\n        elif cmd.startswith(\"toggleStickyAll\"):\n            model = self.note_type()\n            flds = model[\"flds\"]\n\n            any_sticky = any([fld[\"sticky\"] for fld in flds])\n            result = []\n            for fld in flds:\n                if not any_sticky or fld[\"sticky\"]:\n                    fld[\"sticky\"] = not fld[\"sticky\"]\n\n                result.append(fld[\"sticky\"])\n\n            update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(\n                initiator=self\n            )\n\n            return result\n\n        elif cmd.startswith(\"toggleSticky\"):\n            (type, num) = cmd.split(\":\", 1)\n            ord = int(num)\n\n            model = self.note_type()\n            fld = model[\"flds\"][ord]\n            new_state = not fld[\"sticky\"]\n            fld[\"sticky\"] = new_state\n\n            update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(\n                initiator=self\n            )\n\n            return new_state\n\n        elif cmd.startswith(\"lastTextColor\"):\n            (_, textColor) = cmd.split(\":\", 1)\n            assert self.mw.pm.profile is not None\n            self.mw.pm.profile[\"lastTextColor\"] = textColor\n\n        elif cmd.startswith(\"lastHighlightColor\"):\n            (_, highlightColor) = cmd.split(\":\", 1)\n            assert self.mw.pm.profile is not None\n            self.mw.pm.profile[\"lastHighlightColor\"] = highlightColor\n\n        elif cmd.startswith(\"saveTags\"):\n            (type, tagsJson) = cmd.split(\":\", 1)\n            self.note.tags = json.loads(tagsJson)\n\n            gui_hooks.editor_did_update_tags(self.note)\n            if not self.addMode:\n                self._save_current_note()\n\n        elif cmd.startswith(\"setTagsCollapsed\"):\n            (type, collapsed_string) = cmd.split(\":\", 1)\n            collapsed = collapsed_string == \"true\"\n            self.setTagsCollapsed(collapsed)\n\n        elif cmd.startswith(\"editorState\"):\n            (_, new_state_id, old_state_id) = cmd.split(\":\", 2)\n            self.signal_state_change(\n                EditorState(int(new_state_id)), EditorState(int(old_state_id))\n            )\n\n        elif cmd.startswith(\"ioImageLoaded\"):\n            (_, path_or_nid_data) = cmd.split(\":\", 1)\n            path_or_nid = json.loads(path_or_nid_data)\n            if self.addMode:\n                gui_hooks.editor_mask_editor_did_load_image(self, path_or_nid)\n            else:\n                gui_hooks.editor_mask_editor_did_load_image(\n                    self, NoteId(int(path_or_nid))\n                )\n\n        elif cmd in self._links:\n            return self._links[cmd](self)\n\n        else:\n            print(\"uncaught cmd\", cmd)\n\n    def mungeHTML(self, txt: str) -> str:\n        return gui_hooks.editor_will_munge_html(txt, self)\n\n    def signal_state_change(\n        self, new_state: EditorState, old_state: EditorState\n    ) -> None:\n        self.state = new_state\n        gui_hooks.editor_state_did_change(self, new_state, old_state)\n\n    # Setting/unsetting the current note\n    ######################################################################\n\n    def set_note(\n        self,\n        note: Note | None,\n        hide: bool = True,\n        focusTo: int | None = None,\n    ) -> None:\n        \"Make NOTE the current note.\"\n        self.note = note\n        self.currentField = None\n        if self.note:\n            self.loadNote(focusTo=focusTo)\n        elif hide:\n            self.widget.hide()\n\n    def loadNoteKeepingFocus(self) -> None:\n        self.loadNote(self.currentField)\n\n    def loadNote(self, focusTo: int | None = None) -> None:\n        if not self.note:\n            return\n\n        data = [\n            (fld, self.mw.col.media.escape_media_filenames(val))\n            for fld, val in self.note.items()\n        ]\n\n        note_type = self.note_type()\n        flds = note_type[\"flds\"]\n        collapsed = [fld[\"collapsed\"] for fld in flds]\n        cloze_fields_ords = self.mw.col.models.cloze_fields(self.note.mid)\n        cloze_fields = [ord in cloze_fields_ords for ord in range(len(flds))]\n        plain_texts = [fld.get(\"plainText\", False) for fld in flds]\n        descriptions = [fld.get(\"description\", \"\") for fld in flds]\n        notetype_meta = {\"id\": self.note.mid, \"modTime\": note_type[\"mod\"]}\n\n        self.widget.show()\n\n        note_fields_status = self.note.fields_check()\n\n        def oncallback(arg: Any) -> None:\n            if not self.note:\n                return\n            self.setupForegroundButton()\n            # we currently do this synchronously to ensure we load before the\n            # sidebar on browser startup\n            self._update_duplicate_display(note_fields_status)\n            if focusTo is not None:\n                self.web.setFocus()\n            gui_hooks.editor_did_load_note(self)\n\n        assert self.mw.pm.profile is not None\n        text_color = self.mw.pm.profile.get(\"lastTextColor\", \"#0000ff\")\n        highlight_color = self.mw.pm.profile.get(\"lastHighlightColor\", \"#0000ff\")\n\n        js = f\"\"\"\n            saveSession();\n            setFields({json.dumps(data)});\n            setIsImageOcclusion({json.dumps(self.current_notetype_is_image_occlusion())});\n            setNotetypeMeta({json.dumps(notetype_meta)});\n            setCollapsed({json.dumps(collapsed)});\n            setClozeFields({json.dumps(cloze_fields)});\n            setPlainTexts({json.dumps(plain_texts)});\n            setDescriptions({json.dumps(descriptions)});\n            setFonts({json.dumps(self.fonts())});\n            focusField({json.dumps(focusTo)});\n            setNoteId({json.dumps(self.note.id)});\n            setColorButtons({json.dumps([text_color, highlight_color])});\n            setTags({json.dumps(self.note.tags)});\n            setTagsCollapsed({json.dumps(self.mw.pm.tags_collapsed(self.editorMode))});\n            setMathjaxEnabled({json.dumps(self.mw.col.get_config(\"renderMathjax\", True))});\n            setShrinkImages({json.dumps(self.mw.col.get_config(\"shrinkEditorImages\", True))});\n            setCloseHTMLTags({json.dumps(self.mw.col.get_config(\"closeHTMLTags\", True))});\n            triggerChanges();\n            \"\"\"\n\n        if self.addMode:\n            sticky = [field[\"sticky\"] for field in self.note_type()[\"flds\"]]\n            js += \" setSticky(%s);\" % json.dumps(sticky)\n\n        if self.current_notetype_is_image_occlusion():\n            io_field_indices = self.mw.backend.get_image_occlusion_fields(self.note.mid)\n            image_field = self.note.fields[io_field_indices.image]\n            self.last_io_image_path = self.extract_img_path_from_html(image_field)\n\n            if self.editorMode is not EditorMode.ADD_CARDS:\n                io_options = self._create_edit_io_options(note_id=self.note.id)\n                js += \" setupMaskEditor(%s);\" % json.dumps(io_options)\n            elif orig_note_id := self.orig_note_id:\n                self.orig_note_id = None\n                io_options = self._create_clone_io_options(orig_note_id)\n                js += \" setupMaskEditor(%s);\" % json.dumps(io_options)\n\n        js = gui_hooks.editor_will_load_note(js, self.note, self)\n        self.web.evalWithCallback(\n            f'require(\"anki/ui\").loaded.then(() => {{ {js} }})', oncallback\n        )\n\n    def _save_current_note(self) -> None:\n        \"Call after note is updated with data from webview.\"\n        if not self.note:\n            return\n\n        update_note(parent=self.widget, note=self.note).run_in_background(\n            initiator=self\n        )\n\n    def fonts(self) -> list[tuple[str, int, bool]]:\n        return [\n            (gui_hooks.editor_will_use_font_for_field(f[\"font\"]), f[\"size\"], f[\"rtl\"])\n            for f in self.note_type()[\"flds\"]\n        ]\n\n    def call_after_note_saved(\n        self, callback: Callable, keepFocus: bool = False\n    ) -> None:\n        \"Save unsaved edits then call callback().\"\n        if not self.note:\n            # calling code may not expect the callback to fire immediately\n            self.mw.progress.single_shot(10, callback)\n            return\n        self.web.evalWithCallback(\"saveNow(%d)\" % keepFocus, lambda res: callback())\n\n    saveNow = call_after_note_saved\n\n    def _check_and_update_duplicate_display_async(self) -> None:\n        note = self.note\n        if not note:\n            return\n\n        def on_done(result: NoteFieldsCheckResult.V) -> None:\n            if self.note != note:\n                return\n            self._update_duplicate_display(result)\n\n        QueryOp(\n            parent=self.parentWindow,\n            op=lambda _: note.fields_check(),\n            success=on_done,\n        ).run_in_background()\n\n    checkValid = _check_and_update_duplicate_display_async\n\n    def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None:\n        assert self.note is not None\n        cols = [\"\"] * len(self.note.fields)\n        cloze_hint = \"\"\n        if result == NoteFieldsCheckResult.DUPLICATE:\n            cols[0] = \"dupe\"\n        elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:\n            cloze_hint = tr.adding_cloze_outside_cloze_notetype()\n        elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:\n            cloze_hint = tr.adding_cloze_outside_cloze_field()\n\n        self.web.eval(\n            'require(\"anki/ui\").loaded.then(() => {'\n            f\"setBackgrounds({json.dumps(cols)});\\n\"\n            f\"setClozeHint({json.dumps(cloze_hint)});\\n\"\n            \"}); \"\n        )\n\n    def showDupes(self) -> None:\n        assert self.note is not None\n        aqt.dialogs.open(\n            \"Browser\",\n            self.mw,\n            search=(\n                SearchNode(\n                    dupe=SearchNode.Dupe(\n                        notetype_id=self.note_type()[\"id\"],\n                        first_field=self.note.fields[0],\n                    )\n                ),\n            ),\n        )\n\n    def fieldsAreBlank(self, previousNote: Note | None = None) -> bool:\n        if not self.note:\n            return True\n        m = self.note_type()\n        for c, f in enumerate(self.note.fields):\n            f = f.replace(\"<br>\", \"\").strip()\n            notChangedvalues = {\"\", \"<br>\"}\n            if previousNote and m[\"flds\"][c][\"sticky\"]:\n                notChangedvalues.add(previousNote.fields[c].replace(\"<br>\", \"\").strip())\n            if f not in notChangedvalues:\n                return False\n        return True\n\n    def cleanup(self) -> None:\n        av_player.stop_and_clear_queue_if_caller(self.editorMode)\n        self.set_note(None)\n        # prevent any remaining evalWithCallback() events from firing after C++ object deleted\n        if self.web:\n            self.web.cleanup()\n            self.web = None  # type: ignore\n\n    # legacy\n\n    setNote = set_note\n\n    # Tag handling\n    ######################################################################\n\n    def setupTags(self) -> None:\n        import aqt.tagedit\n\n        g = QGroupBox(self.widget)\n        g.setStyleSheet(\"border: 0\")\n        tb = QGridLayout()\n        tb.setSpacing(12)\n        tb.setContentsMargins(2, 6, 2, 6)\n        # tags\n        l = QLabel(tr.editing_tags())\n        tb.addWidget(l, 1, 0)\n        self.tags = aqt.tagedit.TagEdit(self.widget)\n        qconnect(self.tags.lostFocus, self.on_tag_focus_lost)\n        self.tags.setToolTip(shortcut(tr.editing_jump_to_tags_with_ctrlandshiftandt()))\n        border = theme_manager.var(colors.BORDER)\n        self.tags.setStyleSheet(f\"border: 1px solid {border}\")\n        tb.addWidget(self.tags, 1, 1)\n        g.setLayout(tb)\n        self.outerLayout.addWidget(g)\n\n    def updateTags(self) -> None:\n        if self.tags.col != self.mw.col:\n            self.tags.setCol(self.mw.col)\n        if not self.tags.text() or not self.addMode:\n            assert self.note is not None\n            self.tags.setText(self.note.string_tags().strip())\n\n    def on_tag_focus_lost(self) -> None:\n        assert self.note is not None\n        self.note.tags = self.mw.col.tags.split(self.tags.text())\n        gui_hooks.editor_did_update_tags(self.note)\n        if not self.addMode:\n            self._save_current_note()\n\n    def blur_tags_if_focused(self) -> None:\n        if not self.note:\n            return\n        if self.tags.hasFocus():\n            self.widget.setFocus()\n\n    def hideCompleters(self) -> None:\n        self.tags.hideCompleter()\n\n    def onFocusTags(self) -> None:\n        self.tags.setFocus()\n\n    # legacy\n\n    def saveAddModeVars(self) -> None:\n        pass\n\n    saveTags = blur_tags_if_focused\n\n    # Audio/video/images\n    ######################################################################\n\n    def onAddMedia(self) -> None:\n        \"\"\"Show a file selection screen, then add the selected media.\n        This expects initial setup to have been done by TemplateButtons.svelte.\"\"\"\n        extension_filter = \" \".join(\n            f\"*.{extension}\" for extension in sorted(itertools.chain(pics, audio))\n        )\n        filter = f\"{tr.editing_media()} ({extension_filter})\"\n\n        def accept(file: str) -> None:\n            self.resolve_media(file)\n\n        getFile(\n            parent=self.widget,\n            title=tr.editing_add_media(),\n            cb=cast(Callable[[Any], None], accept),\n            filter=filter,\n            key=\"media\",\n        )\n\n        self.parentWindow.activateWindow()\n\n    def addMedia(self, path: str, canDelete: bool = False) -> None:\n        \"\"\"Legacy routine used by add-ons to add a media file and update the current field.\n        canDelete is ignored.\"\"\"\n\n        try:\n            html = self._addMedia(path)\n        except Exception as e:\n            showWarning(str(e))\n            return\n\n        self.web.eval(f\"setFormat('inserthtml', {json.dumps(html)});\")\n\n    def resolve_media(self, path: str) -> None:\n        \"\"\"Finish inserting media into a field.\n        This expects initial setup to have been done by TemplateButtons.svelte.\"\"\"\n        try:\n            html = self._addMedia(path)\n        except Exception as e:\n            showWarning(str(e))\n            return\n\n        self.web.eval(\n            f'require(\"anki/TemplateButtons\").resolveMedia({json.dumps(html)})'\n        )\n\n    def _addMedia(self, path: str, canDelete: bool = False) -> str:\n        \"\"\"Add to media folder and return local img or sound tag.\"\"\"\n        # copy to media folder\n        fname = self.mw.col.media.add_file(path)\n        # return a local html link\n        return self.fnameToLink(fname)\n\n    def _addMediaFromData(self, fname: str, data: bytes) -> str:\n        return self.mw.col.media._legacy_write_data(fname, data)\n\n    def onRecSound(self) -> None:\n        aqt.sound.record_audio(\n            self.parentWindow,\n            self.mw,\n            True,\n            self.resolve_media,\n        )\n\n    # Media downloads\n    ######################################################################\n\n    def urlToLink(self, url: str, allowed_suffixes: Iterable[str] = ()) -> str:\n        fname = (\n            self.urlToFile(url, allowed_suffixes)\n            if allowed_suffixes\n            else self.urlToFile(url)\n        )\n        if not fname:\n            return '<a href=\"{}\">{}</a>'.format(\n                url, html.escape(urllib.parse.unquote(url))\n            )\n        return self.fnameToLink(fname)\n\n    def fnameToLink(self, fname: str) -> str:\n        ext = fname.split(\".\")[-1].lower()\n        if ext in pics:\n            name = urllib.parse.quote(fname.encode(\"utf8\"))\n            return f'<img src=\"{name}\">'\n        else:\n            av_player.play_file_with_caller(fname, self.editorMode)\n            return f\"[sound:{html.escape(fname, quote=False)}]\"\n\n    def urlToFile(\n        self, url: str, allowed_suffixes: Iterable[str] = pics + audio\n    ) -> str | None:\n        l = url.lower()\n        for suffix in allowed_suffixes:\n            if l.endswith(f\".{suffix}\"):\n                return self._retrieveURL(url)\n        # not a supported type\n        return None\n\n    def isURL(self, s: str) -> bool:\n        s = s.lower()\n        return (\n            s.startswith(\"http://\")\n            or s.startswith(\"https://\")\n            or s.startswith(\"ftp://\")\n            or s.startswith(\"file://\")\n        )\n\n    def inlinedImageToFilename(self, txt: str) -> str:\n        prefix = \"data:image/\"\n        suffix = \";base64,\"\n        for ext in (\"jpg\", \"jpeg\", \"png\", \"gif\"):\n            fullPrefix = prefix + ext + suffix\n            if txt.startswith(fullPrefix):\n                b64data = txt[len(fullPrefix) :].strip()\n                data = base64.b64decode(b64data, validate=True)\n                if ext == \"jpeg\":\n                    ext = \"jpg\"\n                return self._addPastedImage(data, ext)\n\n        return \"\"\n\n    def inlinedImageToLink(self, src: str) -> str:\n        fname = self.inlinedImageToFilename(src)\n        if fname:\n            return self.fnameToLink(fname)\n\n        return \"\"\n\n    def _pasted_image_filename(self, data: bytes, ext: str) -> str:\n        csum = checksum(data)\n        return f\"paste-{csum}.{ext}\"\n\n    def _read_pasted_image(self, mime: QMimeData) -> str:\n        image = QImage(mime.imageData())\n        buffer = QBuffer()\n        buffer.open(QBuffer.OpenModeFlag.ReadWrite)\n        if self.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG):\n            ext = \"png\"\n            quality = 50\n        else:\n            ext = \"jpg\"\n            quality = 80\n        image.save(buffer, ext, quality)\n        buffer.reset()\n        data = bytes(buffer.readAll())  # type: ignore\n        fname = self._pasted_image_filename(data, ext)\n        path = namedtmp(fname)\n        with open(path, \"wb\") as file:\n            file.write(data)\n\n        return path\n\n    def _addPastedImage(self, data: bytes, ext: str) -> str:\n        # hash and write\n        fname = self._pasted_image_filename(data, ext)\n        return self._addMediaFromData(fname, data)\n\n    def _retrieveURL(self, url: str) -> str | None:\n        \"Download file into media folder and return local filename or None.\"\n        local = url.lower().startswith(\"file://\")\n        # fetch it into a temporary folder\n        self.mw.progress.start(immediate=not local, parent=self.parentWindow)\n        content_type = None\n        error_msg: str | None = None\n        try:\n            if local:\n                # urllib doesn't understand percent-escaped utf8, but requires things like\n                # '#' to be escaped.\n                url = urllib.parse.unquote(url)\n                url = url.replace(\"%\", \"%25\")\n                url = url.replace(\"#\", \"%23\")\n                req = urllib.request.Request(\n                    url, None, {\"User-Agent\": \"Mozilla/5.0 (compatible; Anki)\"}\n                )\n                with urllib.request.urlopen(req) as response:\n                    filecontents = response.read()\n            else:\n                with HttpClient() as client:\n                    client.timeout = 30\n                    with client.get(url) as response:\n                        if response.status_code != 200:\n                            error_msg = tr.qt_misc_unexpected_response_code(\n                                val=response.status_code,\n                            )\n                            return None\n                        filecontents = response.content\n                        content_type = response.headers.get(\"content-type\")\n        except (urllib.error.URLError, requests.exceptions.RequestException) as e:\n            error_msg = tr.editing_an_error_occurred_while_opening(val=str(e))\n            return None\n        finally:\n            self.mw.progress.finish()\n            if error_msg:\n                showWarning(error_msg)\n        # strip off any query string\n        url = re.sub(r\"\\?.*?$\", \"\", url)\n        fname = os.path.basename(urllib.parse.unquote(url))\n        if not fname.strip():\n            fname = \"paste\"\n        if content_type:\n            fname = self.mw.col.media.add_extension_based_on_mime(fname, content_type)\n\n        return self.mw.col.media.write_data(fname, filecontents)\n\n    # Paste/drag&drop\n    ######################################################################\n\n    removeTags = [\"script\", \"iframe\", \"object\", \"style\"]\n\n    def _pastePreFilter(self, html: str, internal: bool) -> str:\n        # https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx\n        if html.find(\">\") < 0:\n            return html\n\n        with warnings.catch_warnings():\n            warnings.simplefilter(\"ignore\", UserWarning)\n            doc = BeautifulSoup(html, \"html.parser\")\n\n        if not internal:\n            for tag_name in self.removeTags:\n                for node in doc(tag_name):\n                    node.decompose()\n\n            # convert p tags to divs\n            for node in doc(\"p\"):\n                if hasattr(node, \"name\"):\n                    node.name = \"div\"\n\n        for element in doc(\"img\"):\n            if not isinstance(element, bs4.Tag):\n                continue\n            tag = element\n            try:\n                src = tag[\"src\"]\n            except KeyError:\n                # for some bizarre reason, mnemosyne removes src elements\n                # from missing media\n                continue\n\n            # in internal pastes, rewrite mediasrv references to relative\n            if internal:\n                m = re.match(r\"http://127.0.0.1:\\d+/(.*)$\", str(src))\n                if m:\n                    tag[\"src\"] = m.group(1)\n            # in external pastes, download remote media\n            elif isinstance(src, str) and self.isURL(src):\n                fname = self._retrieveURL(src)\n                if fname:\n                    tag[\"src\"] = fname\n            elif isinstance(src, str) and src.startswith(\"data:image/\"):\n                # and convert inlined data\n                tag[\"src\"] = self.inlinedImageToFilename(str(src))\n\n        html = str(doc)\n        return html\n\n    def doPaste(self, html: str, internal: bool, extended: bool = False) -> None:\n        html = self._pastePreFilter(html, internal)\n        if extended:\n            ext = \"true\"\n        else:\n            ext = \"false\"\n        self.web.eval(f\"pasteHTML({json.dumps(html)}, {json.dumps(internal)}, {ext});\")\n        gui_hooks.editor_did_paste(self, html, internal, extended)\n\n    def doDrop(\n        self, html: str, internal: bool, extended: bool, cursor_pos: QPoint\n    ) -> None:\n        def pasteIfField(ret: bool) -> None:\n            if ret:\n                self.doPaste(html, internal, extended)\n\n        zoom = self.web.zoomFactor()\n        x, y = int(cursor_pos.x() / zoom), int(cursor_pos.y() / zoom)\n\n        self.web.evalWithCallback(f\"focusIfField({x}, {y});\", pasteIfField)\n\n    def onPaste(self) -> None:\n        self.web.onPaste()\n\n    def onCutOrCopy(self) -> None:\n        self.web.user_cut_or_copied()\n\n    # Image occlusion\n    ######################################################################\n\n    def current_notetype_is_image_occlusion(self) -> bool:\n        if not self.note:\n            return False\n\n        return (\n            self.note_type().get(\"originalStockKind\", None)\n            == StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION\n        )\n\n    def setup_mask_editor(self, image_path: str) -> None:\n        try:\n            if self.editorMode == EditorMode.ADD_CARDS:\n                self.setup_mask_editor_for_new_note(\n                    image_path=image_path, notetype_id=0\n                )\n            else:\n                assert self.note is not None\n                self.setup_mask_editor_for_existing_note(\n                    note_id=self.note.id, image_path=image_path\n                )\n        except Exception as e:\n            showWarning(str(e))\n\n    def select_image_and_occlude(self) -> None:\n        \"\"\"Show a file selection screen, then get selected image path.\"\"\"\n        extension_filter = \" \".join(\n            f\"*.{extension}\" for extension in sorted(itertools.chain(pics))\n        )\n        filter = f\"{tr.editing_media()} ({extension_filter})\"\n\n        getFile(\n            parent=self.widget,\n            title=tr.editing_add_media(),\n            cb=cast(Callable[[Any], None], self.setup_mask_editor),\n            filter=filter,\n            key=\"media\",\n        )\n\n        self.parentWindow.activateWindow()\n\n    def extract_img_path_from_html(self, html: str) -> str | None:\n        assert self.note is not None\n        # with allowed_suffixes=pics, all non-pics will be rendered as <a>s and won't be included here\n        if not (images := self.mw.col.media.files_in_str(self.note.mid, html)):\n            return None\n        image_path = urllib.parse.unquote(images[0])\n        return os.path.join(self.mw.col.media.dir(), image_path)\n\n    def select_image_from_clipboard_and_occlude(self) -> None:\n        \"\"\"Set up the mask editor for the image in the clipboard.\"\"\"\n\n        clipboard = self.mw.app.clipboard()\n        assert clipboard is not None\n        mime = clipboard.mimeData()\n        assert mime is not None\n        # try checking for urls first, fallback to image data\n        if (\n            (html := self.web._processUrls(mime, allowed_suffixes=pics))\n            and (path := self.extract_img_path_from_html(html))\n        ) or (mime.hasImage() and (path := self._read_pasted_image(mime))):\n            self.setup_mask_editor(path)\n            self.parentWindow.activateWindow()\n        else:\n            showWarning(tr.editing_no_image_found_on_clipboard())\n            return\n\n    def setup_mask_editor_for_new_note(\n        self,\n        image_path: str,\n        notetype_id: NotetypeId | int = 0,\n    ):\n        \"\"\"Set-up IO mask editor for adding new notes\n        Presupposes that active editor notetype is an image occlusion notetype\n        Args:\n            image_path: Absolute path to image.\n            notetype_id: ID of note type to use. Provided ID must belong to an\n              image occlusion notetype. Set this to 0 to auto-select the first\n              found image occlusion notetype in the user's collection.\n        \"\"\"\n        image_field_html = self._addMedia(image_path)\n        self.last_io_image_path = self.extract_img_path_from_html(image_field_html)\n        io_options = self._create_add_io_options(\n            image_path=image_path,\n            image_field_html=image_field_html,\n            notetype_id=notetype_id,\n        )\n        self._setup_mask_editor(io_options)\n\n    def setup_mask_editor_for_existing_note(\n        self, note_id: NoteId, image_path: str | None = None\n    ):\n        \"\"\"Set-up IO mask editor for editing existing notes\n        Presupposes that active editor notetype is an image occlusion notetype\n        Args:\n            note_id: ID of note to edit.\n            image_path: (Optional) Absolute path to image that should replace current\n              image\n        \"\"\"\n        io_options = self._create_edit_io_options(note_id)\n        if image_path:\n            image_field_html = self._addMedia(image_path)\n            self.last_io_image_path = self.extract_img_path_from_html(image_field_html)\n            self.web.eval(f\"resetIOImage({json.dumps(image_path)})\")\n            self.web.eval(f\"setImageField({json.dumps(image_field_html)})\")\n        self._setup_mask_editor(io_options)\n\n    def reset_image_occlusion(self) -> None:\n        self.web.eval(\"resetIOImageLoaded()\")\n\n    def update_occlusions_field(self) -> None:\n        self.web.eval(\"saveOcclusions()\")\n\n    def _setup_mask_editor(self, io_options: dict):\n        self.web.eval(\n            'require(\"anki/ui\").loaded.then(() =>'\n            f\"setupMaskEditor({json.dumps(io_options)})\"\n            \"); \"\n        )\n\n    @staticmethod\n    def _create_add_io_options(\n        image_path: str, image_field_html: str, notetype_id: NotetypeId | int = 0\n    ) -> dict:\n        return {\n            \"mode\": {\"kind\": \"add\", \"imagePath\": image_path, \"notetypeId\": notetype_id},\n            \"html\": image_field_html,\n        }\n\n    @staticmethod\n    def _create_clone_io_options(orig_note_id: NoteId) -> dict:\n        return {\n            \"mode\": {\"kind\": \"add\", \"clonedNoteId\": orig_note_id},\n        }\n\n    @staticmethod\n    def _create_edit_io_options(note_id: NoteId) -> dict:\n        return {\"mode\": {\"kind\": \"edit\", \"noteId\": note_id}}\n\n    # Legacy editing routines\n    ######################################################################\n\n    _js_legacy = \"this routine has been moved into JS, and will be removed soon\"\n\n    @deprecated(info=_js_legacy)\n    def onHtmlEdit(self) -> None:\n        field = self.currentField\n        self.call_after_note_saved(lambda: self._onHtmlEdit(field))\n\n    @deprecated(info=_js_legacy)\n    def _onHtmlEdit(self, field: int) -> None:\n        assert self.note is not None\n        d = QDialog(self.widget, Qt.WindowType.Window)\n        form = aqt.forms.edithtml.Ui_Dialog()\n        form.setupUi(d)\n        restoreGeom(d, \"htmlEditor\")\n        disable_help_button(d)\n        qconnect(\n            form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES)\n        )\n        font = QFont(\"Courier\")\n        font.setStyleHint(QFont.StyleHint.TypeWriter)\n        form.textEdit.setFont(font)\n        form.textEdit.setPlainText(self.note.fields[field])\n        d.show()\n        form.textEdit.moveCursor(QTextCursor.MoveOperation.End)\n        d.exec()\n        html = form.textEdit.toPlainText()\n        if html.find(\">\") > -1:\n            # filter html through beautifulsoup so we can strip out things like a\n            # leading </div>\n            html_escaped = self.mw.col.media.escape_media_filenames(html)\n            with warnings.catch_warnings():\n                warnings.simplefilter(\"ignore\", UserWarning)\n                html_escaped = str(BeautifulSoup(html_escaped, \"html.parser\"))\n                html = self.mw.col.media.escape_media_filenames(\n                    html_escaped, unescape=True\n                )\n        self.note.fields[field] = html\n        if not self.addMode:\n            self._save_current_note()\n        self.loadNote(focusTo=field)\n        saveGeom(d, \"htmlEditor\")\n\n    @deprecated(info=_js_legacy)\n    def toggleBold(self) -> None:\n        self.web.eval(\"setFormat('bold');\")\n\n    @deprecated(info=_js_legacy)\n    def toggleItalic(self) -> None:\n        self.web.eval(\"setFormat('italic');\")\n\n    @deprecated(info=_js_legacy)\n    def toggleUnderline(self) -> None:\n        self.web.eval(\"setFormat('underline');\")\n\n    @deprecated(info=_js_legacy)\n    def toggleSuper(self) -> None:\n        self.web.eval(\"setFormat('superscript');\")\n\n    @deprecated(info=_js_legacy)\n    def toggleSub(self) -> None:\n        self.web.eval(\"setFormat('subscript');\")\n\n    @deprecated(info=_js_legacy)\n    def removeFormat(self) -> None:\n        self.web.eval(\"setFormat('removeFormat');\")\n\n    @deprecated(info=_js_legacy)\n    def onCloze(self) -> None:\n        self.call_after_note_saved(self._onCloze, keepFocus=True)\n\n    @deprecated(info=_js_legacy)\n    def _onCloze(self) -> None:\n        # check that the model is set up for cloze deletion\n        if self.note_type()[\"type\"] != MODEL_CLOZE:\n            if self.addMode:\n                tooltip(tr.editing_warning_cloze_deletions_will_not_work())\n            else:\n                showInfo(tr.editing_to_make_a_cloze_deletion_on())\n                return\n        # find the highest existing cloze\n        highest = 0\n        assert self.note is not None\n        for _, val in list(self.note.items()):\n            m = re.findall(r\"\\{\\{c(\\d+)::\", val)\n            if m:\n                highest = max(highest, sorted(int(x) for x in m)[-1])\n        # reuse last?\n        if not KeyboardModifiersPressed().alt:\n            highest += 1\n        # must start at 1\n        highest = max(1, highest)\n        self.web.eval(\"wrap('{{c%d::', '}}');\" % highest)\n\n    def setupForegroundButton(self) -> None:\n        assert self.mw.pm.profile is not None\n        self.fcolour = self.mw.pm.profile.get(\"lastColour\", \"#00f\")\n\n    # use last colour\n    @deprecated(info=_js_legacy)\n    def onForeground(self) -> None:\n        self._wrapWithColour(self.fcolour)\n\n    # choose new colour\n    @deprecated(info=_js_legacy)\n    def onChangeCol(self) -> None:\n        if is_lin:\n            new = QColorDialog.getColor(\n                QColor(self.fcolour),\n                None,\n                None,\n                QColorDialog.ColorDialogOption.DontUseNativeDialog,\n            )\n        else:\n            new = QColorDialog.getColor(QColor(self.fcolour), None)\n        # native dialog doesn't refocus us for some reason\n        self.parentWindow.activateWindow()\n        if new.isValid():\n            self.fcolour = new.name()\n            self.onColourChanged()\n            self._wrapWithColour(self.fcolour)\n\n    @deprecated(info=_js_legacy)\n    def _updateForegroundButton(self) -> None:\n        pass\n\n    @deprecated(info=_js_legacy)\n    def onColourChanged(self) -> None:\n        self._updateForegroundButton()\n        assert self.mw.pm.profile is not None\n        self.mw.pm.profile[\"lastColour\"] = self.fcolour\n\n    @deprecated(info=_js_legacy)\n    def _wrapWithColour(self, colour: str) -> None:\n        self.web.eval(f\"setFormat('forecolor', '{colour}')\")\n\n    @deprecated(info=_js_legacy)\n    def onAdvanced(self) -> None:\n        m = QMenu(self.mw)\n\n        for text, handler, shortcut in (\n            (tr.editing_mathjax_inline(), self.insertMathjaxInline, \"Ctrl+M, M\"),\n            (tr.editing_mathjax_block(), self.insertMathjaxBlock, \"Ctrl+M, E\"),\n            (\n                tr.editing_mathjax_chemistry(),\n                self.insertMathjaxChemistry,\n                \"Ctrl+M, C\",\n            ),\n            (tr.editing_latex(), self.insertLatex, \"Ctrl+T, T\"),\n            (tr.editing_latex_equation(), self.insertLatexEqn, \"Ctrl+T, E\"),\n            (tr.editing_latex_math_env(), self.insertLatexMathEnv, \"Ctrl+T, M\"),\n            (tr.editing_edit_html(), self.onHtmlEdit, \"Ctrl+Shift+X\"),\n        ):\n            a = m.addAction(text)\n            assert a is not None\n            qconnect(a.triggered, handler)\n            a.setShortcut(QKeySequence(shortcut))\n\n        qtMenuShortcutWorkaround(m)\n\n        m.exec(QCursor.pos())\n\n    @deprecated(info=_js_legacy)\n    def insertLatex(self) -> None:\n        self.web.eval(\"wrap('[latex]', '[/latex]');\")\n\n    @deprecated(info=_js_legacy)\n    def insertLatexEqn(self) -> None:\n        self.web.eval(\"wrap('[$]', '[/$]');\")\n\n    @deprecated(info=_js_legacy)\n    def insertLatexMathEnv(self) -> None:\n        self.web.eval(\"wrap('[$$]', '[/$$]');\")\n\n    @deprecated(info=_js_legacy)\n    def insertMathjaxInline(self) -> None:\n        self.web.eval(\"wrap('\\\\\\\\(', '\\\\\\\\)');\")\n\n    @deprecated(info=_js_legacy)\n    def insertMathjaxBlock(self) -> None:\n        self.web.eval(\"wrap('\\\\\\\\[', '\\\\\\\\]');\")\n\n    @deprecated(info=_js_legacy)\n    def insertMathjaxChemistry(self) -> None:\n        self.web.eval(\"wrap('\\\\\\\\(\\\\\\\\ce{', '}\\\\\\\\)');\")\n\n    def toggleMathjax(self) -> None:\n        self.mw.col.set_config(\n            \"renderMathjax\", not self.mw.col.get_config(\"renderMathjax\", False)\n        )\n        # hackily redraw the page\n        self.setupWeb()\n        self.loadNoteKeepingFocus()\n\n    def toggleShrinkImages(self) -> None:\n        self.mw.col.set_config(\n            \"shrinkEditorImages\",\n            not self.mw.col.get_config(\"shrinkEditorImages\", True),\n        )\n\n    def toggleCloseHTMLTags(self) -> None:\n        self.mw.col.set_config(\n            \"closeHTMLTags\",\n            not self.mw.col.get_config(\"closeHTMLTags\", True),\n        )\n\n    def setTagsCollapsed(self, collapsed: bool) -> None:\n        aqt.mw.pm.set_tags_collapsed(self.editorMode, collapsed)\n\n    # Links from HTML\n    ######################################################################\n\n    def _init_links(self) -> None:\n        self._links: dict[str, Callable] = dict(\n            fields=Editor.onFields,\n            cards=Editor.onCardLayout,\n            bold=Editor.toggleBold,\n            italic=Editor.toggleItalic,\n            underline=Editor.toggleUnderline,\n            super=Editor.toggleSuper,\n            sub=Editor.toggleSub,\n            clear=Editor.removeFormat,\n            colour=Editor.onForeground,\n            changeCol=Editor.onChangeCol,\n            cloze=Editor.onCloze,\n            attach=Editor.onAddMedia,\n            record=Editor.onRecSound,\n            more=Editor.onAdvanced,\n            dupes=Editor.showDupes,\n            paste=Editor.onPaste,\n            cutOrCopy=Editor.onCutOrCopy,\n            htmlEdit=Editor.onHtmlEdit,\n            mathjaxInline=Editor.insertMathjaxInline,\n            mathjaxBlock=Editor.insertMathjaxBlock,\n            mathjaxChemistry=Editor.insertMathjaxChemistry,\n            toggleMathjax=Editor.toggleMathjax,\n            toggleShrinkImages=Editor.toggleShrinkImages,\n            toggleCloseHTMLTags=Editor.toggleCloseHTMLTags,\n            addImageForOcclusion=Editor.select_image_and_occlude,\n            addImageForOcclusionFromClipboard=Editor.select_image_from_clipboard_and_occlude,\n        )\n\n    def note_type(self) -> NotetypeDict:\n        assert self.note is not None\n        note_type = self.note.note_type()\n        assert note_type is not None\n        return note_type\n\n\n# Pasting, drag & drop, and keyboard layouts\n######################################################################\n\n\nclass EditorWebView(AnkiWebView):\n    def __init__(self, parent: QWidget, editor: Editor) -> None:\n        AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR)\n        self.editor = editor\n        self.setAcceptDrops(True)\n        self._store_field_content_on_next_clipboard_change = False\n        # when we detect the user copying from a field, we store the content\n        # here, and use it when they paste, so we avoid filtering field content\n        self._internal_field_text_for_paste: str | None = None\n        self._last_known_clipboard_mime: QMimeData | None = None\n        clip = self.editor.mw.app.clipboard()\n        assert clip is not None\n        clip.dataChanged.connect(self._on_clipboard_change)\n        gui_hooks.editor_web_view_did_init(self)\n\n    def user_cut_or_copied(self) -> None:\n        self._store_field_content_on_next_clipboard_change = True\n        self._internal_field_text_for_paste = None\n\n    def _on_clipboard_change(\n        self, mode: QClipboard.Mode = QClipboard.Mode.Clipboard\n    ) -> None:\n        self._last_known_clipboard_mime = self._clipboard().mimeData(mode)\n        if self._store_field_content_on_next_clipboard_change:\n            # if the flag was set, save the field data\n            self._internal_field_text_for_paste = self._get_clipboard_html_for_field(\n                mode\n            )\n            self._store_field_content_on_next_clipboard_change = False\n        elif self._internal_field_text_for_paste != self._get_clipboard_html_for_field(\n            mode\n        ):\n            # if we've previously saved the field, blank it out if the clipboard state has changed\n            self._internal_field_text_for_paste = None\n\n    def _get_clipboard_html_for_field(self, mode: QClipboard.Mode) -> str | None:\n        clip = self._clipboard()\n        if not (mime := clip.mimeData(mode)):\n            return None\n        if not mime.hasHtml():\n            return None\n        return mime.html()\n\n    def onCut(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Cut)\n\n    def onCopy(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Copy)\n\n    def on_copy_image(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.CopyImageToClipboard)\n\n    def _opened_context_menu_on_image(self) -> bool:\n        if not hasattr(self, \"lastContextMenuRequest\"):\n            return False\n        context_menu_request = self.lastContextMenuRequest()\n        assert context_menu_request is not None\n        return (\n            context_menu_request.mediaType()\n            == context_menu_request.MediaType.MediaTypeImage\n        )\n\n    def _wantsExtendedPaste(self) -> bool:\n        strip_html = self.editor.mw.col.get_config_bool(\n            Config.Bool.PASTE_STRIPS_FORMATTING\n        )\n        if KeyboardModifiersPressed().shift:\n            strip_html = not strip_html\n        return not strip_html\n\n    def _onPaste(self, mode: QClipboard.Mode) -> None:\n        # Since _on_clipboard_change doesn't always trigger properly on macOS, we do a double check if any changes were made before pasting\n        clipboard = self._clipboard()\n        if self._last_known_clipboard_mime != clipboard.mimeData(mode):\n            self._on_clipboard_change(mode)\n        extended = self._wantsExtendedPaste()\n        if html := self._internal_field_text_for_paste:\n            print(\"reuse internal\")\n            self.editor.doPaste(html, True, extended)\n        else:\n            if not (mime := clipboard.mimeData(mode=mode)):\n                return\n            print(\"use clipboard\")\n            html, internal = self._processMime(mime, extended)\n            if html:\n                self.editor.doPaste(html, internal, extended)\n\n    def onPaste(self) -> None:\n        self._onPaste(QClipboard.Mode.Clipboard)\n\n    def onMiddleClickPaste(self) -> None:\n        self._onPaste(QClipboard.Mode.Selection)\n\n    def dragEnterEvent(self, evt: QDragEnterEvent | None) -> None:\n        assert evt is not None\n        evt.accept()\n\n    def dropEvent(self, evt: QDropEvent | None) -> None:\n        assert evt is not None\n        extended = self._wantsExtendedPaste()\n        mime = evt.mimeData()\n        assert mime is not None\n\n        if (\n            self.editor.state is EditorState.IO_PICKER\n            and (html := self._processUrls(mime, allowed_suffixes=pics))\n            and (path := self.editor.extract_img_path_from_html(html))\n        ):\n            self.editor.setup_mask_editor(path)\n            return\n\n        evt_pos = evt.position()\n        cursor_pos = QPoint(int(evt_pos.x()), int(evt_pos.y()))\n\n        if evt.source() and mime.hasHtml():\n            # don't filter html from other fields\n            html, internal = mime.html(), True\n        else:\n            html, internal = self._processMime(mime, extended, drop_event=True)\n\n        if not html:\n            return\n\n        self.editor.doDrop(html, internal, extended, cursor_pos)\n\n    # returns (html, isInternal)\n    def _processMime(\n        self, mime: QMimeData, extended: bool = False, drop_event: bool = False\n    ) -> tuple[str, bool]:\n        # print(\"html=%s image=%s urls=%s txt=%s\" % (\n        #     mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()))\n        # print(\"html\", mime.html())\n        # print(\"urls\", mime.urls())\n        # print(\"text\", mime.text())\n\n        internal = False\n\n        mime = gui_hooks.editor_will_process_mime(\n            mime, self, internal, extended, drop_event\n        )\n\n        # try various content types in turn\n        if mime.hasHtml():\n            html_content = mime.html()[11:] if internal else mime.html()\n            return html_content, internal\n\n        # given _processUrls' extra allowed_suffixes kwarg, placate the typechecker\n        def process_url(mime: QMimeData, extended: bool = False) -> str | None:\n            return self._processUrls(mime, extended)\n\n        # favour url if it's a local link\n        if (\n            mime.hasUrls()\n            and (urls := mime.urls())\n            and urls[0].toString().startswith(\"file://\")\n        ):\n            types = (process_url, self._processImage, self._processText)\n        else:\n            types = (self._processImage, process_url, self._processText)\n\n        for fn in types:\n            html = fn(mime, extended)\n            if html:\n                return html, True\n        return \"\", False\n\n    def _processUrls(\n        self,\n        mime: QMimeData,\n        extended: bool = False,\n        allowed_suffixes: Iterable[str] = (),\n    ) -> str | None:\n        if not mime.hasUrls():\n            return None\n\n        buf = \"\"\n        for qurl in mime.urls():\n            url = qurl.toString()\n            # chrome likes to give us the URL twice with a \\n\n            if lines := url.splitlines():\n                url = lines[0]\n                buf += self.editor.urlToLink(url, allowed_suffixes=allowed_suffixes)\n\n        return buf\n\n    def _processText(self, mime: QMimeData, extended: bool = False) -> str | None:\n        if not mime.hasText():\n            return None\n\n        txt = mime.text()\n        processed = []\n        lines = txt.split(\"\\n\")\n\n        for line in lines:\n            for token in re.split(r\"(\\S+)\", line):\n                # inlined data in base64?\n                if extended and token.startswith(\"data:image/\"):\n                    processed.append(self.editor.inlinedImageToLink(token))\n                elif extended and self.editor.isURL(token):\n                    # if the user is pasting an image or sound link, convert it to local, otherwise paste as a hyperlink\n                    link = self.editor.urlToLink(token)\n                    processed.append(link)\n                else:\n                    token = html.escape(token).replace(\"\\t\", \" \" * 4)\n\n                    # if there's more than one consecutive space,\n                    # use non-breaking spaces for the second one on\n                    def repl(match: Match) -> str:\n                        return f\"{match.group(1).replace(' ', '&nbsp;')} \"\n\n                    token = re.sub(\" ( +)\", repl, token)\n                    processed.append(token)\n\n            processed.append(\"<br>\")\n        # remove last <br>\n        processed.pop()\n        return \"\".join(processed)\n\n    def _processImage(self, mime: QMimeData, extended: bool = False) -> str | None:\n        if not mime.hasImage():\n            return None\n        path = self.editor._read_pasted_image(mime)\n        fname = self.editor._addMedia(path)\n\n        return fname\n\n    def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None:\n        m = QMenu(self)\n        if self.hasSelection():\n            self._add_cut_action(m)\n            self._add_copy_action(m)\n        a = m.addAction(tr.editing_paste())\n        assert a is not None\n        qconnect(a.triggered, self.onPaste)\n        if self.editor.state is EditorState.IO_MASKS and (\n            path := self.editor.last_io_image_path\n        ):\n            self._add_image_menu_with_path(m, path)\n        elif self._opened_context_menu_on_image():\n            self._add_image_menu(m)\n        gui_hooks.editor_will_show_context_menu(self, m)\n        m.popup(QCursor.pos())\n\n    def _add_cut_action(self, menu: QMenu) -> None:\n        a = menu.addAction(tr.editing_cut())\n        assert a is not None\n        qconnect(a.triggered, self.onCut)\n\n    def _add_copy_action(self, menu: QMenu) -> None:\n        a = menu.addAction(tr.actions_copy())\n        assert a is not None\n        qconnect(a.triggered, self.onCopy)\n\n    def _add_image_menu(self, menu: QMenu) -> None:\n        a = menu.addAction(tr.editing_copy_image())\n        assert a is not None\n        qconnect(a.triggered, self.on_copy_image)\n\n        context_menu_request = self.lastContextMenuRequest()\n        assert context_menu_request is not None\n        url = context_menu_request.mediaUrl()\n        file_name = url.fileName()\n        path = os.path.join(self.editor.mw.col.media.dir(), file_name)\n        self._add_image_menu_with_path(menu, path)\n\n    def _add_image_menu_with_path(self, menu: QMenu, path: str) -> None:\n        a = menu.addAction(tr.editing_open_image())\n        assert a is not None\n        qconnect(a.triggered, lambda: openFolder(path))\n\n        a = menu.addAction(tr.editing_show_in_folder())\n        assert a is not None\n        qconnect(a.triggered, lambda: show_in_folder(path))\n\n    def _clipboard(self) -> QClipboard:\n        clipboard = self.editor.mw.app.clipboard()\n        assert clipboard is not None\n        return clipboard\n\n\n# QFont returns \"Kozuka Gothic Pro L\" but WebEngine expects \"Kozuka Gothic Pro Light\"\n# - there may be other cases like a trailing 'Bold' that need fixing, but will\n# wait for further reports first.\ndef fontMungeHack(font: str) -> str:\n    return re.sub(\" L$\", \" Light\", font)\n\n\ndef munge_html(txt: str, editor: Editor) -> str:\n    return \"\" if txt in (\"<br>\", \"<div><br></div>\") else txt\n\n\ndef remove_null_bytes(txt: str, editor: Editor) -> str:\n    # misbehaving apps may include a null byte in the text\n    return txt.replace(\"\\x00\", \"\")\n\n\ndef reverse_url_quoting(txt: str, editor: Editor) -> str:\n    # reverse the url quoting we added to get images to display\n    return editor.mw.col.media.escape_media_filenames(txt, unescape=True)\n\n\ngui_hooks.editor_will_use_font_for_field.append(fontMungeHack)\ngui_hooks.editor_will_munge_html.append(munge_html)\ngui_hooks.editor_will_munge_html.append(remove_null_bytes)\ngui_hooks.editor_will_munge_html.append(reverse_url_quoting)\n\n\ndef set_cloze_button(editor: Editor) -> None:\n    action = \"show\" if editor.note_type()[\"type\"] == MODEL_CLOZE else \"hide\"\n    editor.web.eval(\n        'require(\"anki/ui\").loaded.then(() =>'\n        f'require(\"anki/NoteEditor\").instances[0].toolbar.toolbar.{action}(\"cloze\")'\n        \"); \"\n    )\n\n\ndef set_image_occlusion_button(editor: Editor) -> None:\n    action = \"show\" if editor.current_notetype_is_image_occlusion() else \"hide\"\n    editor.web.eval(\n        'require(\"anki/ui\").loaded.then(() =>'\n        f'require(\"anki/NoteEditor\").instances[0].toolbar.toolbar.{action}(\"image-occlusion-button\")'\n        \"); \"\n    )\n\n\ngui_hooks.editor_did_load_note.append(set_cloze_button)\ngui_hooks.editor_did_load_note.append(set_image_occlusion_button)\n"
  },
  {
    "path": "qt/aqt/emptycards.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport re\nfrom concurrent.futures import Future\nfrom typing import Any\n\nimport aqt\nimport aqt.forms\nimport aqt.main\nfrom anki.cards import CardId\nfrom anki.collection import EmptyCardsReport\nfrom aqt import gui_hooks\nfrom aqt.qt import QDialog, QDialogButtonBox, qconnect\nfrom aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip, tr\n\n\ndef show_empty_cards(mw: aqt.main.AnkiQt) -> None:\n    mw.progress.start()\n\n    def on_done(fut: Future) -> None:\n        mw.progress.finish()\n        report: EmptyCardsReport = fut.result()\n        if not report.notes:\n            tooltip(tr.empty_cards_not_found())\n            return\n        diag = EmptyCardsDialog(mw, report)\n        diag.show()\n\n    mw.taskman.run_in_background(mw.col.get_empty_cards, on_done)\n\n\nclass EmptyCardsDialog(QDialog):\n    silentlyClose = True\n\n    def __init__(self, mw: aqt.main.AnkiQt, report: EmptyCardsReport) -> None:\n        super().__init__(mw)\n        self.mw = mw\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.report = report\n        self.form = aqt.forms.emptycards.Ui_Dialog()\n        self.form.setupUi(self)\n        restoreGeom(self, \"emptycards\")\n        self.setWindowTitle(tr.empty_cards_window_title())\n        disable_help_button(self)\n        self.form.keep_notes.setText(tr.empty_cards_preserve_notes_checkbox())\n        self.form.webview.set_bridge_command(self._on_note_link_clicked, self)\n\n        gui_hooks.empty_cards_will_show(self)\n\n        # make the note ids clickable\n        html = re.sub(\n            r\"\\[anki:nid:(\\d+)\\]\",\n            \"<a href=# onclick=\\\"pycmd('nid:\\\\1'); return false\\\">\\\\1</a>: \",\n            report.report,\n        )\n        style = \"<style>.allempty { color: red; }</style>\"\n        self.form.webview.stdHtml(style + html, context=self)\n\n        def on_finished(code: Any) -> None:\n            self.form.webview.cleanup()\n            self.form.webview = None  # type: ignore\n            saveGeom(self, \"emptycards\")\n\n        qconnect(self.finished, on_finished)\n\n        self._delete_button = self.form.buttonBox.addButton(\n            tr.empty_cards_delete_button(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n        assert self._delete_button is not None\n        self._delete_button.setAutoDefault(False)\n        qconnect(self._delete_button.clicked, self._on_delete)\n\n    def _on_note_link_clicked(self, link: str) -> None:\n        aqt.dialogs.open(\"Browser\", self.mw, search=(link,))\n\n    def _on_delete(self) -> None:\n        self.mw.progress.start()\n\n        def delete() -> int:\n            return self._delete_cards(self.form.keep_notes.isChecked())\n\n        def on_done(fut: Future) -> None:\n            self.mw.progress.finish()\n            try:\n                count = fut.result()\n            finally:\n                self.close()\n            tooltip(tr.empty_cards_deleted_count(cards=count))\n            self.mw.reset()\n\n        self.mw.taskman.run_in_background(delete, on_done)\n\n    def _delete_cards(self, keep_notes: bool) -> int:\n        to_delete: list[CardId] = []\n        note: EmptyCardsReport.NoteWithEmptyCards\n        for note in self.report.notes:\n            if keep_notes and note.will_delete_note:\n                # leave first card\n                to_delete.extend([CardId(id) for id in note.card_ids[1:]])\n            else:\n                to_delete.extend([CardId(id) for id in note.card_ids])\n\n        self.mw.col.remove_cards_and_orphaned_notes(to_delete)\n        return len(to_delete)\n"
  },
  {
    "path": "qt/aqt/errors.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport sys\nimport time\nimport traceback\nfrom typing import TYPE_CHECKING, TextIO, cast\n\nfrom markdown import markdown\n\nimport aqt\nfrom anki.collection import HelpPage\nfrom anki.errors import BackendError, CardTypeError, Interrupted\nfrom anki.utils import is_win\nfrom aqt.addons import AddonManager, AddonMeta\nfrom aqt.qt import *\nfrom aqt.utils import openHelp, showWarning, supportText, tooltip, tr\n\nif TYPE_CHECKING:\n    from aqt.main import AnkiQt\n\n# so we can be non-modal/non-blocking, without Python deallocating the message\n# box ahead of time\n_mbox: QMessageBox | None = None\n\n\ndef show_exception(*, parent: QWidget, exception: Exception) -> None:\n    \"Present a caught exception to the user using a pop-up.\"\n    if isinstance(exception, Interrupted):\n        # nothing to do\n        return\n    global _mbox\n    error_lines = []\n    help_page = HelpPage.TROUBLESHOOTING\n\n    # default to PlainText\n    text_format = Qt.TextFormat.PlainText\n\n    # set CardTypeError messages as rich text to allow HTML formatting\n    if isinstance(exception, CardTypeError):\n        text_format = Qt.TextFormat.RichText\n\n    if isinstance(exception, BackendError):\n        if exception.context:\n            error_lines.append(exception.context)\n        if exception.backtrace:\n            error_lines.append(exception.backtrace)\n        if exception.help_page is not None:\n            help_page = exception.help_page\n    else:\n        # if the error is not originating from the backend, dump\n        # a traceback to the console to aid in debugging\n        error_lines = traceback.format_exception(\n            None, exception, exception.__traceback__\n        )\n    error_text = \"\\n\".join(error_lines)\n    print(error_lines)\n    _mbox = _init_message_box(str(exception), error_text, help_page, text_format)\n    _mbox.show()\n\n\ndef is_chromium_cert_error(error: str) -> bool:\n    \"\"\"QtWebEngine sometimes spits out 'unknown error' messages to stderr on Windows.\n\n    They appear to be IDS_SETTINGS_CERTIFICATE_MANAGER_UNKNOWN_ERROR in\n    chrome/browser/ui/webui/certificates_handler.cc. At a guess, it's the\n    NetErrorToString() method.\n\n    The constant appears to get converted to an ID; the resources are found\n    in files like this:\n\n    chrome/app/resources/generated_resources_fr-CA.xtb\n    2258:<translation id=\"3380365263193509176\">Erreur inconnue</translation>\n\n    List derived with:\n    qtwebengine-chromium% rg --no-heading --no-filename --no-line-number \\\n        3380365263193509176  | perl -pe 's/.*>(.*)<.*/\"$1\",/' | sort | uniq\n        \n    This list has been manually updated to add a different Japanese translation, as\n    the translations may change in different Chromium releases.\n\n    Judging by error reports, we can't assume the error falls on a separate line:\n    https://forums.ankiweb.net/t/topic/22036/\n    \"\"\"\n    if not is_win:\n        return False\n    for msg in (\n        \"알 수 없는 오류가 발생했습니다.\",\n        \"Bilinmeyen hata\",\n        \"Eroare necunoscută\",\n        \"Erreur inconnue\",\n        \"Erreur inconnue.\",\n        \"Erro descoñecido\",\n        \"Erro desconhecido\",\n        \"Error desconegut\",\n        \"Error desconocido\",\n        \"Errore ezezaguna\",\n        \"Errore sconosciuto\",\n        \"Gabim i panjohur\",\n        \"Hindi kilalang error\",\n        \"Hitilafu isiyojulikana\",\n        \"Iphutha elingaziwa\",\n        \"Ismeretlen hiba\",\n        \"Kesalahan tidak dikenal\",\n        \"Lỗi không xác định\",\n        \"Naməlum xəta\",\n        \"Nepoznata greška\",\n        \"Nepoznata pogreška\",\n        \"Nezināma kļūda\",\n        \"Nežinoma klaida\",\n        \"Neznáma chyba\",\n        \"Neznámá chyba\",\n        \"Neznana napaka\",\n        \"Nieznany błąd\",\n        \"Noma’lum xatolik\",\n        \"Okänt fel\",\n        \"Onbekende fout\",\n        \"Óþekkt villa\",\n        \"Ralat tidak diketahui\",\n        \"Tundmatu viga\",\n        \"Tuntematon virhe\",\n        \"Ukendt fejl\",\n        \"Ukjent feil\",\n        \"Unbekannter Fehler\",\n        \"Unknown error\",\n        \"Άγνωστο σφάλμα\",\n        \"Белгисиз ката\",\n        \"Белгісіз қате\",\n        \"Невідома помилка\",\n        \"Невядомая памылка\",\n        \"Неизвестна грешка\",\n        \"Неизвестная ошибка\",\n        \"Непозната грешка\",\n        \"Үл мэдэгдэх алдаа\",\n        \"უცნობი შეცდომა\",\n        \"Անհայտ սխալ\",\n        \"שגיאה לא ידועה\",\n        \"خطأ غير معروف\",\n        \"خطای ناشناس\",\n        \"نامعلوم خرابی\",\n        \"ያልታወቀ ስህተት\",\n        \"अज्ञात एरर\",\n        \"अज्ञात गड़बड़ी\",\n        \"अज्ञात त्रुटि\",\n        \"অজানা ত্রুটি\",\n        \"অজ্ঞাত আসোঁৱাহ\",\n        \"ਅਗਿਆਤ ਗੜਬੜ\",\n        \"અજ્ઞાત ભૂલ\",\n        \"ଅଜଣା ତୃଟି\",\n        \"அறியப்படாத பிழை\",\n        \"తెలియని ఎర్రర్\",\n        \"ಅಪರಿಚಿತ ದೋಷ\",\n        \"അജ്ഞാതമായ പിശക്\",\n        \"නොදන්නා දෝෂය\",\n        \"ข้อผิดพลาดที่ไม่รู้จัก\",\n        \"ຄວາມຜິດພາດທີ່ບໍ່ຮູ້ຈັກ\",\n        \"မသိရ အမှား\",\n        \"កំហុសឆ្គងមិនស្គាល់\",\n        \"不明なエラー\",\n        \"未知のエラー\",\n        \"未知的錯誤\",\n        \"未知错误\",\n    ):\n        if error.startswith(msg):\n            return True\n    return False\n\n\nif not os.environ.get(\"DEBUG\"):\n\n    def excepthook(etype, val, tb) -> None:  # type: ignore\n        sys.stderr.write(\"%s\\n\" % (\"\".join(traceback.format_exception(etype, val, tb))))\n\n    sys.excepthook = excepthook\n\n\ndef _init_message_box(\n    user_text: str,\n    debug_text: str,\n    help_page=HelpPage.TROUBLESHOOTING,\n    text_format=Qt.TextFormat.PlainText,\n):\n    global _mbox\n\n    _mbox = QMessageBox()\n    _mbox.setWindowTitle(\"Anki\")\n    _mbox.setText(user_text)\n    _mbox.setIcon(QMessageBox.Icon.Warning)\n    _mbox.setTextFormat(text_format)\n\n    def show_help():\n        openHelp(help_page)\n\n    def copy_debug_info():\n        QApplication.clipboard().setText(debug_text)\n        tooltip(tr.errors_copied_to_clipboard(), parent=_mbox)\n\n    help = _mbox.addButton(QMessageBox.StandardButton.Help)\n    if debug_text:\n        debug_info = _mbox.addButton(\n            tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole\n        )\n        debug_info.disconnect()\n        debug_info.clicked.connect(copy_debug_info)\n    cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel)\n    cancel.setText(tr.actions_close())\n\n    help.disconnect()\n    help.clicked.connect(show_help)\n\n    return _mbox\n\n\nclass ErrorHandler(QObject):\n    \"Catch stderr and write into buffer.\"\n\n    ivl = 100\n    fatal_error_encountered = False\n\n    errorTimer = pyqtSignal()\n\n    def __init__(self, mw: AnkiQt) -> None:\n        QObject.__init__(self, mw)\n        self.mw = mw\n        self.timer: QTimer | None = None\n        qconnect(self.errorTimer, self._setTimer)\n        self.pool = \"\"\n        self._oldstderr = sys.stderr\n        sys.stderr = cast(TextIO, self)\n\n    def unload(self) -> None:\n        sys.stderr = self._oldstderr\n        sys.excepthook = None\n\n    def write(self, data: str) -> None:\n        # dump to stdout\n        sys.stdout.write(data)\n        # save in buffer\n        self.pool += data\n        # and update timer\n        self.setTimer()\n\n    def setTimer(self) -> None:\n        # we can't create a timer from a different thread, so we post a\n        # message to the object on the main thread\n        self.errorTimer.emit()  # type: ignore\n\n    def _setTimer(self) -> None:\n        if not self.timer:\n            self.timer = QTimer(self.mw)\n            qconnect(self.timer.timeout, self.onTimeout)\n        self.timer.setInterval(self.ivl)\n        self.timer.setSingleShot(True)\n        self.timer.start()\n\n    def tempFolderMsg(self) -> str:\n        return tr.qt_misc_unable_to_access_anki_media_folder()\n\n    def onTimeout(self) -> None:\n        if self.fatal_error_encountered:\n            # suppress follow-up errors caused by the poisoned lock\n            return\n        error = self.pool\n        self.pool = \"\"\n        self.mw.progress.clear()\n        if \"AbortSchemaModification\" in error:\n            return\n        if \"DeprecationWarning\" in error:\n            return\n        if \"10013\" in error:\n            showWarning(tr.qt_misc_your_firewall_or_antivirus_program_is())\n            return\n        if \"invalidTempFolder\" in error:\n            showWarning(self.tempFolderMsg())\n            return\n        if \"Beautiful Soup is not an HTTP client\" in error:\n            return\n        if \"database or disk is full\" in error or \"Errno 28\" in error:\n            showWarning(tr.qt_misc_your_computers_storage_may_be_full())\n            return\n        if \"disk I/O error\" in error:\n            showWarning(markdown(tr.errors_accessing_db()))\n            return\n        if \"unable to get local issuer certificate\" in error and is_win:\n            showWarning(tr.errors_windows_ssl_updates())\n            return\n        if is_chromium_cert_error(error):\n            return\n\n        debug_text = supportText() + \"\\n\" + error\n\n        if \"PanicException\" in error:\n            self.fatal_error_encountered = True\n            # ensure no collection-related timers like backup fire\n            self.mw.col = None\n            user_text = \"A fatal error occurred, and Anki must close. Please report this message on the forums.\"\n        else:\n            user_text = tr.errors_standard_popup2()\n            if self.mw.addonManager.dirty:\n                user_text += \"\\n\\n\" + self._addonText(error)\n                debug_text += addon_debug_info()\n\n        _mbox = _init_message_box(user_text, debug_text)\n\n        if self.fatal_error_encountered:\n            _mbox.exec()\n            sys.exit(1)\n        else:\n            _mbox.show()\n\n    def _addonText(self, error: str) -> str:\n        matches = re.findall(r\"addons21(/|\\\\)(.*?)(/|\\\\)\", error)\n        if not matches:\n            return tr.errors_may_be_addon()\n        # reverse to list most likely suspect first, dict to deduplicate:\n        addons = [\n            aqt.mw.addonManager.addonName(i[1])\n            for i in dict.fromkeys(reversed(matches))\n        ]\n        addons_str = \", \".join(addons)\n        return tr.addons_possibly_involved(addons=addons_str)\n\n\ndef addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str:\n    installed = \"0\"\n    if addon.installed_at:\n        try:\n            installed = time.strftime(\n                \"%Y-%m-%dT%H:%M\", time.localtime(addon.installed_at)\n            )\n        except (OverflowError, OSError):\n            print(\"invalid timestamp for\", addon.provided_name)\n    if addon.provided_name:\n        name = addon.provided_name\n    else:\n        name = \"''\"\n    user = addmgr.getConfig(addon.dir_name)\n    default = addmgr.addonConfigDefaults(addon.dir_name)\n    if user == default:\n        modified = \"''\"\n    else:\n        modified = \"mod\"\n    return (\n        f\"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]\"\n    )\n\n\ndef addon_debug_info() -> str:\n    from aqt import mw\n\n    addmgr = mw.addonManager\n    active = []\n    activeids = []\n    inactive = []\n    for addon in addmgr.all_addon_meta():\n        if addon.enabled:\n            active.append(addon_fmt(addmgr, addon))\n            if addon.ankiweb_id():\n                activeids.append(addon.dir_name)\n        else:\n            inactive.append(addon_fmt(addmgr, addon))\n    newline = \"\\n\"\n    info = f\"\"\"\\\n===Add-ons (active)===\n(add-on provided name [Add-on folder, installed at, version, is config changed])\n{newline.join(sorted(active))}\n\n===IDs of active AnkiWeb add-ons===\n{\" \".join(activeids)}\n\n===Add-ons (inactive)===\n(add-on provided name [Add-on folder, installed at, version, is config changed])\n{newline.join(sorted(inactive))}\n\"\"\"\n    return info\n"
  },
  {
    "path": "qt/aqt/exporting.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport time\nfrom concurrent.futures import Future\n\nimport aqt\nimport aqt.forms\nimport aqt.main\nfrom anki import hooks\nfrom anki.cards import CardId\nfrom anki.decks import DeckId\nfrom anki.exporting import Exporter, exporters\nfrom aqt import gui_hooks\nfrom aqt.errors import show_exception\nfrom aqt.qt import *\nfrom aqt.utils import (\n    checkInvalidFilename,\n    disable_help_button,\n    getSaveFile,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass ExportDialog(QDialog):\n    def __init__(\n        self,\n        mw: aqt.main.AnkiQt,\n        did: DeckId | None = None,\n        cids: list[CardId] | None = None,\n        parent: QWidget | None = None,\n    ):\n        QDialog.__init__(self, parent or mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.col = mw.col.weakref()\n        self.frm = aqt.forms.exporting.Ui_ExportDialog()\n        self.frm.setupUi(self)\n        self.frm.legacy_support.setVisible(False)\n        self.exporter: Exporter | None = None\n        self.cids = cids\n        disable_help_button(self)\n        self.setup(did)\n        self.exec()\n\n    def setup(self, did: DeckId | None) -> None:\n        self.exporters = exporters(self.col)\n        # if a deck specified, start with .apkg type selected\n        idx = 0\n        if did or self.cids:\n            for c, (k, e) in enumerate(self.exporters):\n                if e.ext == \".apkg\":\n                    idx = c\n                    break\n        self.frm.format.insertItems(0, [e[0] for e in self.exporters])\n        self.frm.format.setCurrentIndex(idx)\n        qconnect(self.frm.format.activated, self.exporterChanged)\n        self.exporterChanged(idx)\n        # deck list\n        if self.cids is None:\n            self.decks = [tr.exporting_all_decks()]\n            self.decks.extend(d.name for d in self.col.decks.all_names_and_ids())\n        else:\n            self.decks = [tr.exporting_selected_notes()]\n        self.frm.deck.addItems(self.decks)\n        # save button\n        b = QPushButton(tr.exporting_export())\n        self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)\n        # set default option if accessed through deck button\n        if did:\n            name = self.mw.col.decks.get(did)[\"name\"]\n            index = self.frm.deck.findText(name)\n            self.frm.deck.setCurrentIndex(index)\n\n    def exporterChanged(self, idx: int) -> None:\n        self.exporter = self.exporters[idx][1](self.col)\n        self.isApkg = self.exporter.ext == \".apkg\"\n        self.isVerbatim = getattr(self.exporter, \"verbatim\", False)\n        self.isTextNote = getattr(self.exporter, \"includeTags\", False)\n        self.frm.includeSched.setVisible(\n            getattr(self.exporter, \"includeSched\", None) is not None\n        )\n        self.frm.includeMedia.setVisible(\n            getattr(self.exporter, \"includeMedia\", None) is not None\n        )\n        self.frm.includeTags.setVisible(\n            getattr(self.exporter, \"includeTags\", None) is not None\n        )\n        html = getattr(self.exporter, \"includeHTML\", None)\n        if html is not None:\n            self.frm.includeHTML.setVisible(True)\n            self.frm.includeHTML.setChecked(html)\n        else:\n            self.frm.includeHTML.setVisible(False)\n        # show deck list?\n        self.frm.deck.setVisible(not self.isVerbatim)\n        # used by the new export screen\n        self.frm.includeDeck.setVisible(False)\n        self.frm.includeNotetype.setVisible(False)\n        self.frm.includeGuid.setVisible(False)\n\n    def accept(self) -> None:\n        self.exporter.includeSched = self.frm.includeSched.isChecked()\n        self.exporter.includeMedia = self.frm.includeMedia.isChecked()\n        self.exporter.includeTags = self.frm.includeTags.isChecked()\n        self.exporter.includeHTML = self.frm.includeHTML.isChecked()\n        idx = self.frm.deck.currentIndex()\n        if self.cids is not None:\n            # Browser Selection\n            self.exporter.cids = self.cids\n            self.exporter.did = None\n        elif idx == 0:\n            # All decks\n            self.exporter.did = None\n            self.exporter.cids = None\n        else:\n            # Deck idx-1 in the list of decks\n            self.exporter.cids = None\n            name = self.decks[self.frm.deck.currentIndex()]\n            self.exporter.did = self.col.decks.id(name)\n        if self.isVerbatim:\n            name = time.strftime(\"-%Y-%m-%d@%H-%M-%S\", time.localtime(time.time()))\n            deck_name = tr.exporting_collection() + name\n        else:\n            # Get deck name and remove invalid filename characters\n            deck_name = self.decks[self.frm.deck.currentIndex()]\n            deck_name = re.sub('[\\\\\\\\/?<>:*|\"^]', \"_\", deck_name)\n\n        filename = f\"{deck_name}{self.exporter.ext}\"\n        if callable(self.exporter.key):\n            key_str = self.exporter.key(self.col)\n        else:\n            key_str = self.exporter.key\n        while 1:\n            file = getSaveFile(\n                self,\n                tr.actions_export(),\n                \"export\",\n                key_str,\n                self.exporter.ext,\n                fname=filename,\n            )\n            if not file:\n                return\n            if checkInvalidFilename(os.path.basename(file), dirsep=False):\n                continue\n            file = os.path.normpath(file)\n            if os.path.commonprefix([self.mw.pm.base, file]) == self.mw.pm.base:\n                showWarning(\"Please choose a different export location.\")\n                continue\n            break\n        self.hide()\n        if file:\n            # check we can write to file\n            try:\n                f = open(file, \"wb\")\n                f.close()\n            except OSError as e:\n                showWarning(tr.exporting_couldnt_save_file(val=str(e)))\n            else:\n                os.unlink(file)\n\n            # progress handler: old apkg exporter\n            def exported_media_count(cnt: int) -> None:\n                self.mw.taskman.run_on_main(\n                    lambda: self.mw.progress.update(\n                        label=tr.exporting_exported_media_file(count=cnt)\n                    )\n                )\n\n            # progress handler: adaptor for new colpkg importer into old exporting screen.\n            # don't rename this; there's a hack in pylib/exporting.py that assumes this\n            # name\n            def exported_media(progress: str) -> None:\n                self.mw.taskman.run_on_main(\n                    lambda: self.mw.progress.update(label=progress)\n                )\n\n            def do_export() -> None:\n                self.exporter.exportInto(file)\n\n            def on_done(future: Future) -> None:\n                self.mw.progress.finish()\n                hooks.media_files_did_export.remove(exported_media_count)\n                hooks.legacy_export_progress.remove(exported_media)\n                try:\n                    # raises if exporter failed\n                    future.result()\n                except Exception as exc:\n                    show_exception(parent=self.mw, exception=exc)\n                    self.on_export_failed()\n                else:\n                    self.on_export_finished()\n\n            gui_hooks.legacy_exporter_will_export(self.exporter)\n            if self.isVerbatim:\n                gui_hooks.collection_will_temporarily_close(self.mw.col)\n            self.mw.progress.start()\n            hooks.media_files_did_export.append(exported_media_count)\n            hooks.legacy_export_progress.append(exported_media)\n\n            self.mw.taskman.run_in_background(do_export, on_done)\n\n    def on_export_finished(self) -> None:\n        if self.isVerbatim:\n            msg = tr.exporting_collection_exported()\n            self.mw.reopen()\n        elif self.isTextNote:\n            msg = tr.exporting_note_exported(count=self.exporter.count)\n        else:\n            msg = tr.exporting_card_exported(count=self.exporter.count)\n        gui_hooks.legacy_exporter_did_export(self.exporter)\n        tooltip(msg, period=3000)\n        QDialog.reject(self)\n\n    def on_export_failed(self) -> None:\n        if self.isVerbatim:\n            self.mw.reopen()\n        QDialog.reject(self)\n"
  },
  {
    "path": "qt/aqt/fields.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.collection import OpChanges\nfrom anki.lang import without_unicode_isolation\nfrom anki.models import NotetypeDict\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.operations.notetype import update_notetype_legacy\nfrom aqt.qt import *\nfrom aqt.schema_change_tracker import ChangeTracker\nfrom aqt.utils import (\n    HelpPage,\n    askUser,\n    disable_help_button,\n    getOnlyText,\n    openHelp,\n    show_warning,\n    tooltip,\n    tr,\n)\n\n\nclass FieldDialog(QDialog):\n    def __init__(\n        self,\n        mw: AnkiQt,\n        nt: NotetypeDict,\n        parent: QWidget | None = None,\n        open_at: int = 0,\n    ) -> None:\n        QDialog.__init__(self, parent or mw)\n        mw.garbage_collect_on_dialog_finish(self)\n        self.mw = mw\n        self.col = self.mw.col\n        self.mm = self.mw.col.models\n        self.model = nt\n        self.mm._remove_from_cache(self.model[\"id\"])\n        self.change_tracker = ChangeTracker(self.mw)\n        self.webview = None\n\n        self.form = aqt.forms.fields.Ui_Dialog()\n        self.form.setupUi(self)\n\n        self.setWindowTitle(\n            without_unicode_isolation(tr.fields_fields_for(val=self.model[\"name\"]))\n        )\n\n        disable_help_button(self)\n        help_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help)\n        assert help_button is not None\n        help_button.setAutoDefault(False)\n\n        cancel_button = self.form.buttonBox.button(\n            QDialogButtonBox.StandardButton.Cancel\n        )\n        assert cancel_button is not None\n        cancel_button.setAutoDefault(False)\n\n        save_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Save)\n        assert save_button is not None\n        save_button.setAutoDefault(False)\n\n        self.currentIdx: int | None = None\n        self.fillFields()\n        self.setupSignals()\n        self.form.fieldList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)\n        self.form.fieldList.dropEvent = self.onDrop  # type: ignore[assignment]\n        self.form.fieldList.setCurrentRow(open_at)\n        self.exec()\n\n    def _on_bridge_cmd(self, cmd: str) -> bool:\n        return False\n\n    ##########################################################################\n\n    def fillFields(self) -> None:\n        self.currentIdx = None\n        self.form.fieldList.clear()\n        for c, f in enumerate(self.model[\"flds\"]):\n            self.form.fieldList.addItem(f\"{c + 1}: {f['name']}\")\n\n    def setupSignals(self) -> None:\n        f = self.form\n        qconnect(f.fieldList.currentRowChanged, self.onRowChange)\n        qconnect(f.fieldAdd.clicked, self.onAdd)\n        qconnect(f.fieldDelete.clicked, self.onDelete)\n        qconnect(f.fieldRename.clicked, self.onRename)\n        qconnect(f.fieldPosition.clicked, self.onPosition)\n        qconnect(f.sortField.clicked, self.onSortField)\n        qconnect(f.buttonBox.helpRequested, self.onHelp)\n\n    def onDrop(self, ev: QDropEvent) -> None:\n        fieldList = self.form.fieldList\n        indicatorPos = fieldList.dropIndicatorPosition()\n        if qtmajor == 5:\n            pos = ev.pos()  # type: ignore\n        else:\n            pos = ev.position().toPoint()\n        dropPos = fieldList.indexAt(pos).row()\n        idx = self.currentIdx\n        if dropPos == idx:\n            return\n        if (\n            indicatorPos == QAbstractItemView.DropIndicatorPosition.OnViewport\n        ):  # to bottom.\n            movePos = fieldList.count() - 1\n        elif indicatorPos == QAbstractItemView.DropIndicatorPosition.AboveItem:\n            movePos = dropPos\n        elif indicatorPos == QAbstractItemView.DropIndicatorPosition.BelowItem:\n            movePos = dropPos + 1\n        else:\n            # for pylint\n            return\n        # the item in idx is removed thus subtract 1.\n        assert idx is not None\n        if idx < dropPos:\n            movePos -= 1\n        self.moveField(movePos + 1)  # convert to 1 based.\n\n    def onRowChange(self, idx: int) -> None:\n        if idx == -1:\n            return\n        self.saveField()\n        self.loadField(idx)\n\n    def _uniqueName(self, prompt: str, old: str = \"\") -> str | None:\n        txt = getOnlyText(prompt, default=old).replace('\"', \"\").strip()\n        if not txt:\n            return None\n        if txt[0] in \"#^/\":\n            show_warning(tr.fields_name_first_letter_not_valid())\n            return None\n        for letter in \"\"\":{\"}\"\"\":\n            if letter in txt:\n                show_warning(tr.fields_name_invalid_letter())\n                return None\n        if txt.casefold() == old.casefold():\n            return None\n        for f in self.model[\"flds\"]:\n            if f[\"name\"].casefold() == txt.casefold():\n                show_warning(tr.fields_that_field_name_is_already_used())\n                return None\n        return txt\n\n    def onRename(self) -> None:\n        if self.currentIdx is None:\n            return\n\n        idx = self.currentIdx\n        f = self.model[\"flds\"][idx]\n        name = self._uniqueName(tr.actions_new_name(), f[\"name\"])\n        if not name:\n            return\n\n        old_name = f[\"name\"]\n        self.change_tracker.mark_basic()\n        self.mm.rename_field(self.model, f, name)\n        gui_hooks.fields_did_rename_field(self, f, old_name)\n\n        self.saveField()\n        self.fillFields()\n        self.form.fieldList.setCurrentRow(idx)\n\n    def onAdd(self) -> None:\n        name = self._uniqueName(tr.fields_field_name())\n        if not name:\n            return\n        if not self.change_tracker.mark_schema():\n            return\n        self.saveField()\n        f = self.mm.new_field(name)\n        self.mm.add_field(self.model, f)\n        gui_hooks.fields_did_add_field(self, f)\n\n        self.fillFields()\n        self.form.fieldList.setCurrentRow(len(self.model[\"flds\"]) - 1)\n\n    def onDelete(self) -> None:\n        if len(self.model[\"flds\"]) < 2:\n            show_warning(tr.fields_notes_require_at_least_one_field())\n            return\n        field = self.model[\"flds\"][self.form.fieldList.currentRow()]\n        if field[\"preventDeletion\"]:\n            show_warning(tr.fields_field_is_required())\n            return\n        count = self.mm.use_count(self.model)\n        c = tr.browsing_note_count(count=count)\n        if not askUser(tr.fields_delete_field_from(val=c)):\n            return\n        if not self.change_tracker.mark_schema():\n            return\n        self.mm.remove_field(self.model, field)\n        gui_hooks.fields_did_delete_field(self, field)\n\n        self.fillFields()\n        self.form.fieldList.setCurrentRow(0)\n\n    def onPosition(self, delta: int = -1) -> None:\n        idx = self.currentIdx\n        assert idx is not None\n        l = len(self.model[\"flds\"])\n        txt = getOnlyText(tr.fields_new_position_1(val=l), default=str(idx + 1))\n        if not txt:\n            return\n        try:\n            pos = int(txt)\n        except ValueError:\n            return\n        if not 0 < pos <= l:\n            return\n        self.moveField(pos)\n\n    def onSortField(self) -> None:\n        if not self.change_tracker.mark_schema():\n            return\n        # don't allow user to disable; it makes no sense\n        self.form.sortField.setChecked(True)\n        self.mm.set_sort_index(self.model, self.form.fieldList.currentRow())\n\n    def moveField(self, pos: int) -> None:\n        if not self.change_tracker.mark_schema():\n            return\n        self.saveField()\n        f = self.model[\"flds\"][self.currentIdx]\n        self.mm.reposition_field(self.model, f, pos - 1)\n        self.fillFields()\n        self.form.fieldList.setCurrentRow(pos - 1)\n\n    def loadField(self, idx: int) -> None:\n        self.currentIdx = idx\n        fld = self.model[\"flds\"][idx]\n        f = self.form\n        f.fontFamily.setCurrentFont(QFont(fld[\"font\"]))\n        f.fontSize.setValue(fld[\"size\"])\n        f.sortField.setChecked(self.model[\"sortf\"] == fld[\"ord\"])\n        f.rtl.setChecked(fld[\"rtl\"])\n        f.plainTextByDefault.setChecked(fld[\"plainText\"])\n        f.collapseByDefault.setChecked(fld[\"collapsed\"])\n        f.excludeFromSearch.setChecked(fld[\"excludeFromSearch\"])\n        f.fieldDescription.setText(fld.get(\"description\", \"\"))\n\n    def saveField(self) -> None:\n        # not initialized yet?\n        if self.currentIdx is None:\n            return\n        idx = self.currentIdx\n        fld = self.model[\"flds\"][idx]\n        f = self.form\n        font = f.fontFamily.currentFont().family()\n        if fld[\"font\"] != font:\n            fld[\"font\"] = font\n            self.change_tracker.mark_basic()\n        size = f.fontSize.value()\n        if fld[\"size\"] != size:\n            fld[\"size\"] = size\n            self.change_tracker.mark_basic()\n        rtl = f.rtl.isChecked()\n        if fld[\"rtl\"] != rtl:\n            fld[\"rtl\"] = rtl\n            self.change_tracker.mark_basic()\n        plain_text = f.plainTextByDefault.isChecked()\n        if fld[\"plainText\"] != plain_text:\n            fld[\"plainText\"] = plain_text\n            self.change_tracker.mark_basic()\n        collapsed = f.collapseByDefault.isChecked()\n        if fld[\"collapsed\"] != collapsed:\n            fld[\"collapsed\"] = collapsed\n            self.change_tracker.mark_basic()\n        exclude_from_search = f.excludeFromSearch.isChecked()\n        if fld[\"excludeFromSearch\"] != exclude_from_search:\n            fld[\"excludeFromSearch\"] = exclude_from_search\n            self.change_tracker.mark_basic()\n        desc = f.fieldDescription.text()\n        if fld.get(\"description\", \"\") != desc:\n            fld[\"description\"] = desc\n            self.change_tracker.mark_basic()\n\n    def reject(self) -> None:\n        if self.webview:\n            self.webview.cleanup()\n            self.webview = None\n\n        if self.change_tracker.changed():\n            if not askUser(tr.card_templates_discard_changes()):\n                return\n\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        self.saveField()\n\n        def on_done(changes: OpChanges) -> None:\n            tooltip(tr.card_templates_changes_saved(), parent=self.parentWidget())\n            QDialog.accept(self)\n\n        update_notetype_legacy(\n            parent=self.mw, notetype=self.model, skip_checks=True\n        ).success(on_done).run_in_background()\n\n    def onHelp(self) -> None:\n        openHelp(HelpPage.CUSTOMIZING_FIELDS)\n"
  },
  {
    "path": "qt/aqt/filtered_deck.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.collection import OpChangesWithId, SearchNode\nfrom anki.decks import DeckDict, DeckId, FilteredDeckConfig\nfrom anki.errors import SearchError\nfrom anki.lang import without_unicode_isolation\nfrom anki.scheduler import FilteredDeckForUpdate\nfrom aqt import AnkiQt, colors, gui_hooks\nfrom aqt.operations import QueryOp\nfrom aqt.operations.scheduling import add_or_update_filtered_deck\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    HelpPage,\n    disable_help_button,\n    openHelp,\n    restoreGeom,\n    saveGeom,\n    showWarning,\n    tr,\n)\n\n\nclass FilteredDeckConfigDialog(QDialog):\n    \"\"\"Dialog to modify and (re)build a filtered deck.\"\"\"\n\n    GEOMETRY_KEY = \"dyndeckconf\"\n    DIALOG_KEY = \"FilteredDeckConfigDialog\"\n    silentlyClose = True\n\n    def __init__(\n        self,\n        mw: AnkiQt,\n        deck_id: DeckId = DeckId(0),\n        search: str | None = None,\n        search_2: str | None = None,\n    ) -> None:\n        \"\"\"If 'deck_id' is non-zero, load and modify its settings.\n        Otherwise, build a new deck and derive settings from the current deck.\n\n        If search or search_2 are provided, they will be used as the default\n        search text.\n        \"\"\"\n\n        QDialog.__init__(self, mw)\n        self.mw = mw\n        mw.garbage_collect_on_dialog_finish(self)\n        self.col = self.mw.col\n        self._desired_search_1 = search\n        self._desired_search_2 = search_2\n\n        self._initial_dialog_setup()\n\n        # set on successful query\n        self.deck: FilteredDeckForUpdate\n\n        QueryOp(\n            parent=self.mw,\n            op=lambda col: col.sched.get_or_create_filtered_deck(deck_id=deck_id),\n            success=self.load_deck_and_show,\n        ).failure(self.on_fetch_error).run_in_background()\n\n    def on_fetch_error(self, exc: Exception) -> None:\n        showWarning(str(exc))\n        self.close()\n\n    def _initial_dialog_setup(self) -> None:\n        self.form = aqt.forms.filtered_deck.Ui_Dialog()\n        self.form.setupUi(self)\n\n        order_labels = self.col.sched.filtered_deck_order_labels()\n\n        self.form.order.addItems(order_labels)\n        self.form.order_2.addItems(order_labels)\n\n        qconnect(self.form.allow_empty.stateChanged, self._on_allow_empty_toggled)\n\n        qconnect(self.form.resched.stateChanged, self._onReschedToggled)\n\n        qconnect(self.form.search_button.clicked, self.on_search_button)\n        qconnect(self.form.search_button_2.clicked, self.on_search_button_2)\n        qconnect(self.form.hint_button.clicked, self.on_hint_button)\n        blue = theme_manager.var(colors.FG_LINK)\n        grey = theme_manager.var(colors.FG_DISABLED)\n        self.setStyleSheet(\n            f\"\"\"QPushButton[label] {{ padding: 0; border: 0 }}\n            QPushButton[label]:hover {{ text-decoration: underline }}\n            QPushButton[label=\"search\"] {{ color: {blue} }}\n            QPushButton[label=\"hint\"] {{ color: {grey} }}\"\"\"\n        )\n        disable_help_button(self)\n        self.setWindowModality(Qt.WindowModality.WindowModal)\n        qconnect(\n            self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.FILTERED_DECK)\n        )\n\n        self.form.again_delay_label.setText(\n            tr.decks_delay_for_button(button=tr.studying_again())\n        )\n        self.form.hard_delay_label.setText(\n            tr.decks_delay_for_button(button=tr.studying_hard())\n        )\n        self.form.good_delay_label.setText(\n            tr.decks_delay_for_button(button=tr.studying_good())\n        )\n\n        restoreGeom(self, self.GEOMETRY_KEY)\n\n    def load_deck_and_show(self, deck: FilteredDeckForUpdate) -> None:\n        self.deck = deck\n        self._load_deck()\n        self.show()\n\n    def _load_deck(self) -> None:\n        form = self.form\n        deck = self.deck\n        config = deck.config\n\n        self.form.name.setText(deck.name)\n        self.form.name.setPlaceholderText(deck.name)\n\n        existing = deck.id != 0\n        if existing:\n            build_label = tr.actions_rebuild()\n        else:\n            build_label = tr.decks_build()\n\n        ok_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Ok)\n        assert ok_button is not None\n        ok_button.setText(build_label)\n\n        form.resched.setChecked(config.reschedule)\n        self._onReschedToggled(0)\n\n        term1: FilteredDeckConfig.SearchTerm = config.search_terms[0]\n        form.search.setText(term1.search)\n        form.order.setCurrentIndex(term1.order)\n        form.limit.setValue(term1.limit)\n\n        form.preview_again.setValue(config.preview_again_secs)\n        form.preview_hard.setValue(config.preview_hard_secs)\n        form.preview_good.setValue(config.preview_good_secs)\n\n        if len(config.search_terms) > 1:\n            term2: FilteredDeckConfig.SearchTerm = config.search_terms[1]\n            form.search_2.setText(term2.search)\n            form.order_2.setCurrentIndex(term2.order)\n            form.limit_2.setValue(term2.limit)\n            show_second = existing\n        else:\n            show_second = False\n            form.order_2.setCurrentIndex(5)\n            form.limit_2.setValue(20)\n\n        form.secondFilter.setChecked(show_second)\n        form.filter2group.setVisible(show_second)\n\n        self.set_custom_searches(self._desired_search_1, self._desired_search_2)\n\n        self.setWindowTitle(\n            without_unicode_isolation(tr.actions_options_for(val=self.deck.name))\n        )\n\n        gui_hooks.filtered_deck_dialog_did_load_deck(self, deck)\n\n    def reopen(\n        self,\n        _mw: AnkiQt,\n        search: str | None = None,\n        search_2: str | None = None,\n        _deck: DeckDict | None = None,\n    ) -> None:\n        self.set_custom_searches(search, search_2)\n\n    def set_custom_searches(self, search: str | None, search_2: str | None) -> None:\n        if search is not None:\n            self.form.search.setText(search)\n        self.form.search.setFocus()\n        self.form.search.selectAll()\n        if search_2 is not None:\n            self.form.secondFilter.setChecked(True)\n            self.form.filter2group.setVisible(True)\n            self.form.search_2.setText(search_2)\n            self.form.search_2.setFocus()\n            self.form.search_2.selectAll()\n\n    def on_search_button(self) -> None:\n        self._on_search_button(self.form.search)\n\n    def on_search_button_2(self) -> None:\n        self._on_search_button(self.form.search_2)\n\n    def _on_search_button(self, line: QLineEdit) -> None:\n        try:\n            search = self.col.build_search_string(line.text())\n        except SearchError as err:\n            line.setFocus()\n            line.selectAll()\n            showWarning(str(err))\n        else:\n            aqt.dialogs.open(\"Browser\", self.mw, search=(search,))\n\n    def on_hint_button(self) -> None:\n        \"\"\"Open the browser to show cards that match the typed-in filters but cannot be included\n        due to internal limitations.\n        \"\"\"\n        manual_filters = (self.form.search.text(), *self._second_filter())\n        implicit_filters = (\n            SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED),\n            SearchNode(card_state=SearchNode.CARD_STATE_BURIED),\n            *self._filtered_search_node(),\n        )\n        manual_filter = self.col.group_searches(*manual_filters, joiner=\"OR\")\n        implicit_filter = self.col.group_searches(*implicit_filters, joiner=\"OR\")\n        try:\n            search = self.col.build_search_string(manual_filter, implicit_filter)\n        except Exception as err:\n            showWarning(str(err))\n        else:\n            aqt.dialogs.open(\"Browser\", self.mw, search=(search,))\n\n    def _second_filter(self) -> tuple[str, ...]:\n        if self.form.secondFilter.isChecked():\n            return (self.form.search_2.text(),)\n        return ()\n\n    def _filtered_search_node(self) -> tuple[SearchNode]:\n        \"\"\"Return a search node that matches cards in filtered decks, if applicable excluding those\n        in the deck being rebuild.\"\"\"\n        if self.deck.id:\n            return (\n                self.col.group_searches(\n                    SearchNode(deck=\"filtered\"),\n                    SearchNode(negated=SearchNode(deck=self.deck.name)),\n                ),\n            )\n        return (SearchNode(deck=\"filtered\"),)\n\n    def _onReschedToggled(self, _state: int) -> None:\n        self.form.previewDelayWidget.setVisible(not self.form.resched.isChecked())\n\n    def _on_allow_empty_toggled(self) -> None:\n        self.deck.allow_empty = self.form.allow_empty.isChecked()\n\n    def _update_deck(self) -> bool:\n        \"\"\"Update our stored deck with the details from the GUI.\n        If false, abort adding.\"\"\"\n        form = self.form\n        deck = self.deck\n        config = deck.config\n\n        deck.name = form.name.text()\n        config.reschedule = form.resched.isChecked()\n\n        del config.delays[:]\n        terms = [\n            FilteredDeckConfig.SearchTerm(\n                search=form.search.text(),\n                limit=form.limit.value(),\n                order=form.order.currentIndex(),  # type: ignore[arg-type]\n            )\n        ]\n\n        if form.secondFilter.isChecked():\n            terms.append(\n                FilteredDeckConfig.SearchTerm(\n                    search=form.search_2.text(),\n                    limit=form.limit_2.value(),\n                    order=form.order_2.currentIndex(),  # type: ignore[arg-type]\n                )\n            )\n\n        del config.search_terms[:]\n        config.search_terms.extend(terms)\n        config.preview_again_secs = form.preview_again.value()\n        config.preview_hard_secs = form.preview_hard.value()\n        config.preview_good_secs = form.preview_good.value()\n\n        return True\n\n    def reject(self) -> None:\n        aqt.dialogs.markClosed(self.DIALOG_KEY)\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        if not self._update_deck():\n            return\n\n        def success(out: OpChangesWithId) -> None:\n            gui_hooks.filtered_deck_dialog_did_add_or_update_deck(\n                self, self.deck, out.id\n            )\n            saveGeom(self, self.GEOMETRY_KEY)\n            aqt.dialogs.markClosed(self.DIALOG_KEY)\n            QDialog.accept(self)\n\n        gui_hooks.filtered_deck_dialog_will_add_or_update_deck(self, self.deck)\n\n        add_or_update_filtered_deck(parent=self, deck=self.deck).success(\n            success\n        ).run_in_background()\n"
  },
  {
    "path": "qt/aqt/flags.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import cast\n\nimport aqt\nimport aqt.main\nfrom anki.collection import SearchNode\nfrom aqt import colors, gui_hooks\nfrom aqt.theme import ColoredIcon\nfrom aqt.utils import tr\n\n\n@dataclass\nclass Flag:\n    \"\"\"A container class for flag related data.\n\n    index -- The integer by which the flag is represented internally (1-7).\n    label -- The text by which the flag is described in the GUI.\n    icon -- The icon by which the flag is represented in the GUI.\n    search_node -- The node to build a search string for finding cards with the flag.\n    action -- The name of the action to assign the flag in the browser form.\n    \"\"\"\n\n    index: int\n    label: str\n    icon: ColoredIcon\n    search_node: SearchNode\n    action: str\n\n\nclass FlagManager:\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        self.mw = mw\n        self._flags: list[Flag] = []\n\n    def all(self) -> list[Flag]:\n        \"\"\"Return a list of all flags.\"\"\"\n        if not self._flags:\n            self._load_flags()\n        return self._flags\n\n    def get_flag(self, flag_index: int) -> Flag:\n        if not 1 <= flag_index <= len(self.all()):\n            raise Exception(f\"Flag index out of range (1-{len(self.all())}).\")\n        return self.all()[flag_index - 1]\n\n    def rename_flag(self, flag_index: int, new_name: str) -> None:\n        if new_name in (\"\", self.get_flag(flag_index).label):\n            return\n        labels = self.mw.col.get_config(\"flagLabels\", {})\n        labels[str(flag_index)] = self.get_flag(flag_index).label = new_name\n        self.mw.col.set_config(\"flagLabels\", labels)\n        gui_hooks.flag_label_did_change()\n\n    def restore_default_flag_name(self, flag_index: int) -> None:\n        labels = self.mw.col.get_config(\"flagLabels\", {})\n        if str(flag_index) not in labels:\n            return\n        del labels[str(flag_index)]\n        self.mw.col.set_config(\"flagLabels\", labels)\n        self.require_refresh()\n        gui_hooks.flag_label_did_change()\n\n    def require_refresh(self) -> None:\n        \"Discard cached labels.\"\n        self._flags = []\n\n    def _load_flags(self) -> None:\n        labels = cast(dict[str, str], self.mw.col.get_config(\"flagLabels\", {}))\n        icon = ColoredIcon(path=\"icons:flag-variant.svg\", color=colors.FG_DISABLED)\n\n        self._flags = [\n            Flag(\n                1,\n                labels[\"1\"] if \"1\" in labels else tr.actions_flag_red(),\n                icon.with_color(colors.FLAG_1),\n                SearchNode(flag=SearchNode.FLAG_RED),\n                \"actionRed_Flag\",\n            ),\n            Flag(\n                2,\n                labels[\"2\"] if \"2\" in labels else tr.actions_flag_orange(),\n                icon.with_color(colors.FLAG_2),\n                SearchNode(flag=SearchNode.FLAG_ORANGE),\n                \"actionOrange_Flag\",\n            ),\n            Flag(\n                3,\n                labels[\"3\"] if \"3\" in labels else tr.actions_flag_green(),\n                icon.with_color(colors.FLAG_3),\n                SearchNode(flag=SearchNode.FLAG_GREEN),\n                \"actionGreen_Flag\",\n            ),\n            Flag(\n                4,\n                labels[\"4\"] if \"4\" in labels else tr.actions_flag_blue(),\n                icon.with_color(colors.FLAG_4),\n                SearchNode(flag=SearchNode.FLAG_BLUE),\n                \"actionBlue_Flag\",\n            ),\n            Flag(\n                5,\n                labels[\"5\"] if \"5\" in labels else tr.actions_flag_pink(),\n                icon.with_color(colors.FLAG_5),\n                SearchNode(flag=SearchNode.FLAG_PINK),\n                \"actionPink_Flag\",\n            ),\n            Flag(\n                6,\n                labels[\"6\"] if \"6\" in labels else tr.actions_flag_turquoise(),\n                icon.with_color(colors.FLAG_6),\n                SearchNode(flag=SearchNode.FLAG_TURQUOISE),\n                \"actionTurquoise_Flag\",\n            ),\n            Flag(\n                7,\n                labels[\"7\"] if \"7\" in labels else tr.actions_flag_purple(),\n                icon.with_color(colors.FLAG_7),\n                SearchNode(flag=SearchNode.FLAG_PURPLE),\n                \"actionPurple_Flag\",\n            ),\n        ]\n"
  },
  {
    "path": "qt/aqt/forms/__init__.py",
    "content": "# ruff: noqa: F401\nfrom . import (\n    about,\n    addcards,\n    addfield,\n    addmodel,\n    addonconf,\n    addons,\n    browser,\n    browserdisp,\n    browseropts,\n    changemap,\n    changemodel,\n    clayout_top,\n    customstudy,\n    dconf,\n    debug,\n    editcurrent,\n    edithtml,\n    emptycards,\n    exporting,\n    fields,\n    filtered_deck,\n    finddupes,\n    findreplace,\n    forget,\n    getaddons,\n    importing,\n    main,\n    modelopts,\n    models,\n    preferences,\n    preview,\n    profiles,\n    progress,\n    reposition,\n    setgroup,\n    setlang,\n    stats,\n    studydeck,\n    synclog,\n    taglimit,\n    template,\n    widgets,\n)\n"
  },
  {
    "path": "qt/aqt/forms/about.py",
    "content": "from _aqt.forms.about_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/about.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>About</class>\n <widget class=\"QDialog\" name=\"About\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>410</width>\n    <height>664</height>\n   </rect>\n  </property>\n  <property name=\"sizePolicy\">\n   <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Minimum\">\n    <horstretch>0</horstretch>\n    <verstretch>0</verstretch>\n   </sizepolicy>\n  </property>\n  <property name=\"windowTitle\">\n   <string>about_about_anki</string>\n  </property>\n  <layout class=\"QVBoxLayout\">\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <widget class=\"AnkiWebView\" name=\"label\" native=\"true\">\n     <property name=\"url\" stdset=\"0\">\n      <url>\n       <string notr=\"true\">about:blank</string>\n      </url>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>AnkiWebView</class>\n   <extends>QWidget</extends>\n   <header location=\"global\">aqt/webview</header>\n   <container>1</container>\n  </customwidget>\n </customwidgets>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>About</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>About</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/addcards.py",
    "content": "from _aqt.forms.addcards_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/addcards.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QMainWindow\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>750</width>\n    <height>493</height>\n   </rect>\n  </property>\n  <property name=\"minimumSize\">\n   <size>\n    <width>400</width>\n    <height>400</height>\n   </size>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset>\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"centralwidget\">\n   <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n    <property name=\"spacing\">\n     <number>12</number>\n    </property>\n    <property name=\"leftMargin\">\n     <number>12</number>\n    </property>\n    <property name=\"topMargin\">\n     <number>6</number>\n    </property>\n    <property name=\"rightMargin\">\n     <number>12</number>\n    </property>\n    <property name=\"bottomMargin\">\n     <number>12</number>\n    </property>\n    <item>\n     <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n      <property name=\"spacing\">\n       <number>6</number>\n      </property>\n      <property name=\"bottomMargin\">\n       <number>0</number>\n      </property>\n      <item>\n       <widget class=\"QWidget\" name=\"modelArea\" native=\"true\">\n        <property name=\"minimumSize\">\n         <size>\n          <width>0</width>\n          <height>10</height>\n         </size>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <widget class=\"QWidget\" name=\"deckArea\" native=\"true\"/>\n      </item>\n     </layout>\n    </item>\n    <item>\n     <widget class=\"QWidget\" name=\"fieldsArea\" native=\"true\">\n      <property name=\"sizePolicy\">\n       <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Expanding\">\n        <horstretch>0</horstretch>\n        <verstretch>10</verstretch>\n       </sizepolicy>\n      </property>\n      <property name=\"autoFillBackground\">\n       <bool>true</bool>\n      </property>\n     </widget>\n    </item>\n    <item>\n     <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n      <property name=\"standardButtons\">\n       <set>QDialogButtonBox::NoButton</set>\n      </property>\n     </widget>\n    </item>\n   </layout>\n  </widget>\n  <widget class=\"QMenuBar\" name=\"menubar\">\n   <property name=\"geometry\">\n    <rect>\n     <x>0</x>\n     <y>0</y>\n     <width>750</width>\n     <height>22</height>\n    </rect>\n   </property>\n   <widget class=\"QMenu\" name=\"menu_Edit\">\n    <property name=\"title\">\n     <string>qt_accel_edit</string>\n    </property>\n   </widget>\n   <addaction name=\"menu_Edit\"/>\n  </widget>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/addfield.py",
    "content": "from _aqt.forms.addfield_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/addfield.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>434</width>\n    <height>186</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>fields_add_field</string>\n  </property>\n  <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>fields_field</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_3\">\n       <property name=\"text\">\n        <string>fields_size</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QFontComboBox\" name=\"font\"/>\n     </item>\n     <item row=\"3\" column=\"1\">\n      <spacer name=\"verticalSpacer\">\n       <property name=\"orientation\">\n        <enum>Qt::Vertical</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>20</width>\n         <height>40</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>fields_font</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QListWidget\" name=\"fields\"/>\n     </item>\n     <item row=\"2\" column=\"1\">\n      <widget class=\"QSpinBox\" name=\"size\">\n       <property name=\"minimum\">\n        <number>6</number>\n       </property>\n       <property name=\"maximum\">\n        <number>200</number>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>fields</tabstop>\n  <tabstop>font</tabstop>\n  <tabstop>size</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/addmodel.py",
    "content": "from _aqt.forms.addmodel_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/addmodel.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>285</width>\n    <height>269</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>notetypes_add_note_type</string>\n  </property>\n  <layout class=\"QVBoxLayout\">\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox\">\n     <property name=\"title\">\n      <string/>\n     </property>\n     <layout class=\"QVBoxLayout\">\n      <item>\n       <widget class=\"QListWidget\" name=\"models\">\n        <property name=\"editTriggers\">\n         <set>QAbstractItemView::NoEditTriggers</set>\n        </property>\n        <property name=\"tabKeyNavigation\">\n         <bool>true</bool>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>266</x>\n     <y>353</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>334</x>\n     <y>353</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/addonconf.py",
    "content": "from _aqt.forms.addonconf_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/addonconf.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"windowModality\">\n   <enum>Qt::ApplicationModal</enum>\n  </property>\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>631</width>\n    <height>521</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>addons_configuration</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QSplitter\" name=\"splitter\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <widget class=\"QPlainTextEdit\" name=\"editor\">\n      <property name=\"sizePolicy\">\n       <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n        <horstretch>3</horstretch>\n        <verstretch>0</verstretch>\n       </sizepolicy>\n      </property>\n      <property name=\"lineWrapMode\">\n       <enum>QPlainTextEdit::NoWrap</enum>\n      </property>\n     </widget>\n     <widget class=\"AnkiWebView\" name=\"help\" native=\"true\">\n      <property name=\"url\" stdset=\"0\">\n       <url>\n        <string>about:blank</string>\n       </url>\n      </property>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>AnkiWebView</class>\n   <extends>QWidget</extends>\n   <header location=\"global\">aqt/webview</header>\n   <container>1</container>\n  </customwidget>\n </customwidgets>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/addons.py",
    "content": "from _aqt.forms.addons_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/addons.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"windowModality\">\n   <enum>Qt::ApplicationModal</enum>\n  </property>\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>800</width>\n    <height>800</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">ADD-ONS</string>\n  </property>\n  <property name=\"modal\">\n   <bool>true</bool>\n  </property>\n  <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n   <item>\n    <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n     <item>\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>addons_changes_will_take_effect_when_anki</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QListWidget\" name=\"addonList\">\n       <property name=\"selectionMode\">\n        <enum>QAbstractItemView::ExtendedSelection</enum>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n     <item>\n      <widget class=\"QPushButton\" name=\"getAddons\">\n       <property name=\"text\">\n        <string>addons_get_addons</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"installFromFile\">\n       <property name=\"text\">\n        <string>addons_install_from_file</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"checkForUpdates\">\n       <property name=\"text\">\n        <string>addons_check_for_updates</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <spacer name=\"verticalSpacer\">\n       <property name=\"orientation\">\n        <enum>Qt::Vertical</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>20</width>\n         <height>40</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"viewPage\">\n       <property name=\"text\">\n        <string>addons_view_addon_page</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"config\">\n       <property name=\"text\">\n        <string>addons_config</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"viewFiles\">\n       <property name=\"text\">\n        <string>addons_view_files</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"toggleEnabled\">\n       <property name=\"text\">\n        <string>addons_toggle_enabled</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"delete_2\">\n       <property name=\"text\">\n        <string>actions_delete</string>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/browser.py",
    "content": "from _aqt.forms.browser_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/browser.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QMainWindow\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>750</width>\n    <height>493</height>\n   </rect>\n  </property>\n  <property name=\"minimumSize\">\n   <size>\n    <width>400</width>\n    <height>400</height>\n   </size>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset>\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"centralwidget\">\n   <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n    <property name=\"spacing\">\n     <number>12</number>\n    </property>\n    <property name=\"leftMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"topMargin\">\n     <number>6</number>\n    </property>\n    <property name=\"rightMargin\">\n     <number>6</number>\n    </property>\n    <property name=\"bottomMargin\">\n     <number>12</number>\n    </property>\n    <item>\n     <widget class=\"QSplitter\" name=\"splitter\">\n      <property name=\"sizePolicy\">\n       <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Expanding\">\n        <horstretch>4</horstretch>\n        <verstretch>0</verstretch>\n       </sizepolicy>\n      </property>\n      <property name=\"orientation\">\n       <enum>Qt::Horizontal</enum>\n      </property>\n      <widget class=\"QWidget\" name=\"widget\" native=\"true\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n         <horstretch>3</horstretch>\n         <verstretch>1</verstretch>\n        </sizepolicy>\n       </property>\n       <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n        <property name=\"spacing\">\n         <number>0</number>\n        </property>\n        <property name=\"leftMargin\">\n         <number>0</number>\n        </property>\n        <property name=\"topMargin\">\n         <number>0</number>\n        </property>\n        <property name=\"rightMargin\">\n         <number>0</number>\n        </property>\n        <property name=\"bottomMargin\">\n         <number>0</number>\n        </property>\n        <item>\n         <layout class=\"QGridLayout\" name=\"gridLayout\">\n          <property name=\"leftMargin\">\n           <number>0</number>\n          </property>\n          <property name=\"topMargin\">\n           <number>0</number>\n          </property>\n          <property name=\"rightMargin\">\n           <number>0</number>\n          </property>\n          <property name=\"bottomMargin\">\n           <number>6</number>\n          </property>\n          <property name=\"horizontalSpacing\">\n           <number>12</number>\n          </property>\n          <property name=\"verticalSpacing\">\n           <number>0</number>\n          </property>\n          <item row=\"0\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"searchEdit\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Fixed\">\n              <horstretch>9</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"editable\">\n             <bool>true</bool>\n            </property>\n            <property name=\"insertPolicy\">\n             <enum>QComboBox::NoInsert</enum>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </item>\n        <item>\n         <widget class=\"QTableView\" name=\"tableView\">\n          <property name=\"sizePolicy\">\n           <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n            <horstretch>9</horstretch>\n            <verstretch>1</verstretch>\n           </sizepolicy>\n          </property>\n          <property name=\"minimumSize\">\n           <size>\n            <width>0</width>\n            <height>150</height>\n           </size>\n          </property>\n          <property name=\"contextMenuPolicy\">\n           <enum>Qt::ActionsContextMenu</enum>\n          </property>\n          <property name=\"horizontalScrollBarPolicy\">\n           <enum>Qt::ScrollBarAsNeeded</enum>\n          </property>\n          <property name=\"editTriggers\">\n           <set>QAbstractItemView::NoEditTriggers</set>\n          </property>\n          <property name=\"tabKeyNavigation\">\n           <bool>false</bool>\n          </property>\n          <property name=\"alternatingRowColors\">\n           <bool>true</bool>\n          </property>\n          <property name=\"selectionBehavior\">\n           <enum>QAbstractItemView::SelectRows</enum>\n          </property>\n          <attribute name=\"horizontalHeaderCascadingSectionResizes\">\n           <bool>false</bool>\n          </attribute>\n          <attribute name=\"horizontalHeaderMinimumSectionSize\">\n           <number>20</number>\n          </attribute>\n          <attribute name=\"horizontalHeaderHighlightSections\">\n           <bool>false</bool>\n          </attribute>\n          <attribute name=\"horizontalHeaderShowSortIndicator\" stdset=\"0\">\n           <bool>true</bool>\n          </attribute>\n         </widget>\n        </item>\n       </layout>\n      </widget>\n      <widget class=\"QWidget\" name=\"verticalLayoutWidget\">\n       <layout class=\"QVBoxLayout\" name=\"verticalLayout\" stretch=\"0\">\n        <property name=\"spacing\">\n         <number>0</number>\n        </property>\n        <property name=\"leftMargin\">\n         <number>0</number>\n        </property>\n        <property name=\"topMargin\">\n         <number>1</number>\n        </property>\n        <property name=\"rightMargin\">\n         <number>0</number>\n        </property>\n        <property name=\"bottomMargin\">\n         <number>0</number>\n        </property>\n        <item>\n         <layout class=\"QHBoxLayout\" name=\"horizontalLayout2\">\n          <property name=\"spacing\">\n           <number>0</number>\n          </property>\n          <item>\n           <widget class=\"QWidget\" name=\"fieldsArea\" native=\"true\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Expanding\">\n              <horstretch>0</horstretch>\n              <verstretch>1</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"minimumSize\">\n             <size>\n              <width>50</width>\n              <height>200</height>\n             </size>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </item>\n       </layout>\n      </widget>\n     </widget>\n    </item>\n   </layout>\n  </widget>\n  <widget class=\"QMenuBar\" name=\"menubar\">\n   <property name=\"geometry\">\n    <rect>\n     <x>0</x>\n     <y>0</y>\n     <width>750</width>\n     <height>23</height>\n    </rect>\n   </property>\n   <widget class=\"QMenu\" name=\"menuEdit\">\n    <property name=\"title\">\n     <string>qt_accel_edit</string>\n    </property>\n    <addaction name=\"actionUndo\"/>\n    <addaction name=\"actionRedo\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionSelectAll\"/>\n    <addaction name=\"actionSelectNotes\"/>\n    <addaction name=\"actionInvertSelection\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionClose\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionCreateFilteredDeck\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuJump\">\n    <property name=\"title\">\n     <string>qt_accel_go</string>\n    </property>\n    <addaction name=\"actionFind\"/>\n    <addaction name=\"actionSidebarFilter\"/>\n    <addaction name=\"actionSidebar\"/>\n    <addaction name=\"actionNote\"/>\n    <addaction name=\"actionCardList\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionFirstCard\"/>\n    <addaction name=\"actionPreviousCard\"/>\n    <addaction name=\"actionNextCard\"/>\n    <addaction name=\"actionLastCard\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menu_Help\">\n    <property name=\"title\">\n     <string>qt_accel_help</string>\n    </property>\n    <addaction name=\"actionGuide\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menu_Cards\">\n    <property name=\"title\">\n     <string>qt_accel_cards</string>\n    </property>\n    <widget class=\"QMenu\" name=\"menuFlag\">\n     <property name=\"title\">\n      <string>browsing_flag</string>\n     </property>\n     <addaction name=\"actionRed_Flag\"/>\n     <addaction name=\"actionOrange_Flag\"/>\n     <addaction name=\"actionGreen_Flag\"/>\n     <addaction name=\"actionBlue_Flag\"/>\n     <addaction name=\"actionPink_Flag\"/>\n     <addaction name=\"actionTurquoise_Flag\"/>\n     <addaction name=\"actionPurple_Flag\"/>\n    </widget>\n    <addaction name=\"actionChange_Deck\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"action_set_due_date\"/>\n    <addaction name=\"action_grade_now\"/>\n    <addaction name=\"action_forget\"/>\n    <addaction name=\"actionReposition\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionToggle_Suspend\"/>\n    <addaction name=\"action_toggle_bury\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"menuFlag\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"action_Info\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menu_Notes\">\n    <property name=\"title\">\n     <string>qt_accel_notes</string>\n    </property>\n    <addaction name=\"actionAdd\"/>\n    <addaction name=\"actionCopy\"/>\n    <addaction name=\"actionExport\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionAdd_Tags\"/>\n    <addaction name=\"actionRemove_Tags\"/>\n    <addaction name=\"actionClear_Unused_Tags\"/>\n    <addaction name=\"actionToggle_Mark\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionChangeModel\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionFindDuplicates\"/>\n    <addaction name=\"actionFindReplace\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionManage_Note_Types\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionDelete\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuqt_accel_view\">\n    <property name=\"title\">\n     <string>qt_accel_view</string>\n    </property>\n    <widget class=\"QMenu\" name=\"menuLayout\">\n     <property name=\"title\">\n      <string>qt_accel_layout</string>\n     </property>\n     <addaction name=\"actionLayoutAuto\"/>\n     <addaction name=\"separator\"/>\n     <addaction name=\"actionLayoutVertical\"/>\n     <addaction name=\"actionLayoutHorizontal\"/>\n    </widget>\n    <addaction name=\"action_toggle_mode\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionFullScreen\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionToggleSidebar\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionZoomIn\"/>\n    <addaction name=\"actionZoomOut\"/>\n    <addaction name=\"actionResetZoom\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"menuLayout\"/>\n   </widget>\n   <addaction name=\"menuEdit\"/>\n   <addaction name=\"menuqt_accel_view\"/>\n   <addaction name=\"menu_Notes\"/>\n   <addaction name=\"menu_Cards\"/>\n   <addaction name=\"menuJump\"/>\n   <addaction name=\"menu_Help\"/>\n  </widget>\n  <action name=\"actionSelectAll\">\n   <property name=\"text\">\n    <string>qt_accel_select_all</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+A</string>\n   </property>\n  </action>\n  <action name=\"actionUndo\">\n   <property name=\"text\">\n    <string>qt_accel_undo</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Z</string>\n   </property>\n  </action>\n  <action name=\"actionInvertSelection\">\n   <property name=\"text\">\n    <string>qt_accel_invert_selection</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+S</string>\n   </property>\n  </action>\n  <action name=\"actionFind\">\n   <property name=\"text\">\n    <string>qt_accel_find</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+F</string>\n   </property>\n  </action>\n  <action name=\"actionNote\">\n   <property name=\"text\">\n    <string>qt_accel_note</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+N</string>\n   </property>\n  </action>\n  <action name=\"actionNextCard\">\n   <property name=\"text\">\n    <string>qt_accel_next_card</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+N</string>\n   </property>\n  </action>\n  <action name=\"actionPreviousCard\">\n   <property name=\"text\">\n    <string>qt_accel_previous_card</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+P</string>\n   </property>\n  </action>\n  <action name=\"actionGuide\">\n   <property name=\"text\">\n    <string>qt_accel_guide</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">F1</string>\n   </property>\n  </action>\n  <action name=\"actionChangeModel\">\n   <property name=\"text\">\n    <string>browsing_change_note_type2</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+M</string>\n   </property>\n  </action>\n  <action name=\"actionSelectNotes\">\n   <property name=\"text\">\n    <string>qt_accel_select_notes</string>\n   </property>\n  </action>\n  <action name=\"actionFindReplace\">\n   <property name=\"text\">\n    <string>qt_accel_find_and_replace</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+F</string>\n   </property>\n  </action>\n  <action name=\"actionSidebarFilter\">\n   <property name=\"text\">\n    <string>qt_accel_filter</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+F</string>\n   </property>\n  </action>\n  <action name=\"actionCardList\">\n   <property name=\"text\">\n    <string>browsing_card_list</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+L</string>\n   </property>\n  </action>\n  <action name=\"actionFindDuplicates\">\n   <property name=\"text\">\n    <string>qt_accel_find_duplicates</string>\n   </property>\n  </action>\n  <action name=\"actionReposition\">\n   <property name=\"text\">\n    <string>browsing_reposition</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+S</string>\n   </property>\n  </action>\n  <action name=\"actionFirstCard\">\n   <property name=\"text\">\n    <string>browsing_first_card</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Home</string>\n   </property>\n  </action>\n  <action name=\"actionLastCard\">\n   <property name=\"text\">\n    <string>browsing_last_card</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">End</string>\n   </property>\n  </action>\n  <action name=\"actionClose\">\n   <property name=\"text\">\n    <string>actions_close</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+W</string>\n   </property>\n  </action>\n  <action name=\"action_Info\">\n   <property name=\"text\">\n    <string>qt_accel_info</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+I</string>\n   </property>\n  </action>\n  <action name=\"actionAdd_Tags\">\n   <property name=\"text\">\n    <string>browsing_add_tags2</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+A</string>\n   </property>\n  </action>\n  <action name=\"actionRemove_Tags\">\n   <property name=\"text\">\n    <string>browsing_remove_tags</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+Shift+A</string>\n   </property>\n  </action>\n  <action name=\"actionToggle_Suspend\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>browsing_toggle_suspend</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+J</string>\n   </property>\n  </action>\n  <action name=\"actionDelete\">\n   <property name=\"text\">\n    <string>actions_delete</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Del</string>\n   </property>\n  </action>\n  <action name=\"actionAdd\">\n   <property name=\"text\">\n    <string>browsing_add_notes</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+E</string>\n   </property>\n  </action>\n  <action name=\"actionChange_Deck\">\n   <property name=\"text\">\n    <string>browsing_change_deck2</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+D</string>\n   </property>\n  </action>\n  <action name=\"actionRed_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_red</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+1</string>\n   </property>\n  </action>\n  <action name=\"actionOrange_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_orange</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+2</string>\n   </property>\n  </action>\n  <action name=\"actionGreen_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_green</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+3</string>\n   </property>\n  </action>\n  <action name=\"actionBlue_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_blue</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+4</string>\n   </property>\n  </action>\n  <action name=\"actionSidebar\">\n   <property name=\"text\">\n    <string>browsing_sidebar</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+R</string>\n   </property>\n  </action>\n  <action name=\"actionClear_Unused_Tags\">\n   <property name=\"text\">\n    <string>browsing_clear_unused_tags</string>\n   </property>\n  </action>\n  <action name=\"actionManage_Note_Types\">\n   <property name=\"text\">\n    <string>browsing_manage_note_types</string>\n   </property>\n  </action>\n  <action name=\"actionToggle_Mark\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>browsing_toggle_mark</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+K</string>\n   </property>\n  </action>\n  <action name=\"actionExport\">\n   <property name=\"text\">\n    <string>qt_accel_export_notes</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+E</string>\n   </property>\n  </action>\n  <action name=\"actionCreateFilteredDeck\">\n   <property name=\"text\">\n    <string>qt_misc_create_filtered_deck</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+G</string>\n   </property>\n  </action>\n  <action name=\"action_set_due_date\">\n   <property name=\"text\">\n    <string>qt_accel_set_due_date</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+D</string>\n   </property>\n  </action>\n  <action name=\"action_grade_now\">\n   <property name=\"text\">\n    <string>actions_grade_now</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+G</string>\n   </property>\n  </action>\n  <action name=\"action_forget\">\n   <property name=\"text\">\n    <string>qt_accel_forget</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+N</string>\n   </property>\n  </action>\n  <action name=\"action_toggle_mode\">\n   <property name=\"text\">\n    <string>browsing_toggle_showing_cards_notes</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+T</string>\n   </property>\n  </action>\n  <action name=\"actionRedo\">\n   <property name=\"text\">\n    <string>qt_accel_redo</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+Z</string>\n   </property>\n  </action>\n  <action name=\"actionPink_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_pink</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+5</string>\n   </property>\n  </action>\n  <action name=\"actionTurquoise_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_turquoise</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+6</string>\n   </property>\n  </action>\n  <action name=\"actionPurple_Flag\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>actions_flag_purple</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+7</string>\n   </property>\n  </action>\n  <action name=\"actionCopy\">\n   <property name=\"text\">\n    <string>actions_create_copy</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Alt+E</string>\n   </property>\n  </action>\n  <action name=\"actionFullScreen\">\n   <property name=\"text\">\n    <string>qt_accel_full_screen</string>\n   </property>\n  </action>\n  <action name=\"actionToggleSidebar\">\n    <property name=\"text\">\n      <string>qt_accel_toggle_sidebar</string>\n    </property>\n  </action>\n  <action name=\"actionZoomIn\">\n   <property name=\"text\">\n    <string>qt_accel_zoom_editor_in</string>\n   </property>\n  </action>\n  <action name=\"actionZoomOut\">\n   <property name=\"text\">\n    <string>qt_accel_zoom_editor_out</string>\n   </property>\n  </action>\n  <action name=\"actionResetZoom\">\n   <property name=\"text\">\n    <string>qt_accel_reset_zoom</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+0</string>\n   </property>\n  </action>\n  <action name=\"actionLayoutAuto\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>qt_accel_layout_auto</string>\n   </property>\n  </action>\n  <action name=\"actionLayoutVertical\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>qt_accel_layout_vertical</string>\n   </property>\n  </action>\n  <action name=\"actionLayoutHorizontal\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>qt_accel_layout_horizontal</string>\n   </property>\n  </action>\n  <action name=\"actionbrowsing_toggle_showing_cards_notes\">\n   <property name=\"text\">\n    <string>browsing_toggle_showing_cards_notes</string>\n   </property>\n  </action>\n  <action name=\"action_toggle_bury\">\n   <property name=\"checkable\">\n    <bool>true</bool>\n   </property>\n   <property name=\"text\">\n    <string>browsing_toggle_bury</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+J</string>\n   </property>\n  </action>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>actionSelectAll</sender>\n   <signal>triggered()</signal>\n   <receiver>tableView</receiver>\n   <slot>selectAll()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>-1</x>\n     <y>-1</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>299</x>\n     <y>279</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>actionClose</sender>\n   <signal>triggered()</signal>\n   <receiver>Dialog</receiver>\n   <slot>_handle_close()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>-1</x>\n     <y>-1</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>374</x>\n     <y>199</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/browserdisp.py",
    "content": "from _aqt.forms.browserdisp_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/browserdisp.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>412</width>\n    <height>241</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_browser_appearance</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>browsing_override_front_template</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QLineEdit\" name=\"qfmt\"/>\n   </item>\n   <item>\n    <widget class=\"QLabel\" name=\"label_2\">\n     <property name=\"text\">\n      <string>browsing_override_back_template</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QLineEdit\" name=\"afmt\"/>\n   </item>\n   <item>\n    <widget class=\"QCheckBox\" name=\"overrideFont\">\n     <property name=\"text\">\n      <string>browsing_override_font</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <widget class=\"QFontComboBox\" name=\"font\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Fixed\">\n         <horstretch>5</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QSpinBox\" name=\"fontSize\">\n       <property name=\"minimum\">\n        <number>6</number>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>qfmt</tabstop>\n  <tabstop>afmt</tabstop>\n  <tabstop>font</tabstop>\n  <tabstop>fontSize</tabstop>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/browseropts.py",
    "content": "from _aqt.forms.browseropts_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/browseropts.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>288</width>\n    <height>195</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_browser_options</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>browsing_font</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QFontComboBox\" name=\"fontCombo\"/>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>browsing_font_size</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QSpinBox\" name=\"fontSize\">\n       <property name=\"minimumSize\">\n        <size>\n         <width>75</width>\n         <height>0</height>\n        </size>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_3\">\n       <property name=\"text\">\n        <string>browsing_line_size</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QSpinBox\" name=\"lineSize\"/>\n     </item>\n     <item row=\"0\" column=\"2\">\n      <spacer name=\"horizontalSpacer_2\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>40</width>\n         <height>20</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QCheckBox\" name=\"fullSearch\">\n     <property name=\"text\">\n      <string>browsing_search_within_formatting_slow</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>fontCombo</tabstop>\n  <tabstop>fontSize</tabstop>\n  <tabstop>lineSize</tabstop>\n  <tabstop>fullSearch</tabstop>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/changemap.py",
    "content": "from _aqt.forms.changemap_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/changemap.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>ChangeMap</class>\n <widget class=\"QDialog\" name=\"ChangeMap\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>391</width>\n    <height>360</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>actions_import</string>\n  </property>\n  <layout class=\"QVBoxLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>browsing_target_field</string>\n     </property>\n     <property name=\"wordWrap\">\n      <bool>true</bool>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QListWidget\" name=\"fields\"/>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>ChangeMap</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>254</x>\n     <y>355</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>ChangeMap</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>322</x>\n     <y>355</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>fields</sender>\n   <signal>doubleClicked(QModelIndex)</signal>\n   <receiver>ChangeMap</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>99</x>\n     <y>123</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>193</x>\n     <y>5</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/changemodel.py",
    "content": "from _aqt.forms.changemodel_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/changemodel.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>362</width>\n    <height>391</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_change_note_type</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <property name=\"spacing\">\n    <number>10</number>\n   </property>\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <property name=\"verticalSpacing\">\n      <number>4</number>\n     </property>\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_6\">\n       <property name=\"text\">\n        <string>browsing_current_note_type</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QLabel\" name=\"oldModelLabel\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"text\">\n        <string/>\n       </property>\n       <property name=\"margin\">\n        <number>4</number>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>browsing_new_note_type</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QWidget\" name=\"modelChooserWidget\" native=\"true\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"tgroup\">\n     <property name=\"sizePolicy\">\n      <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Expanding\">\n       <horstretch>0</horstretch>\n       <verstretch>0</verstretch>\n      </sizepolicy>\n     </property>\n     <property name=\"title\">\n      <string>editing_cards</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n      <property name=\"margin\">\n       <number>0</number>\n      </property>\n      <item>\n       <widget class=\"QScrollArea\" name=\"scrollArea\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"widgetResizable\">\n         <bool>true</bool>\n        </property>\n        <widget class=\"QWidget\" name=\"templateMap\">\n         <property name=\"geometry\">\n          <rect>\n           <x>0</x>\n           <y>0</y>\n           <width>330</width>\n           <height>120</height>\n          </rect>\n         </property>\n        </widget>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"fgroup\">\n     <property name=\"sizePolicy\">\n      <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Expanding\">\n       <horstretch>0</horstretch>\n       <verstretch>0</verstretch>\n      </sizepolicy>\n     </property>\n     <property name=\"title\">\n      <string>editing_fields</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n      <property name=\"margin\">\n       <number>0</number>\n      </property>\n      <item>\n       <widget class=\"QScrollArea\" name=\"scrollArea_2\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"widgetResizable\">\n         <bool>true</bool>\n        </property>\n        <widget class=\"QWidget\" name=\"fieldMap\">\n         <property name=\"geometry\">\n          <rect>\n           <x>0</x>\n           <y>0</y>\n           <width>330</width>\n           <height>119</height>\n          </rect>\n         </property>\n        </widget>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/clayout_top.py",
    "content": "from _aqt.forms.clayout_top_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/clayout_top.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Form</class>\n <widget class=\"QWidget\" name=\"Form\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>400</width>\n    <height>300</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>card_templates_form</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <property name=\"spacing\">\n    <number>3</number>\n   </property>\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <property name=\"spacing\">\n      <number>12</number>\n     </property>\n     <item>\n      <widget class=\"QLabel\" name=\"card_type_label\">\n       <property name=\"text\">\n        <string notr=\"true\">CARD TYPE:</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QComboBox\" name=\"templatesBox\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Fixed\">\n         <horstretch>30</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"minimumContentsLength\">\n        <number>50</number>\n       </property>\n       <property name=\"maxVisibleItems\">\n        <number>30</number>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <spacer name=\"horizontalSpacer\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"sizeType\">\n        <enum>QSizePolicy::MinimumExpanding</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>1</width>\n         <height>20</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"templateOptions\">\n       <property name=\"text\">\n        <string/>\n       </property>\n       <property name=\"autoDefault\">\n        <bool>false</bool>\n       </property>\n       <property name=\"default\">\n        <bool>false</bool>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/customstudy.py",
    "content": "from _aqt.forms.customstudy_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/customstudy.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>332</width>\n    <height>380</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>actions_custom_study</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"3\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioAhead\">\n       <property name=\"text\">\n        <string>custom_study_review_ahead</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioForgot\">\n       <property name=\"text\">\n        <string>custom_study_review_forgotten_cards</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioNew\">\n       <property name=\"text\">\n        <string>custom_study_increase_todays_new_card_limit</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioRev\">\n       <property name=\"text\">\n        <string>custom_study_increase_todays_review_card_limit</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"5\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioCram\">\n       <property name=\"text\">\n        <string>custom_study_study_by_card_state_or_tag</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"4\" column=\"0\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"radioPreview\">\n       <property name=\"text\">\n        <string>custom_study_preview_new_cards</string>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox\">\n     <property name=\"title\">\n      <string/>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n      <item>\n       <widget class=\"QLabel\" name=\"title\">\n        <property name=\"text\">\n         <string notr=\"true\">...</string>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n        <item>\n         <widget class=\"QLabel\" name=\"preSpin\">\n          <property name=\"text\">\n           <string notr=\"true\">...</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QSpinBox\" name=\"spin\"/>\n        </item>\n        <item>\n         <widget class=\"QLabel\" name=\"postSpin\">\n          <property name=\"text\">\n           <string notr=\"true\">...</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <spacer name=\"horizontalSpacer_2\">\n          <property name=\"orientation\">\n           <enum>Qt::Horizontal</enum>\n          </property>\n          <property name=\"sizeHint\" stdset=\"0\">\n           <size>\n            <width>40</width>\n            <height>20</height>\n           </size>\n          </property>\n         </spacer>\n        </item>\n       </layout>\n      </item>\n      <item>\n       <widget class=\"QListWidget\" name=\"cardType\">\n        <property name=\"currentRow\">\n         <number>0</number>\n        </property>\n        <item>\n         <property name=\"text\">\n          <string>custom_study_new_cards_only</string>\n         </property>\n        </item>\n        <item>\n         <property name=\"text\">\n          <string>custom_study_due_cards_only</string>\n         </property>\n        </item>\n        <item>\n         <property name=\"text\">\n          <string>custom_study_all_review_cards_in_random_order</string>\n         </property>\n        </item>\n        <item>\n         <property name=\"text\">\n          <string>custom_study_all_cards_in_random_order_dont</string>\n         </property>\n        </item>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer_2\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>radioNew</tabstop>\n  <tabstop>radioRev</tabstop>\n  <tabstop>radioForgot</tabstop>\n  <tabstop>radioAhead</tabstop>\n  <tabstop>radioPreview</tabstop>\n  <tabstop>radioCram</tabstop>\n  <tabstop>spin</tabstop>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/dconf.py",
    "content": "from _aqt.forms.dconf_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/dconf.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>638</width>\n    <height>514</height>\n   </rect>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout_2\">\n     <item>\n      <widget class=\"QLabel\" name=\"label_31\">\n       <property name=\"text\">\n        <string>scheduling_options_group</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QComboBox\" name=\"dconf\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Fixed\">\n         <horstretch>3</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QToolButton\" name=\"confOpts\">\n       <property name=\"maximumSize\">\n        <size>\n         <width>16777215</width>\n         <height>32</height>\n        </size>\n       </property>\n       <property name=\"text\">\n        <string>actions_manage</string>\n       </property>\n       <property name=\"toolButtonStyle\">\n        <enum>Qt::ToolButtonTextBesideIcon</enum>\n       </property>\n       <property name=\"arrowType\">\n        <enum>Qt::NoArrow</enum>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QLabel\" name=\"count\">\n     <property name=\"styleSheet\">\n      <string notr=\"true\">* { color: red }</string>\n     </property>\n     <property name=\"text\">\n      <string/>\n     </property>\n     <property name=\"alignment\">\n      <set>Qt::AlignCenter</set>\n     </property>\n     <property name=\"wordWrap\">\n      <bool>true</bool>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QTabWidget\" name=\"tabWidget\">\n     <property name=\"currentIndex\">\n      <number>0</number>\n     </property>\n     <widget class=\"QWidget\" name=\"tab\">\n      <attribute name=\"title\">\n       <string>scheduling_new_cards</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <layout class=\"QGridLayout\" name=\"gridLayout\">\n         <property name=\"spacing\">\n          <number>12</number>\n         </property>\n         <item row=\"5\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_24\">\n           <property name=\"text\">\n            <string>scheduling_starting_ease</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"5\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"lrnFactor\">\n           <property name=\"suffix\">\n            <string notr=\"true\">%</string>\n           </property>\n           <property name=\"minimum\">\n            <number>131</number>\n           </property>\n           <property name=\"maximum\">\n            <number>999</number>\n           </property>\n           <property name=\"value\">\n            <number>250</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"1\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_8\">\n           <property name=\"text\">\n            <string>scheduling_order</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"lrnEasyInt\">\n           <property name=\"minimum\">\n            <number>1</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"lrnGradInt\">\n           <property name=\"minimum\">\n            <number>1</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"2\">\n          <widget class=\"QLabel\" name=\"newplim\">\n           <property name=\"text\">\n            <string/>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_5\">\n           <property name=\"text\">\n            <string>scheduling_easy_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_4\">\n           <property name=\"text\">\n            <string>scheduling_graduating_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"newPerDay\">\n           <property name=\"maximum\">\n            <number>9999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_6\">\n           <property name=\"text\">\n            <string>scheduling_new_cardsday</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"1\" colspan=\"2\">\n          <widget class=\"QLineEdit\" name=\"lrnSteps\"/>\n         </item>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_2\">\n           <property name=\"text\">\n            <string>scheduling_steps_in_minutes</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"1\" column=\"1\" colspan=\"2\">\n          <widget class=\"QComboBox\" name=\"newOrder\"/>\n         </item>\n         <item row=\"6\" column=\"0\" colspan=\"3\"  alignment=\"Qt::AlignLeft\">\n          <widget class=\"QCheckBox\" name=\"bury\">\n           <property name=\"text\">\n            <string>scheduling_bury_related_new_cards_until_the</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_9\">\n           <property name=\"text\">\n            <string>scheduling_days</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_7\">\n           <property name=\"sizePolicy\">\n            <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n             <horstretch>0</horstretch>\n             <verstretch>0</verstretch>\n            </sizepolicy>\n           </property>\n           <property name=\"text\">\n            <string>scheduling_days</string>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_3\">\n      <attribute name=\"title\">\n       <string>scheduling_reviews</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_4\">\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <layout class=\"QGridLayout\" name=\"gridLayout_3\">\n         <property name=\"spacing\">\n          <number>12</number>\n         </property>\n         <item row=\"1\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_20\">\n           <property name=\"text\">\n            <string>scheduling_easy_bonus</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"1\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"easyBonus\">\n           <property name=\"suffix\">\n            <string notr=\"true\">%</string>\n           </property>\n           <property name=\"minimum\">\n            <number>100</number>\n           </property>\n           <property name=\"maximum\">\n            <number>1000</number>\n           </property>\n           <property name=\"singleStep\">\n            <number>5</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"revPerDay\">\n           <property name=\"minimum\">\n            <number>0</number>\n           </property>\n           <property name=\"maximum\">\n            <number>9999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_33\">\n           <property name=\"text\">\n            <string>scheduling_interval_modifier</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_37\">\n           <property name=\"text\">\n            <string>scheduling_maximum_reviewsday</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_3\">\n           <property name=\"text\">\n            <string>scheduling_maximum_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"maxIvl\">\n           <property name=\"suffix\">\n            <string/>\n           </property>\n           <property name=\"minimum\">\n            <number>1</number>\n           </property>\n           <property name=\"maximum\">\n            <number>99999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_23\">\n           <property name=\"text\">\n            <string>scheduling_days</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"2\">\n          <widget class=\"QLabel\" name=\"revplim\">\n           <property name=\"text\">\n            <string/>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"1\">\n          <widget class=\"QDoubleSpinBox\" name=\"fi1\">\n           <property name=\"suffix\">\n            <string notr=\"true\">%</string>\n           </property>\n           <property name=\"decimals\">\n            <number>0</number>\n           </property>\n           <property name=\"minimum\">\n            <double>0.000000000000000</double>\n           </property>\n           <property name=\"maximum\">\n            <double>999.000000000000000</double>\n           </property>\n           <property name=\"singleStep\">\n            <double>1.000000000000000</double>\n           </property>\n           <property name=\"value\">\n            <double>100.000000000000000</double>\n           </property>\n          </widget>\n         </item>\n         <item row=\"5\" column=\"0\" colspan=\"3\"  alignment=\"Qt::AlignLeft\">\n          <widget class=\"QCheckBox\" name=\"buryRev\">\n           <property name=\"text\">\n            <string>scheduling_bury_related_reviews_until_the_next</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"0\">\n          <widget class=\"QLabel\" name=\"hardFactorLabel\">\n           <property name=\"text\">\n            <string>scheduling_hard_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"hardFactor\">\n           <property name=\"suffix\">\n            <string notr=\"true\">%</string>\n           </property>\n           <property name=\"minimum\">\n            <number>5</number>\n           </property>\n           <property name=\"maximum\">\n            <number>120</number>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_3\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>152</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_2\">\n      <attribute name=\"title\">\n       <string>scheduling_lapses</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n         <property name=\"spacing\">\n          <number>12</number>\n         </property>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_17\">\n           <property name=\"text\">\n            <string>scheduling_steps_in_minutes</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"1\" colspan=\"2\">\n          <widget class=\"QLineEdit\" name=\"lapSteps\"/>\n         </item>\n         <item row=\"1\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label\">\n           <property name=\"text\">\n            <string>scheduling_new_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_10\">\n           <property name=\"text\">\n            <string>scheduling_leech_threshold</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"leechThreshold\"/>\n         </item>\n         <item row=\"3\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_11\">\n           <property name=\"sizePolicy\">\n            <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n             <horstretch>0</horstretch>\n             <verstretch>0</verstretch>\n            </sizepolicy>\n           </property>\n           <property name=\"text\">\n            <string>scheduling_lapses2</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_12\">\n           <property name=\"text\">\n            <string>scheduling_leech_action</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"lapMinInt\">\n           <property name=\"minimum\">\n            <number>1</number>\n           </property>\n           <property name=\"maximum\">\n            <number>99</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_13\">\n           <property name=\"text\">\n            <string>scheduling_minimum_interval</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_14\">\n           <property name=\"text\">\n            <string>scheduling_days</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"1\" colspan=\"2\">\n          <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n           <item>\n            <widget class=\"QComboBox\" name=\"leechAction\">\n             <item>\n              <property name=\"text\">\n               <string>actions_suspend_card</string>\n              </property>\n             </item>\n             <item>\n              <property name=\"text\">\n               <string>scheduling_tag_only</string>\n              </property>\n             </item>\n            </widget>\n           </item>\n           <item>\n            <spacer name=\"horizontalSpacer\">\n             <property name=\"orientation\">\n              <enum>Qt::Horizontal</enum>\n             </property>\n             <property name=\"sizeHint\" stdset=\"0\">\n              <size>\n               <width>40</width>\n               <height>20</height>\n              </size>\n             </property>\n            </spacer>\n           </item>\n          </layout>\n         </item>\n         <item row=\"1\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"lapMult\">\n           <property name=\"suffix\">\n            <string notr=\"true\">%</string>\n           </property>\n           <property name=\"maximum\">\n            <number>100</number>\n           </property>\n           <property name=\"singleStep\">\n            <number>5</number>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_2\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>72</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_5\">\n      <attribute name=\"title\">\n       <string>scheduling_general</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_6\">\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <layout class=\"QGridLayout\" name=\"gridLayout_5\">\n         <property name=\"spacing\">\n          <number>12</number>\n         </property>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label_25\">\n           <property name=\"text\">\n            <string>scheduling_ignore_answer_times_longer_than</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"1\">\n          <widget class=\"QSpinBox\" name=\"maxTaken\">\n           <property name=\"minimum\">\n            <number>30</number>\n           </property>\n           <property name=\"maximum\">\n            <number>3600</number>\n           </property>\n           <property name=\"singleStep\">\n            <number>10</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"2\">\n          <widget class=\"QLabel\" name=\"label_26\">\n           <property name=\"text\">\n            <string>scheduling_seconds</string>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n       <item alignment=\"Qt::AlignLeft\">\n        <widget class=\"QCheckBox\" name=\"showTimer\">\n         <property name=\"text\">\n          <string>scheduling_show_answer_timer</string>\n         </property>\n        </widget>\n       </item>\n       <item alignment=\"Qt::AlignLeft\">\n        <widget class=\"QCheckBox\" name=\"autoplaySounds\">\n         <property name=\"text\">\n          <string>scheduling_automatically_play_audio</string>\n         </property>\n        </widget>\n       </item>\n       <item alignment=\"Qt::AlignLeft\">\n        <widget class=\"QCheckBox\" name=\"replayQuestion\">\n         <property name=\"text\">\n          <string>scheduling_always_include_question_side_when_replaying</string>\n         </property>\n         <property name=\"checked\">\n          <bool>false</bool>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_5\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>199</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Help|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>dconf</tabstop>\n  <tabstop>confOpts</tabstop>\n  <tabstop>tabWidget</tabstop>\n  <tabstop>lrnSteps</tabstop>\n  <tabstop>newOrder</tabstop>\n  <tabstop>newPerDay</tabstop>\n  <tabstop>lrnGradInt</tabstop>\n  <tabstop>lrnEasyInt</tabstop>\n  <tabstop>lrnFactor</tabstop>\n  <tabstop>bury</tabstop>\n  <tabstop>revPerDay</tabstop>\n  <tabstop>easyBonus</tabstop>\n  <tabstop>fi1</tabstop>\n  <tabstop>maxIvl</tabstop>\n  <tabstop>hardFactor</tabstop>\n  <tabstop>buryRev</tabstop>\n  <tabstop>lapSteps</tabstop>\n  <tabstop>lapMult</tabstop>\n  <tabstop>lapMinInt</tabstop>\n  <tabstop>leechThreshold</tabstop>\n  <tabstop>leechAction</tabstop>\n  <tabstop>maxTaken</tabstop>\n  <tabstop>showTimer</tabstop>\n  <tabstop>autoplaySounds</tabstop>\n  <tabstop>replayQuestion</tabstop>\n </tabstops>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>254</x>\n     <y>320</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>322</x>\n     <y>320</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/debug.py",
    "content": "from _aqt.forms.debug_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/debug.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>637</width>\n    <height>582</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>qt_misc_debug_console</string>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset>\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QComboBox\" name=\"script\">\n     <property name=\"currentText\">\n      <string notr=\"true\"/>\n     </property>\n     <property name=\"currentIndex\">\n      <number>-1</number>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QSplitter\" name=\"splitter\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"childrenCollapsible\">\n      <bool>false</bool>\n     </property>\n     <widget class=\"QPlainTextEdit\" name=\"text\">\n      <property name=\"sizePolicy\">\n       <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n        <horstretch>0</horstretch>\n        <verstretch>1</verstretch>\n       </sizepolicy>\n      </property>\n      <property name=\"minimumSize\">\n       <size>\n        <width>0</width>\n        <height>100</height>\n       </size>\n      </property>\n      <property name=\"maximumSize\">\n       <size>\n        <width>16777215</width>\n        <height>1677215</height>\n       </size>\n      </property>\n      <property name=\"lineWrapMode\">\n       <enum>QPlainTextEdit::NoWrap</enum>\n      </property>\n      <property name=\"placeholderText\">\n       <string notr=\"true\">Actions:\n    Ctrl+Enter          Execute\n    Ctrl+Shift+Enter    Execute and print current line\n    Ctrl+L              Clear log\n    Ctrl+Shift+L        Clear input\n    Ctrl+S              Save script\n    Ctrl+O              Open script\n    Ctrl+D              Delete script\n\nLocals:\n    mw: AnkiQt                          Main window\n    card: Callable[[], Card | None]     Reviewer card\n    bcard: Callable[[], Card | None]    Browser card\n    pp: Callable[[object], None]        Pretty print</string>\n      </property>\n     </widget>\n     <widget class=\"QPlainTextEdit\" name=\"log\">\n      <property name=\"sizePolicy\">\n       <sizepolicy hsizetype=\"Expanding\" vsizetype=\"MinimumExpanding\">\n        <horstretch>0</horstretch>\n        <verstretch>8</verstretch>\n       </sizepolicy>\n      </property>\n      <property name=\"minimumSize\">\n       <size>\n        <width>0</width>\n        <height>150</height>\n       </size>\n      </property>\n      <property name=\"focusPolicy\">\n       <enum>Qt::ClickFocus</enum>\n      </property>\n      <property name=\"readOnly\">\n       <bool>true</bool>\n      </property>\n      <property name=\"placeholderText\">\n       <string notr=\"true\">Output</string>\n      </property>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox\">\n     <property name=\"title\">\n      <string notr=\"true\">Styling</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n      <item>\n       <widget class=\"QPushButton\" name=\"widgetsButton\">\n        <property name=\"text\">\n         <string notr=\"true\">Qt Widget Gallery</string>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>text</tabstop>\n  <tabstop>widgetsButton</tabstop>\n  <tabstop>script</tabstop>\n </tabstops>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/editcurrent.py",
    "content": "from _aqt.forms.editcurrent_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/editcurrent.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QMainWindow\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>400</width>\n    <height>300</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Dialog</string>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset resource=\"icons.qrc\">\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"centralwidget\">\n    <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n    <property name=\"spacing\">\n      <number>3</number>\n    </property>\n    <property name=\"margin\">\n      <number>12</number>\n    </property>\n    <item>\n      <widget class=\"QWidget\" name=\"fieldsArea\" native=\"true\"/>\n    </item>\n    <item>\n      <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n      <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n      </property>\n      <property name=\"standardButtons\">\n        <set>QDialogButtonBox::Close</set>\n      </property>\n      </widget>\n    </item>\n    </layout>\n  </widget>\n  <widget class=\"QMenuBar\" name=\"menubar\">\n    <property name=\"geometry\">\n      <rect>\n        <x>0</x>\n        <y>0</y>\n        <width>750</width>\n        <height>22</height>\n    </rect>\n   </property>\n   <widget class=\"QMenu\" name=\"menu_Edit\">\n    <property name=\"title\">\n     <string>qt_accel_edit</string>\n    </property>\n   </widget>\n   <addaction name=\"menu_Edit\"/>\n  </widget>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>close()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/edithtml.py",
    "content": "from _aqt.forms.edithtml_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/edithtml.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>400</width>\n    <height>300</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>editing_html_editor</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QPlainTextEdit\" name=\"textEdit\"/>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/emptycards.py",
    "content": "from _aqt.forms.emptycards_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/emptycards.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>531</width>\n    <height>345</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">EMPTY_CARDS</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <property name=\"spacing\">\n    <number>0</number>\n   </property>\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <widget class=\"EmptyCardsWebView\" name=\"webview\" native=\"true\">\n     <property name=\"url\" stdset=\"0\">\n      <url>\n       <string notr=\"true\">about:blank</string>\n      </url>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n     <property name=\"spacing\">\n      <number>12</number>\n     </property>\n     <property name=\"leftMargin\">\n      <number>12</number>\n     </property>\n     <property name=\"topMargin\">\n      <number>12</number>\n     </property>\n     <property name=\"rightMargin\">\n      <number>12</number>\n     </property>\n     <property name=\"bottomMargin\">\n      <number>12</number>\n     </property>\n     <item  alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"keep_notes\">\n       <property name=\"text\">\n        <string notr=\"true\">KEEP_NOTES</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"standardButtons\">\n        <set>QDialogButtonBox::Close</set>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>EmptyCardsWebView</class>\n   <extends>QWidget</extends>\n   <header location=\"global\">aqt/webview</header>\n   <container>1</container>\n  </customwidget>\n </customwidgets>\n <tabstops>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/exporting.py",
    "content": "from _aqt.forms.exporting_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/exporting.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>ExportDialog</class>\n <widget class=\"QDialog\" name=\"ExportDialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>550</width>\n    <height>200</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>actions_export</string>\n  </property>\n  <layout class=\"QVBoxLayout\">\n   <item>\n    <layout class=\"QGridLayout\">\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"minimumSize\">\n        <size>\n         <width>100</width>\n         <height>0</height>\n        </size>\n       </property>\n       <property name=\"text\">\n        <string>exporting_export_format</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QComboBox\" name=\"format\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>exporting_include</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QComboBox\" name=\"deck\">\n       <property name=\"minimumContentsLength\">\n        <number>50</number>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <layout class=\"QVBoxLayout\">\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeSched\">\n       <property name=\"text\">\n        <string>exporting_include_scheduling_information</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"include_deck_configs\">\n       <property name=\"text\">\n        <string>exporting_include_deck_configs</string>\n       </property>\n       <property name=\"checked\">\n        <bool>false</bool>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeMedia\">\n       <property name=\"text\">\n        <string>exporting_include_media</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeHTML\">\n       <property name=\"text\">\n        <string>exporting_include_html_and_media_references</string>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeTags\">\n       <property name=\"text\">\n        <string>exporting_include_tags</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeDeck\">\n       <property name=\"enabled\">\n        <bool>true</bool>\n       </property>\n       <property name=\"text\">\n        <string>exporting_include_deck</string>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeNotetype\">\n       <property name=\"enabled\">\n        <bool>true</bool>\n       </property>\n       <property name=\"text\">\n        <string>exporting_include_notetype</string>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"includeGuid\">\n       <property name=\"text\">\n        <string>exporting_include_guid</string>\n       </property>\n      </widget>\n     </item>\n     <item alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"legacy_support\">\n       <property name=\"text\">\n        <string>exporting_support_older_anki_versions</string>\n       </property>\n       <property name=\"checked\">\n        <bool>false</bool>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>format</tabstop>\n  <tabstop>deck</tabstop>\n  <tabstop>includeSched</tabstop>\n  <tabstop>include_deck_configs</tabstop>\n  <tabstop>includeMedia</tabstop>\n  <tabstop>includeHTML</tabstop>\n  <tabstop>includeTags</tabstop>\n  <tabstop>includeDeck</tabstop>\n  <tabstop>includeNotetype</tabstop>\n  <tabstop>includeGuid</tabstop>\n  <tabstop>legacy_support</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>ExportDialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>ExportDialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/fields.py",
    "content": "from _aqt.forms.fields_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/fields.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>567</width>\n    <height>438</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>editing_fields</string>\n  </property>\n  <property name=\"modal\">\n   <bool>true</bool>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <widget class=\"QListWidget\" name=\"fieldList\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"MinimumExpanding\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"minimumSize\">\n        <size>\n         <width>50</width>\n         <height>60</height>\n        </size>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n       <item>\n        <widget class=\"QPushButton\" name=\"fieldAdd\">\n         <property name=\"text\">\n          <string>actions_add</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QPushButton\" name=\"fieldDelete\">\n         <property name=\"text\">\n          <string>actions_delete</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QPushButton\" name=\"fieldRename\">\n         <property name=\"text\">\n          <string>actions_rename</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QPushButton\" name=\"fieldPosition\">\n         <property name=\"text\">\n          <string>actions_reposition</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_3\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <layout class=\"QGridLayout\" name=\"_2\">\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_description\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"text\">\n        <string>fields_description</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_font\">\n       <property name=\"text\">\n        <string>fields_editing_font</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"2\">\n      <widget class=\"QSpinBox\" name=\"fontSize\">\n       <property name=\"minimum\">\n        <number>5</number>\n       </property>\n       <property name=\"maximum\">\n        <number>300</number>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_sort\">\n       <property name=\"text\">\n        <string>actions_options</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QFontComboBox\" name=\"fontFamily\">\n       <property name=\"minimumSize\">\n        <size>\n         <width>0</width>\n         <height>25</height>\n        </size>\n       </property>\n      </widget>\n     </item>\n     <item row=\"3\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"rtl\">\n       <property name=\"text\">\n        <string>fields_reverse_text_direction_rtl</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"4\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"plainTextByDefault\">\n       <property name=\"enabled\">\n        <bool>true</bool>\n       </property>\n       <property name=\"text\">\n        <string>fields_html_by_default</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\" colspan=\"2\">\n      <widget class=\"QLineEdit\" name=\"fieldDescription\">\n       <property name=\"placeholderText\">\n        <string>fields_description_placeholder</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QRadioButton\" name=\"sortField\">\n       <property name=\"text\">\n        <string>fields_sort_by_this_field_in_the</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"5\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"collapseByDefault\">\n       <property name=\"enabled\">\n        <bool>true</bool>\n       </property>\n       <property name=\"text\">\n        <string>fields_collapse_by_default</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"6\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"excludeFromSearch\">\n       <property name=\"enabled\">\n        <bool>true</bool>\n       </property>\n       <property name=\"text\">\n        <string>fields_exclude_from_search</string>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Save</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>fieldList</tabstop>\n  <tabstop>fieldAdd</tabstop>\n  <tabstop>fieldDelete</tabstop>\n  <tabstop>fieldRename</tabstop>\n  <tabstop>fieldPosition</tabstop>\n  <tabstop>fieldDescription</tabstop>\n  <tabstop>fontFamily</tabstop>\n  <tabstop>fontSize</tabstop>\n  <tabstop>sortField</tabstop>\n  <tabstop>rtl</tabstop>\n </tabstops>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/filtered_deck.py",
    "content": "from _aqt.forms.filtered_deck_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/filtered_deck.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>526</width>\n    <height>587</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Dialog</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox_3\">\n     <property name=\"title\">\n      <string>decks_deck</string>\n     </property>\n     <layout class=\"QGridLayout\" name=\"gridLayout_4\">\n      <item row=\"0\" column=\"0\">\n       <widget class=\"QLabel\" name=\"label_2\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Preferred\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"text\">\n         <string>actions_name</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"0\" column=\"1\">\n       <widget class=\"QLineEdit\" name=\"name\">\n        <property name=\"text\">\n         <string/>\n        </property>\n        <property name=\"placeholderText\">\n         <string notr=\"true\"/>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox\">\n     <property name=\"title\">\n      <string>actions_filter</string>\n     </property>\n     <layout class=\"QGridLayout\" name=\"gridLayout\">\n      <item row=\"1\" column=\"0\">\n       <widget class=\"QPushButton\" name=\"search_button\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"focusPolicy\">\n         <enum>Qt::NoFocus</enum>\n        </property>\n        <property name=\"toolTip\">\n         <string>search_view_in_browser</string>\n        </property>\n        <property name=\"text\">\n         <string>actions_search</string>\n        </property>\n        <property name=\"autoDefault\">\n         <bool>false</bool>\n        </property>\n        <property name=\"flat\">\n         <bool>true</bool>\n        </property>\n        <property name=\"label\" stdset=\"0\">\n         <string notr=\"true\">search</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"2\" colspan=\"4\">\n       <widget class=\"QLineEdit\" name=\"search\"/>\n      </item>\n      <item row=\"2\" column=\"2\">\n       <widget class=\"QSpinBox\" name=\"limit\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"minimum\">\n         <number>1</number>\n        </property>\n        <property name=\"maximum\">\n         <number>99999</number>\n        </property>\n       </widget>\n      </item>\n      <item row=\"2\" column=\"4\" colspan=\"2\">\n       <widget class=\"QComboBox\" name=\"order\"/>\n      </item>\n      <item row=\"2\" column=\"3\">\n       <widget class=\"QLabel\" name=\"label\">\n        <property name=\"text\">\n         <string>decks_cards_selected_by</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"2\" column=\"0\">\n       <widget class=\"QLabel\" name=\"label_5\">\n        <property name=\"text\">\n         <string>decks_limit_to</string>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"filter2group\">\n     <property name=\"title\">\n      <string>decks_filter_2</string>\n     </property>\n     <layout class=\"QGridLayout\" name=\"gridLayout_3\">\n      <item row=\"0\" column=\"1\" colspan=\"4\">\n       <widget class=\"QLineEdit\" name=\"search_2\"/>\n      </item>\n      <item row=\"0\" column=\"0\">\n       <widget class=\"QPushButton\" name=\"search_button_2\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"focusPolicy\">\n         <enum>Qt::NoFocus</enum>\n        </property>\n        <property name=\"toolTip\">\n         <string>search_view_in_browser</string>\n        </property>\n        <property name=\"text\">\n         <string>actions_search</string>\n        </property>\n        <property name=\"autoDefault\">\n         <bool>false</bool>\n        </property>\n        <property name=\"flat\">\n         <bool>true</bool>\n        </property>\n        <property name=\"label\" stdset=\"0\">\n         <string notr=\"true\">search</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"3\" colspan=\"2\">\n       <widget class=\"QComboBox\" name=\"order_2\"/>\n      </item>\n      <item row=\"1\" column=\"0\">\n       <widget class=\"QLabel\" name=\"label_6\">\n        <property name=\"text\">\n         <string>decks_limit_to</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"1\">\n       <widget class=\"QSpinBox\" name=\"limit_2\">\n        <property name=\"sizePolicy\">\n         <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n          <horstretch>0</horstretch>\n          <verstretch>0</verstretch>\n         </sizepolicy>\n        </property>\n        <property name=\"minimum\">\n         <number>1</number>\n        </property>\n        <property name=\"maximum\">\n         <number>99999</number>\n        </property>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"2\">\n       <widget class=\"QLabel\" name=\"label_4\">\n        <property name=\"text\">\n         <string>decks_cards_selected_by</string>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox_2\">\n     <property name=\"title\">\n      <string>actions_options</string>\n     </property>\n     <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n      <item row=\"1\" column=\"0\">\n       <widget class=\"QWidget\" name=\"previewDelayWidget\" native=\"true\">\n        <layout class=\"QGridLayout\" name=\"gridLayout_5\">\n         <item row=\"3\" column=\"0\">\n          <widget class=\"QLabel\" name=\"good_delay_label\">\n           <property name=\"text\">\n            <string notr=\"true\">good delay</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"2\">\n          <widget class=\"QSpinBox\" name=\"preview_again\">\n           <property name=\"maximum\">\n            <number>99999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"2\">\n          <widget class=\"QSpinBox\" name=\"preview_hard\">\n           <property name=\"maximum\">\n            <number>99999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"0\" colspan=\"2\">\n          <widget class=\"QLabel\" name=\"again_delay_label\">\n           <property name=\"text\">\n            <string notr=\"true\">again delay</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"2\">\n          <widget class=\"QSpinBox\" name=\"preview_good\">\n           <property name=\"maximum\">\n            <number>99999</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"3\">\n          <widget class=\"QLabel\" name=\"label_13\">\n           <property name=\"text\">\n            <string>scheduling_seconds</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"3\" colspan=\"2\">\n          <widget class=\"QLabel\" name=\"label_9\">\n           <property name=\"text\">\n            <string>scheduling_seconds</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"0\">\n          <widget class=\"QLabel\" name=\"hard_delay_label\">\n           <property name=\"text\">\n            <string notr=\"true\">hard delay</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"3\">\n          <widget class=\"QLabel\" name=\"label_8\">\n           <property name=\"text\">\n            <string>scheduling_seconds</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"4\" column=\"0\" colspan=\"2\">\n          <widget class=\"QLabel\" name=\"label_12\">\n           <property name=\"text\">\n            <string>decks_zero_minutes_hint</string>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"3\" column=\"0\" alignment=\"Qt::AlignLeft\">\n       <widget class=\"QCheckBox\" name=\"allow_empty\">\n        <property name=\"text\">\n         <string>decks_create_even_if_empty</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"2\" column=\"0\" alignment=\"Qt::AlignLeft\">\n       <widget class=\"QCheckBox\" name=\"secondFilter\">\n        <property name=\"text\">\n         <string>decks_enable_second_filter</string>\n        </property>\n       </widget>\n      </item>\n      <item row=\"0\" column=\"0\" colspan=\"2\" alignment=\"Qt::AlignLeft\">\n       <widget class=\"QCheckBox\" name=\"resched\">\n        <property name=\"text\">\n         <string>decks_reschedule_cards_based_on_my_answers</string>\n        </property>\n        <property name=\"checked\">\n         <bool>true</bool>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <spacer name=\"horizontalSpacer_2\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>40</width>\n         <height>20</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n     <item>\n      <widget class=\"QPushButton\" name=\"hint_button\">\n       <property name=\"focusPolicy\">\n        <enum>Qt::NoFocus</enum>\n       </property>\n       <property name=\"toolTip\">\n        <string>search_view_in_browser</string>\n       </property>\n       <property name=\"text\">\n        <string>decks_unmovable_cards</string>\n       </property>\n       <property name=\"autoDefault\">\n        <bool>false</bool>\n       </property>\n       <property name=\"flat\">\n        <bool>true</bool>\n       </property>\n       <property name=\"label\" stdset=\"0\">\n        <string notr=\"true\">hint</string>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>name</tabstop>\n  <tabstop>search</tabstop>\n  <tabstop>limit</tabstop>\n  <tabstop>order</tabstop>\n  <tabstop>search_2</tabstop>\n  <tabstop>limit_2</tabstop>\n  <tabstop>order_2</tabstop>\n  <tabstop>resched</tabstop>\n  <tabstop>preview_again</tabstop>\n  <tabstop>preview_hard</tabstop>\n  <tabstop>preview_good</tabstop>\n  <tabstop>secondFilter</tabstop>\n  <tabstop>allow_empty</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>254</x>\n     <y>295</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>322</x>\n     <y>295</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>secondFilter</sender>\n   <signal>toggled(bool)</signal>\n   <receiver>filter2group</receiver>\n   <slot>setVisible(bool)</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>125</x>\n     <y>265</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>222</x>\n     <y>155</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/finddupes.py",
    "content": "from _aqt.forms.finddupes_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/finddupes.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>531</width>\n    <height>345</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_find_duplicates</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"1\" column=\"2\" colspan=\"2\">\n      <widget class=\"QComboBox\" name=\"fields\"/>\n     </item>\n     <item row=\"2\" column=\"1\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>browsing_optional_filter</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>browsing_search_in</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"2\" colspan=\"2\">\n      <widget class=\"QComboBox\" name=\"search\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Fixed\">\n         <horstretch>9</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"editable\">\n        <bool>true</bool>\n       </property>\n       <property name=\"insertPolicy\">\n        <enum>QComboBox::NoInsert</enum>\n       </property>\n       <property name=\"sizeAdjustPolicy\">\n        <enum>QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon</enum>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QFrame\" name=\"frame\">\n     <property name=\"frameShape\">\n      <enum>QFrame::StyledPanel</enum>\n     </property>\n     <property name=\"frameShadow\">\n      <enum>QFrame::Raised</enum>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n      <property name=\"leftMargin\">\n       <number>0</number>\n      </property>\n      <property name=\"topMargin\">\n       <number>0</number>\n      </property>\n      <property name=\"rightMargin\">\n       <number>0</number>\n      </property>\n      <property name=\"bottomMargin\">\n       <number>0</number>\n      </property>\n      <item>\n       <widget class=\"FindDupesWebView\" name=\"webView\" native=\"true\">\n        <property name=\"url\" stdset=\"0\">\n         <url>\n          <string notr=\"true\">about:blank</string>\n         </url>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>FindDupesWebView</class>\n   <extends>QWidget</extends>\n   <header location=\"global\">aqt/webview</header>\n   <container>1</container>\n  </customwidget>\n </customwidgets>\n <tabstops>\n  <tabstop>fields</tabstop>\n  <tabstop>webView</tabstop>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/findreplace.py",
    "content": "from _aqt.forms.findreplace_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/findreplace.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>479</width>\n    <height>247</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_find_and_replace</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QComboBox\" name=\"replace\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Fixed\">\n         <horstretch>9</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"editable\">\n        <bool>true</bool>\n       </property>\n       <property name=\"insertPolicy\">\n        <enum>QComboBox::NoInsert</enum>\n       </property>\n       <property name=\"sizeAdjustPolicy\">\n        <enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>browsing_replace_with</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_3\">\n       <property name=\"text\">\n        <string>browsing_in</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>browsing_find</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"2\" column=\"1\">\n      <widget class=\"QComboBox\" name=\"field\">\n       <property name=\"sizeAdjustPolicy\">\n        <enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>\n       </property>\n      </widget>\n     </item>\n     <item row=\"5\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"re\">\n       <property name=\"text\">\n        <string>browsing_treat_input_as_regular_expression</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"4\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"ignoreCase\">\n       <property name=\"text\">\n        <string>browsing_ignore_case</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QComboBox\" name=\"find\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Fixed\">\n         <horstretch>9</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n       <property name=\"editable\">\n        <bool>true</bool>\n       </property>\n       <property name=\"insertPolicy\">\n        <enum>QComboBox::NoInsert</enum>\n       </property>\n       <property name=\"sizeAdjustPolicy\">\n        <enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>\n       </property>\n      </widget>\n     </item>\n     <item row=\"3\" column=\"1\" alignment=\"Qt::AlignLeft\">\n      <widget class=\"QCheckBox\" name=\"selected_notes\">\n       <property name=\"text\">\n        <string>browsing_selected_notes_only</string>\n       </property>\n       <property name=\"checked\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>find</tabstop>\n  <tabstop>replace</tabstop>\n  <tabstop>field</tabstop>\n  <tabstop>selected_notes</tabstop>\n  <tabstop>ignoreCase</tabstop>\n  <tabstop>re</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>256</x>\n     <y>154</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>290</x>\n     <y>154</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/forget.py",
    "content": "from _aqt.forms.forget_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/forget.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>235</width>\n    <height>118</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>actions_forget_card</string>\n  </property>\n  <property name=\"modal\">\n   <bool>true</bool>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <item alignment=\"Qt::AlignLeft\">\n    <widget class=\"QCheckBox\" name=\"restore_position\">\n     <property name=\"text\">\n      <string>scheduling_restore_position</string>\n     </property>\n    </widget>\n   </item>\n   <item alignment=\"Qt::AlignLeft\">\n    <widget class=\"QCheckBox\" name=\"reset_counts\">\n     <property name=\"text\">\n      <string>scheduling_reset_counts</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/getaddons.py",
    "content": "from _aqt.forms.getaddons_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/getaddons.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>367</width>\n    <height>204</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>addons_install_addon</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>addons_to_browse_addons_please_click_the</string>\n     </property>\n     <property name=\"wordWrap\">\n      <bool>true</bool>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>addons_code</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QLineEdit\" name=\"code\"/>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/importing.py",
    "content": "from _aqt.forms.importing_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/importing.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>ImportDialog</class>\n <widget class=\"QDialog\" name=\"ImportDialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>553</width>\n    <height>466</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>actions_import</string>\n  </property>\n  <layout class=\"QVBoxLayout\">\n   <item>\n    <widget class=\"QGroupBox\" name=\"groupBox\">\n     <property name=\"title\">\n      <string>importing_import_options</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"toplayout\">\n      <item>\n       <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n        <item row=\"0\" column=\"3\">\n         <widget class=\"QWidget\" name=\"deckArea\" native=\"true\"/>\n        </item>\n        <item row=\"0\" column=\"1\">\n         <widget class=\"QWidget\" name=\"modelArea\" native=\"true\"/>\n        </item>\n        <item row=\"0\" column=\"0\">\n         <widget class=\"QLabel\" name=\"label\">\n          <property name=\"text\">\n           <string>notetypes_type</string>\n          </property>\n         </widget>\n        </item>\n        <item row=\"0\" column=\"2\">\n         <widget class=\"QLabel\" name=\"label_2\">\n          <property name=\"text\">\n           <string>decks_deck</string>\n          </property>\n         </widget>\n        </item>\n       </layout>\n      </item>\n      <item>\n       <widget class=\"QPushButton\" name=\"autoDetect\">\n        <property name=\"text\">\n         <string/>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <widget class=\"QComboBox\" name=\"importMode\">\n        <item>\n         <property name=\"text\">\n          <string>importing_update_existing_notes_when_first_field</string>\n         </property>\n        </item>\n        <item>\n         <property name=\"text\">\n          <string>importing_ignore_lines_where_first_field_matches</string>\n         </property>\n        </item>\n        <item>\n         <property name=\"text\">\n          <string>importing_import_even_if_existing_note_has</string>\n         </property>\n        </item>\n       </widget>\n      </item>\n      <item alignment=\"Qt::AlignLeft\">\n       <widget class=\"QCheckBox\" name=\"allowHTML\">\n        <property name=\"text\">\n         <string>importing_allow_html_in_fields</string>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <layout class=\"QHBoxLayout\" name=\"tagModifiedLayout\">\n        <item>\n         <widget class=\"QLabel\" name=\"tagModifiedLabel\">\n          <property name=\"text\">\n           <string>importing_tag_modified_notes</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"TagEdit\" name=\"tagModified\"/>\n        </item>\n       </layout>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QGroupBox\" name=\"mappingGroup\">\n     <property name=\"sizePolicy\">\n      <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n       <horstretch>0</horstretch>\n       <verstretch>0</verstretch>\n      </sizepolicy>\n     </property>\n     <property name=\"title\">\n      <string>importing_field_mapping</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n      <item>\n       <layout class=\"QGridLayout\" name=\"gridLayout\">\n        <item row=\"0\" column=\"0\">\n         <widget class=\"QScrollArea\" name=\"mappingArea\">\n          <property name=\"sizePolicy\">\n           <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"MinimumExpanding\">\n            <horstretch>0</horstretch>\n            <verstretch>0</verstretch>\n           </sizepolicy>\n          </property>\n          <property name=\"minimumSize\">\n           <size>\n            <width>400</width>\n            <height>150</height>\n           </size>\n          </property>\n          <property name=\"frameShape\">\n           <enum>QFrame::NoFrame</enum>\n          </property>\n          <property name=\"widgetResizable\">\n           <bool>true</bool>\n          </property>\n          <widget class=\"QWidget\" name=\"scrollAreaWidgetContents\">\n           <property name=\"geometry\">\n            <rect>\n             <x>0</x>\n             <y>0</y>\n             <width>529</width>\n             <height>251</height>\n            </rect>\n           </property>\n          </widget>\n         </widget>\n        </item>\n       </layout>\n      </item>\n     </layout>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>TagEdit</class>\n   <extends>QLineEdit</extends>\n   <header>aqt/tagedit.h</header>\n  </customwidget>\n </customwidgets>\n <tabstops>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>ImportDialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>ImportDialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/main.py",
    "content": "from _aqt.forms.main_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/main.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>MainWindow</class>\n <widget class=\"QMainWindow\" name=\"MainWindow\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>667</width>\n    <height>570</height>\n   </rect>\n  </property>\n  <property name=\"sizePolicy\">\n   <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n    <horstretch>0</horstretch>\n    <verstretch>0</verstretch>\n   </sizepolicy>\n  </property>\n  <property name=\"minimumSize\">\n   <size>\n    <width>400</width>\n    <height>0</height>\n   </size>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Anki</string>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset resource=\"icons.qrc\">\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"centralwidget\">\n   <property name=\"sizePolicy\">\n    <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n     <horstretch>1</horstretch>\n     <verstretch>1</verstretch>\n    </sizepolicy>\n   </property>\n   <property name=\"autoFillBackground\">\n    <bool>true</bool>\n   </property>\n  </widget>\n  <widget class=\"QMenuBar\" name=\"menubar\">\n   <property name=\"geometry\">\n    <rect>\n     <x>0</x>\n     <y>0</y>\n     <width>667</width>\n     <height>43</height>\n    </rect>\n   </property>\n   <widget class=\"QMenu\" name=\"menuHelp\">\n    <property name=\"title\">\n     <string>qt_accel_help</string>\n    </property>\n    <addaction name=\"actionDocumentation\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionDonate\"/>\n    <addaction name=\"actionAbout\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuEdit\">\n    <property name=\"title\">\n     <string>qt_accel_edit</string>\n    </property>\n    <addaction name=\"actionUndo\"/>\n    <addaction name=\"actionRedo\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuCol\">\n    <property name=\"title\">\n     <string>qt_accel_file</string>\n    </property>\n    <addaction name=\"actionSwitchProfile\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionImport\"/>\n    <addaction name=\"actionExport\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"action_create_backup\"/>\n    <addaction name=\"action_open_backup\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionExit\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuTools\">\n    <property name=\"title\">\n     <string>qt_accel_tools</string>\n    </property>\n    <addaction name=\"actionStudyDeck\"/>\n    <addaction name=\"actionCreateFiltered\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionFullDatabaseCheck\"/>\n    <addaction name=\"actionCheckMediaDatabase\"/>\n    <addaction name=\"actionEmptyCards\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionAdd_ons\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionNoteTypes\"/>\n    <addaction name=\"action_upgrade_downgrade\"/>\n    <addaction name=\"actionPreferences\"/>\n   </widget>\n   <widget class=\"QMenu\" name=\"menuqt_accel_view\">\n    <property name=\"title\">\n     <string>qt_accel_view</string>\n    </property>\n    <addaction name=\"actionFullScreen\"/>\n    <addaction name=\"separator\"/>\n    <addaction name=\"actionZoomIn\"/>\n    <addaction name=\"actionZoomOut\"/>\n    <addaction name=\"actionResetZoom\"/>\n   </widget>\n   <addaction name=\"menuCol\"/>\n   <addaction name=\"menuEdit\"/>\n   <addaction name=\"menuqt_accel_view\"/>\n   <addaction name=\"menuTools\"/>\n   <addaction name=\"menuHelp\"/>\n  </widget>\n  <action name=\"actionExit\">\n   <property name=\"text\">\n    <string>qt_accel_exit</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Q</string>\n   </property>\n  </action>\n  <action name=\"actionPreferences\">\n   <property name=\"text\">\n    <string>qt_accel_preferences</string>\n   </property>\n   <property name=\"statusTip\">\n    <string>qt_misc_configure_interface_language_and_options</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+P</string>\n   </property>\n   <property name=\"menuRole\">\n    <enum>QAction::MenuRole::PreferencesRole</enum>\n   </property>\n  </action>\n  <action name=\"actionAbout\">\n   <property name=\"text\">\n    <string>qt_accel_about</string>\n   </property>\n   <property name=\"menuRole\">\n    <enum>QAction::MenuRole::ApplicationSpecificRole</enum>\n   </property>\n  </action>\n  <action name=\"actionUndo\">\n   <property name=\"enabled\">\n    <bool>false</bool>\n   </property>\n   <property name=\"text\">\n    <string>qt_accel_undo</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Z</string>\n   </property>\n  </action>\n  <action name=\"actionCheckMediaDatabase\">\n   <property name=\"text\">\n    <string>qt_accel_check_media</string>\n   </property>\n   <property name=\"statusTip\">\n    <string>qt_misc_check_the_files_in_the_media</string>\n   </property>\n  </action>\n  <action name=\"actionDonate\">\n   <property name=\"text\">\n    <string>qt_accel_support_anki</string>\n   </property>\n  </action>\n  <action name=\"actionFullDatabaseCheck\">\n   <property name=\"text\">\n    <string>qt_accel_check_database</string>\n   </property>\n  </action>\n  <action name=\"actionDocumentation\">\n   <property name=\"text\">\n    <string>qt_accel_guide</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">F1</string>\n   </property>\n  </action>\n  <action name=\"actionSwitchProfile\">\n   <property name=\"text\">\n    <string>qt_accel_switch_profile</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+P</string>\n   </property>\n  </action>\n  <action name=\"actionExport\">\n   <property name=\"text\">\n    <string>qt_accel_export</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+E</string>\n   </property>\n  </action>\n  <action name=\"actionImport\">\n   <property name=\"text\">\n    <string>qt_accel_import</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+I</string>\n   </property>\n  </action>\n  <action name=\"actionStudyDeck\">\n   <property name=\"text\">\n    <string>qt_misc_study_deck</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">/</string>\n   </property>\n  </action>\n  <action name=\"actionEmptyCards\">\n   <property name=\"text\">\n    <string>qt_misc_empty_cards</string>\n   </property>\n  </action>\n  <action name=\"actionCreateFiltered\">\n   <property name=\"text\">\n    <string>qt_misc_create_filtered_deck</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">F</string>\n   </property>\n  </action>\n  <action name=\"actionNoteTypes\">\n   <property name=\"text\">\n    <string>qt_misc_manage_note_types</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+N</string>\n   </property>\n  </action>\n  <action name=\"actionAdd_ons\">\n   <property name=\"text\">\n    <string>qt_misc_addons</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+A</string>\n   </property>\n  </action>\n  <action name=\"actionRedo\">\n   <property name=\"enabled\">\n    <bool>false</bool>\n   </property>\n   <property name=\"text\">\n    <string>qt_accel_redo</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+Shift+Z</string>\n   </property>\n  </action>\n  <action name=\"actionFullScreen\">\n   <property name=\"text\">\n    <string>qt_accel_full_screen</string>\n   </property>\n  </action>\n  <action name=\"actionZoomIn\">\n   <property name=\"text\">\n    <string>qt_accel_zoom_in</string>\n   </property>\n  </action>\n  <action name=\"actionZoomOut\">\n   <property name=\"text\">\n    <string>qt_accel_zoom_out</string>\n   </property>\n  </action>\n  <action name=\"actionResetZoom\">\n   <property name=\"text\">\n    <string>qt_accel_reset_zoom</string>\n   </property>\n   <property name=\"shortcut\">\n    <string notr=\"true\">Ctrl+0</string>\n   </property>\n  </action>\n  <action name=\"action_create_backup\">\n   <property name=\"text\">\n    <string>qt_accel_create_backup</string>\n   </property>\n  </action>\n  <action name=\"action_open_backup\">\n   <property name=\"text\">\n    <string>qt_accel_load_backup</string>\n   </property>\n  </action>\n  <action name=\"action_upgrade_downgrade\">\n   <property name=\"text\">\n    <string>qt_accel_upgrade_downgrade</string>\n   </property>\n  </action>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/modelopts.py",
    "content": "from _aqt.forms.modelopts_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/modelopts.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"windowModality\">\n   <enum>Qt::ApplicationModal</enum>\n  </property>\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>374</width>\n    <height>344</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string/>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <item>\n    <widget class=\"QTabWidget\" name=\"qtabwidget\">\n     <property name=\"currentIndex\">\n      <number>0</number>\n     </property>\n     <widget class=\"QWidget\" name=\"tab\">\n      <attribute name=\"title\">\n       <string>editing_latex</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n       <item alignment=\"Qt::AlignLeft\">\n        <widget class=\"QCheckBox\" name=\"latexsvg\">\n         <property name=\"text\">\n          <string>notetypes_create_scalable_images_with_dvisvgm</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"label_6\">\n         <property name=\"text\">\n          <string>notetypes_header</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QTextEdit\" name=\"latexHeader\">\n         <property name=\"tabChangesFocus\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"label_7\">\n         <property name=\"text\">\n          <string>notetypes_footer</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QTextEdit\" name=\"latexFooter\">\n         <property name=\"tabChangesFocus\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n      </layout>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>qtabwidget</tabstop>\n  <tabstop>buttonBox</tabstop>\n  <tabstop>latexHeader</tabstop>\n  <tabstop>latexFooter</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>275</x>\n     <y>442</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>343</x>\n     <y>442</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/models.py",
    "content": "from _aqt.forms.models_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/models.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"windowModality\">\n   <enum>Qt::ApplicationModal</enum>\n  </property>\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>396</width>\n    <height>255</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>notetypes_note_types</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_5\" stretch=\"100,0\">\n   <property name=\"spacing\">\n    <number>0</number>\n   </property>\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n     <property name=\"spacing\">\n      <number>6</number>\n     </property>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QListWidget\" name=\"modelsList\">\n       <property name=\"sizePolicy\">\n        <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n         <horstretch>0</horstretch>\n         <verstretch>0</verstretch>\n        </sizepolicy>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n       <property name=\"spacing\">\n        <number>12</number>\n       </property>\n       <item>\n        <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n         <property name=\"orientation\">\n          <enum>Qt::Vertical</enum>\n         </property>\n         <property name=\"standardButtons\">\n          <set>QDialogButtonBox::Close|QDialogButtonBox::Help</set>\n         </property>\n        </widget>\n       </item>\n      </layout>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer_2\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeType\">\n      <enum>QSizePolicy::Minimum</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>6</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>modelsList</tabstop>\n </tabstops>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>252</x>\n     <y>513</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>320</x>\n     <y>513</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/preferences.py",
    "content": "from _aqt.forms.preferences_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/preferences.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Preferences</class>\n <widget class=\"QDialog\" name=\"Preferences\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>636</width>\n    <height>638</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>preferences_preferences</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <item>\n    <widget class=\"QTabWidget\" name=\"tabWidget\">\n     <property name=\"focusPolicy\">\n      <enum>Qt::FocusPolicy::StrongFocus</enum>\n     </property>\n     <property name=\"currentIndex\">\n      <number>0</number>\n     </property>\n     <widget class=\"QWidget\" name=\"appearanceTab\">\n      <attribute name=\"title\">\n       <string>preferences_appearance</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_8\">\n       <item>\n        <widget class=\"QGroupBox\" name=\"generalGroup\">\n         <property name=\"sizePolicy\">\n          <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n           <horstretch>0</horstretch>\n           <verstretch>0</verstretch>\n          </sizepolicy>\n         </property>\n         <property name=\"title\">\n          <string>preferences_general</string>\n         </property>\n         <layout class=\"QGridLayout\" name=\"gridLayout_5\">\n          <item row=\"0\" column=\"0\">\n           <widget class=\"QLabel\" name=\"label_9\">\n            <property name=\"text\">\n             <string>preferences_language</string>\n            </property>\n            <property name=\"buddy\">\n             <cstring>lang</cstring>\n            </property>\n           </widget>\n          </item>\n          <item row=\"1\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"video_driver\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n           </widget>\n          </item>\n          <item row=\"1\" column=\"0\">\n           <widget class=\"QLabel\" name=\"video_driver_label\">\n            <property name=\"text\">\n             <string>preferences_video_driver</string>\n            </property>\n            <property name=\"buddy\">\n             <cstring>video_driver</cstring>\n            </property>\n           </widget>\n          </item>\n          <item row=\"0\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"lang\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"sizeAdjustPolicy\">\n             <enum>QComboBox::SizeAdjustPolicy::AdjustToMinimumContentsLengthWithIcon</enum>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"updatesGroup\">\n         <property name=\"title\">\n          <string>preferences_updates</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"updatesLayout\">\n          <item>\n           <widget class=\"QCheckBox\" name=\"check_for_updates\">\n            <property name=\"text\">\n             <string>preferences_check_for_updates</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"check_for_addon_updates\">\n            <property name=\"text\">\n             <string>preferences_check_for_addon_updates</string>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"uiGroup\">\n         <property name=\"title\">\n          <string>preferences_user_interface</string>\n         </property>\n         <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n          <item row=\"0\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"theme\"/>\n          </item>\n          <item row=\"2\" column=\"1\">\n           <widget class=\"QSpinBox\" name=\"uiScale\">\n            <property name=\"suffix\">\n             <string notr=\"true\">%</string>\n            </property>\n            <property name=\"minimum\">\n             <number>100</number>\n            </property>\n            <property name=\"maximum\">\n             <number>200</number>\n            </property>\n            <property name=\"singleStep\">\n             <number>5</number>\n            </property>\n           </widget>\n          </item>\n          <item row=\"1\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"styleComboBox\">\n            <property name=\"currentText\">\n             <string/>\n            </property>\n           </widget>\n          </item>\n          <item row=\"1\" column=\"0\">\n           <widget class=\"QLabel\" name=\"styleLabel\">\n            <property name=\"text\">\n             <string>preferences_style</string>\n            </property>\n            <property name=\"buddy\">\n             <cstring>styleComboBox</cstring>\n            </property>\n           </widget>\n          </item>\n          <item row=\"0\" column=\"0\">\n           <widget class=\"QLabel\" name=\"themeLabel\">\n            <property name=\"text\">\n             <string>preferences_theme</string>\n            </property>\n            <property name=\"buddy\">\n             <cstring>theme</cstring>\n            </property>\n           </widget>\n          </item>\n          <item row=\"2\" column=\"0\">\n           <widget class=\"QLabel\" name=\"uiSizeLabel\">\n            <property name=\"text\">\n             <string>preferences_user_interface_size</string>\n            </property>\n            <property name=\"buddy\">\n             <cstring>uiScale</cstring>\n            </property>\n           </widget>\n          </item>\n          <item row=\"3\" column=\"0\" colspan=\"2\">\n           <widget class=\"QPushButton\" name=\"resetWindowSizes\">\n            <property name=\"text\">\n             <string>preferences_reset_window_sizes</string>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"distractionsGroup\">\n         <property name=\"title\">\n          <string>preferences_distractions</string>\n         </property>\n         <layout class=\"QGridLayout\" name=\"gridLayout_3\">\n          <item row=\"3\" column=\"0\">\n           <widget class=\"QCheckBox\" name=\"reduce_motion\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"toolTip\">\n             <string>preferences_reduce_motion_tooltip</string>\n            </property>\n            <property name=\"text\">\n             <string>preferences_reduce_motion</string>\n            </property>\n           </widget>\n          </item>\n          <item row=\"2\" column=\"0\">\n           <widget class=\"QCheckBox\" name=\"hide_bottom_bar\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_hide_bottom_bar_during_review</string>\n            </property>\n           </widget>\n          </item>\n          <item row=\"2\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"bottomBarComboBox\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n           </widget>\n          </item>\n          <item row=\"0\" column=\"0\">\n           <widget class=\"QCheckBox\" name=\"hide_top_bar\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_hide_top_bar_during_review</string>\n            </property>\n           </widget>\n          </item>\n          <item row=\"4\" column=\"0\">\n           <widget class=\"QCheckBox\" name=\"minimalist_mode\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"toolTip\">\n             <string>preferences_minimalist_mode_tooltip</string>\n            </property>\n            <property name=\"text\">\n             <string>preferences_minimalist_mode</string>\n            </property>\n           </widget>\n          </item>\n          <item row=\"0\" column=\"1\">\n           <widget class=\"QComboBox\" name=\"topBarComboBox\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_9\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_3\">\n      <attribute name=\"title\">\n       <string>preferences_review</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"review_options_layout\">\n       <item>\n        <widget class=\"QGroupBox\" name=\"schedulerGroup\">\n         <property name=\"title\">\n          <string>preferences_scheduler</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_17\">\n          <item>\n           <layout class=\"QGridLayout\" name=\"gridLayout_4\">\n            <property name=\"spacing\">\n             <number>12</number>\n            </property>\n            <item row=\"1\" column=\"1\">\n             <widget class=\"QSpinBox\" name=\"lrnCutoff\">\n              <property name=\"maximumSize\">\n               <size>\n                <width>16777215</width>\n                <height>16777215</height>\n               </size>\n              </property>\n              <property name=\"suffix\">\n               <string>preferences_mins</string>\n              </property>\n              <property name=\"maximum\">\n               <number>999</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"2\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_30\">\n              <property name=\"text\">\n               <string>preferences_timebox_time_limit</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>timeLimit</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"0\" column=\"1\">\n             <widget class=\"QSpinBox\" name=\"dayOffset\">\n              <property name=\"maximumSize\">\n               <size>\n                <width>16777215</width>\n                <height>16777215</height>\n               </size>\n              </property>\n              <property name=\"suffix\">\n               <string>preferences_hours_past_midnight</string>\n              </property>\n              <property name=\"maximum\">\n               <number>23</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"1\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_24\">\n              <property name=\"text\">\n               <string>preferences_learn_ahead_limit</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>lrnCutoff</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"0\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_23\">\n              <property name=\"text\">\n               <string>preferences_next_day_starts_at</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>dayOffset</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"2\" column=\"1\">\n             <widget class=\"QSpinBox\" name=\"timeLimit\">\n              <property name=\"suffix\">\n               <string>preferences_mins</string>\n              </property>\n              <property name=\"maximum\">\n               <number>9999</number>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <layout class=\"QHBoxLayout\" name=\"horizontalLayout_5\">\n         <item>\n          <widget class=\"QGroupBox\" name=\"reviewerGroup\">\n           <property name=\"title\">\n            <string>preferences_review</string>\n           </property>\n           <layout class=\"QVBoxLayout\" name=\"verticalLayout_5\">\n            <item>\n             <widget class=\"QCheckBox\" name=\"showPlayButtons\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_show_play_buttons_on_cards_with</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QCheckBox\" name=\"interrupt_audio\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_interrupt_current_audio_when_answering</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QCheckBox\" name=\"showProgress\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_show_remaining_card_count</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QCheckBox\" name=\"showEstimates\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_show_next_review_time_above_answer</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QCheckBox\" name=\"spacebar_rates_card\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_spacebar_rates_card</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QCheckBox\" name=\"render_latex\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string>preferences_generate_latex_images_automatically</string>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QPushButton\" name=\"url_schemes\">\n              <property name=\"text\">\n               <string>preferences_url_schemes</string>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QGroupBox\" name=\"preferences_answer_keys\">\n           <property name=\"title\">\n            <string>preferences_answer_keys</string>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_12\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_1\">\n      <attribute name=\"title\">\n       <string>preferences_editing</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_10\">\n       <item>\n        <widget class=\"QGroupBox\" name=\"editorGroupBox\">\n         <property name=\"title\">\n          <string>preferences_editing</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_9\">\n          <item>\n           <widget class=\"QCheckBox\" name=\"pastePNG\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_paste_clipboard_images_as_png</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"paste_strips_formatting\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_paste_without_shift_key_strips_formatting</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <spacer name=\"verticalSpacer_7\">\n            <property name=\"orientation\">\n             <enum>Qt::Orientation::Vertical</enum>\n            </property>\n            <property name=\"sizeType\">\n             <enum>QSizePolicy::Policy::Fixed</enum>\n            </property>\n            <property name=\"sizeHint\" stdset=\"0\">\n             <size>\n              <width>20</width>\n              <height>10</height>\n             </size>\n            </property>\n           </spacer>\n          </item>\n          <item>\n           <layout class=\"QHBoxLayout\" name=\"horizontalLayout_7\">\n            <item>\n             <widget class=\"QLabel\" name=\"deck_label\">\n              <property name=\"text\">\n               <string>preferences_default_deck</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>useCurrent</cstring>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QComboBox\" name=\"useCurrent\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"MinimumExpanding\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <item>\n               <property name=\"text\">\n                <string>preferences_when_adding_default_to_current_deck</string>\n               </property>\n              </item>\n              <item>\n               <property name=\"text\">\n                <string>preferences_change_deck_depending_on_note_type</string>\n               </property>\n              </item>\n             </widget>\n            </item>\n           </layout>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"browserGroupBox\">\n         <property name=\"title\">\n          <string>preferences_browsing</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n          <item>\n           <layout class=\"QHBoxLayout\" name=\"horizontalLayout_2\">\n            <item>\n             <widget class=\"QLabel\" name=\"search_text_label\">\n              <property name=\"text\">\n               <string>preferences_default_search_text</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>default_search_text</cstring>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QLineEdit\" name=\"default_search_text\">\n              <property name=\"placeholderText\">\n               <string>preferences_default_search_text_example</string>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"ignore_accents_in_search\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_ignore_accents_in_search</string>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_3\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeType\">\n          <enum>QSizePolicy::Policy::Expanding</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>20</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_2\">\n      <attribute name=\"title\">\n       <string>preferences_network</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_4\">\n       <property name=\"spacing\">\n        <number>12</number>\n       </property>\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <widget class=\"QGroupBox\" name=\"groupBox_5\">\n         <property name=\"title\">\n          <string>preferences_tab_synchronisation</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_12\">\n          <item>\n           <widget class=\"QCheckBox\" name=\"syncMedia\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_synchronize_audio_and_images_too</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"syncOnProgramOpen\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_automatically_sync_on_profile_openclose</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"autoSyncMedia\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_periodically_sync_media</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QCheckBox\" name=\"fullSync\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>preferences_on_next_sync_force_changes_in</string>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <layout class=\"QHBoxLayout\" name=\"horizontalLayout_4\">\n            <item>\n             <widget class=\"QLabel\" name=\"label_2\">\n              <property name=\"text\">\n               <string>preferences_network_timeout</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>network_timeout</cstring>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QSpinBox\" name=\"network_timeout\">\n              <property name=\"suffix\">\n               <string>scheduling_seconds</string>\n              </property>\n              <property name=\"minimum\">\n               <number>30</number>\n              </property>\n              <property name=\"maximum\">\n               <number>99999</number>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </item>\n          <item>\n           <layout class=\"QHBoxLayout\" name=\"horizontalLayout_3\">\n            <item>\n             <spacer name=\"horizontalSpacer\">\n              <property name=\"orientation\">\n               <enum>Qt::Orientation::Horizontal</enum>\n              </property>\n              <property name=\"sizeHint\" stdset=\"0\">\n               <size>\n                <width>40</width>\n                <height>20</height>\n               </size>\n              </property>\n             </spacer>\n            </item>\n            <item>\n             <widget class=\"QPushButton\" name=\"media_log\">\n              <property name=\"sizePolicy\">\n               <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n                <horstretch>0</horstretch>\n                <verstretch>0</verstretch>\n               </sizepolicy>\n              </property>\n              <property name=\"text\">\n               <string/>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"groupBox_6\">\n         <property name=\"title\">\n          <string>preferences_account</string>\n         </property>\n         <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n          <item>\n           <widget class=\"QLabel\" name=\"syncUser\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string/>\n            </property>\n            <property name=\"wordWrap\">\n             <bool>true</bool>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QPushButton\" name=\"syncLogout\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>sync_log_out_button</string>\n            </property>\n            <property name=\"autoDefault\">\n             <bool>false</bool>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QPushButton\" name=\"syncLogin\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>sync_log_in_button</string>\n            </property>\n            <property name=\"autoDefault\">\n             <bool>false</bool>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_2\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_2\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>40</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n       <item>\n        <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n         <item row=\"0\" column=\"1\">\n          <widget class=\"QLineEdit\" name=\"custom_sync_url\">\n           <property name=\"placeholderText\">\n            <string>preferences_custom_sync_url_disclaimer</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"label\">\n           <property name=\"text\">\n            <string>preferences_custom_sync_url</string>\n           </property>\n           <property name=\"buddy\">\n            <cstring>custom_sync_url</cstring>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab\">\n      <attribute name=\"title\">\n       <string>preferences_backups</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n       <property name=\"spacing\">\n        <number>12</number>\n       </property>\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <widget class=\"QGroupBox\" name=\"groupBox_8\">\n         <property name=\"sizePolicy\">\n          <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n           <horstretch>0</horstretch>\n           <verstretch>0</verstretch>\n          </sizepolicy>\n         </property>\n         <property name=\"title\">\n          <string>preferences_backups</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_15\">\n          <item>\n           <widget class=\"QLabel\" name=\"backup_explanation\">\n            <property name=\"text\">\n             <string>preferences_backup_explanation</string>\n            </property>\n            <property name=\"wordWrap\">\n             <bool>true</bool>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <spacer name=\"verticalSpacer_5\">\n            <property name=\"orientation\">\n             <enum>Qt::Orientation::Vertical</enum>\n            </property>\n            <property name=\"sizeType\">\n             <enum>QSizePolicy::Policy::Fixed</enum>\n            </property>\n            <property name=\"sizeHint\" stdset=\"0\">\n             <size>\n              <width>20</width>\n              <height>20</height>\n             </size>\n            </property>\n           </spacer>\n          </item>\n          <item>\n           <layout class=\"QGridLayout\" name=\"gridLayout\">\n            <item row=\"1\" column=\"2\">\n             <widget class=\"QSpinBox\" name=\"minutes_between_backups\">\n              <property name=\"minimum\">\n               <number>5</number>\n              </property>\n              <property name=\"maximum\">\n               <number>9999</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"5\" column=\"2\">\n             <widget class=\"QSpinBox\" name=\"monthly_backups\">\n              <property name=\"maximum\">\n               <number>9999</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"1\" column=\"3\">\n             <spacer name=\"horizontalSpacer_2\">\n              <property name=\"orientation\">\n               <enum>Qt::Orientation::Horizontal</enum>\n              </property>\n              <property name=\"sizeHint\" stdset=\"0\">\n               <size>\n                <width>40</width>\n                <height>20</height>\n               </size>\n              </property>\n             </spacer>\n            </item>\n            <item row=\"2\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_3\">\n              <property name=\"text\">\n               <string>preferences_daily_backups</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>daily_backups</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"4\" column=\"2\">\n             <widget class=\"QSpinBox\" name=\"weekly_backups\">\n              <property name=\"maximum\">\n               <number>9999</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"5\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_6\">\n              <property name=\"text\">\n               <string>preferences_monthly_backups</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>monthly_backups</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"4\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_5\">\n              <property name=\"text\">\n               <string>preferences_weekly_backups</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>weekly_backups</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"1\" column=\"0\">\n             <widget class=\"QLabel\" name=\"label_7\">\n              <property name=\"text\">\n               <string>preferences_minutes_between_backups</string>\n              </property>\n              <property name=\"buddy\">\n               <cstring>minutes_between_backups</cstring>\n              </property>\n             </widget>\n            </item>\n            <item row=\"2\" column=\"2\">\n             <widget class=\"QSpinBox\" name=\"daily_backups\">\n              <property name=\"maximum\">\n               <number>9999</number>\n              </property>\n             </widget>\n            </item>\n            <item row=\"1\" column=\"1\">\n             <spacer name=\"horizontalSpacer_3\">\n              <property name=\"orientation\">\n               <enum>Qt::Orientation::Horizontal</enum>\n              </property>\n              <property name=\"sizeHint\" stdset=\"0\">\n               <size>\n                <width>40</width>\n                <height>20</height>\n               </size>\n              </property>\n             </spacer>\n            </item>\n           </layout>\n          </item>\n          <item>\n           <spacer name=\"verticalSpacer_6\">\n            <property name=\"orientation\">\n             <enum>Qt::Orientation::Vertical</enum>\n            </property>\n            <property name=\"sizeType\">\n             <enum>QSizePolicy::Policy::Fixed</enum>\n            </property>\n            <property name=\"sizeHint\" stdset=\"0\">\n             <size>\n              <width>20</width>\n              <height>20</height>\n             </size>\n            </property>\n           </spacer>\n          </item>\n          <item>\n           <widget class=\"QLabel\" name=\"openBackupFolder\">\n            <property name=\"text\">\n             <string>preferences_you_can_restore_backups_via_fileswitch</string>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"groupBox_9\">\n         <property name=\"title\">\n          <string>preferences_note</string>\n         </property>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_13\">\n          <item>\n           <widget class=\"QLabel\" name=\"label_4\">\n            <property name=\"text\">\n             <string>preferences_media_is_not_backed_up</string>\n            </property>\n            <property name=\"wordWrap\">\n             <bool>true</bool>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer_4\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>59</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_3\">\n      <attribute name=\"title\">\n       <string>preferences_third_party_services</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_4\">\n       <property name=\"spacing\">\n        <number>12</number>\n       </property>\n       <property name=\"leftMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>12</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>12</number>\n       </property>\n       <item>\n        <widget class=\"QLabel\" name=\"label\">\n         <property name=\"sizePolicy\">\n          <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n           <horstretch>0</horstretch>\n           <verstretch>0</verstretch>\n          </sizepolicy>\n         </property>\n         <property name=\"text\">\n          <string>preferences_third_party_description</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalSpacer\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeType\">\n          <enum>QSizePolicy::Policy::Maximum</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>20</width>\n           <height>12</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n       <item>\n        <widget class=\"QGroupBox\" name=\"groupBox_6\">\n         <property name=\"sizePolicy\">\n          <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Fixed\">\n           <horstretch>0</horstretch>\n           <verstretch>0</verstretch>\n          </sizepolicy>\n         </property>\n         <property name=\"title\">\n          <string notr=\"true\">AnkiHub</string>\n         </property>\n         <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n          <item>\n           <widget class=\"QLabel\" name=\"syncAnkiHubUser\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Preferred\" vsizetype=\"Preferred\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string/>\n            </property>\n            <property name=\"wordWrap\">\n             <bool>true</bool>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QPushButton\" name=\"syncAnkiHubLogout\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>sync_log_out_button</string>\n            </property>\n            <property name=\"autoDefault\">\n             <bool>false</bool>\n            </property>\n           </widget>\n          </item>\n          <item>\n           <widget class=\"QPushButton\" name=\"syncAnkiHubLogin\">\n            <property name=\"sizePolicy\">\n             <sizepolicy hsizetype=\"Fixed\" vsizetype=\"Fixed\">\n              <horstretch>0</horstretch>\n              <verstretch>0</verstretch>\n             </sizepolicy>\n            </property>\n            <property name=\"text\">\n             <string>sync_log_in_button</string>\n            </property>\n            <property name=\"autoDefault\">\n             <bool>false</bool>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"verticalspacer_13\">\n         <property name=\"orientation\">\n          <enum>Qt::Orientation::Vertical</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>40</width>\n           <height>20</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QLabel\" name=\"someSettingsLabel\">\n     <property name=\"text\">\n      <string>preferences_some_settings_will_take_effect_after</string>\n     </property>\n     <property name=\"alignment\">\n      <set>Qt::AlignmentFlag::AlignCenter</set>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Orientation::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::StandardButton::Close|QDialogButtonBox::StandardButton::Help</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>lang</tabstop>\n  <tabstop>video_driver</tabstop>\n  <tabstop>check_for_updates</tabstop>\n  <tabstop>check_for_addon_updates</tabstop>\n  <tabstop>theme</tabstop>\n  <tabstop>styleComboBox</tabstop>\n  <tabstop>uiScale</tabstop>\n  <tabstop>resetWindowSizes</tabstop>\n  <tabstop>hide_top_bar</tabstop>\n  <tabstop>topBarComboBox</tabstop>\n  <tabstop>hide_bottom_bar</tabstop>\n  <tabstop>bottomBarComboBox</tabstop>\n  <tabstop>reduce_motion</tabstop>\n  <tabstop>minimalist_mode</tabstop>\n  <tabstop>dayOffset</tabstop>\n  <tabstop>lrnCutoff</tabstop>\n  <tabstop>timeLimit</tabstop>\n  <tabstop>showPlayButtons</tabstop>\n  <tabstop>interrupt_audio</tabstop>\n  <tabstop>showProgress</tabstop>\n  <tabstop>showEstimates</tabstop>\n  <tabstop>spacebar_rates_card</tabstop>\n  <tabstop>render_latex</tabstop>\n  <tabstop>url_schemes</tabstop>\n  <tabstop>pastePNG</tabstop>\n  <tabstop>paste_strips_formatting</tabstop>\n  <tabstop>useCurrent</tabstop>\n  <tabstop>default_search_text</tabstop>\n  <tabstop>ignore_accents_in_search</tabstop>\n  <tabstop>syncMedia</tabstop>\n  <tabstop>syncOnProgramOpen</tabstop>\n  <tabstop>autoSyncMedia</tabstop>\n  <tabstop>fullSync</tabstop>\n  <tabstop>network_timeout</tabstop>\n  <tabstop>media_log</tabstop>\n  <tabstop>syncLogout</tabstop>\n  <tabstop>syncLogin</tabstop>\n  <tabstop>custom_sync_url</tabstop>\n  <tabstop>minutes_between_backups</tabstop>\n  <tabstop>daily_backups</tabstop>\n  <tabstop>weekly_backups</tabstop>\n  <tabstop>monthly_backups</tabstop>\n  <tabstop>syncAnkiHubLogout</tabstop>\n  <tabstop>syncAnkiHubLogin</tabstop>\n  <tabstop>buttonBox</tabstop>\n  <tabstop>tabWidget</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Preferences</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>285</x>\n     <y>439</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Preferences</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>332</x>\n     <y>439</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/preview.py",
    "content": "from _aqt.forms.preview_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/preview.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Form</class>\n <widget class=\"QWidget\" name=\"Form\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>717</width>\n    <height>636</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Form</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <widget class=\"QGroupBox\" name=\"preview_box\">\n     <property name=\"title\">\n      <string notr=\"true\">GroupBox</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n      <item>\n       <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"preview_front\">\n          <property name=\"text\">\n           <string notr=\"true\">FRONT</string>\n          </property>\n          <property name=\"checked\">\n           <bool>true</bool>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"preview_back\">\n          <property name=\"text\">\n           <string notr=\"true\">BACK</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <spacer name=\"horizontalSpacer\">\n          <property name=\"orientation\">\n           <enum>Qt::Horizontal</enum>\n          </property>\n          <property name=\"sizeHint\" stdset=\"0\">\n           <size>\n            <width>40</width>\n            <height>20</height>\n           </size>\n          </property>\n         </spacer>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"preview_settings\">\n          <property name=\"text\">\n           <string/>\n          </property>\n          <property name=\"autoDefault\">\n           <bool>false</bool>\n          </property>\n          <property name=\"default\">\n           <bool>false</bool>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QComboBox\" name=\"cloze_number_combo\"/>\n        </item>\n       </layout>\n      </item>\n     </layout>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/profiles.py",
    "content": "from _aqt.forms.profiles_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/profiles.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>MainWindow</class>\n <widget class=\"QMainWindow\" name=\"MainWindow\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>423</width>\n    <height>356</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>profiles_profiles</string>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset resource=\"icons.qrc\">\n    <normaloff>:/icons/anki.png</normaloff>:/icons/anki.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"centralwidget\">\n   <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n    <item>\n     <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n      <item>\n       <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n        <item>\n         <widget class=\"QListWidget\" name=\"profiles\"/>\n        </item>\n       </layout>\n      </item>\n      <item>\n       <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n        <item>\n         <widget class=\"QPushButton\" name=\"login\">\n          <property name=\"text\">\n           <string>profiles_open</string>\n          </property>\n          <property name=\"default\">\n           <bool>true</bool>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"add\">\n          <property name=\"text\">\n           <string>actions_add</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"rename\">\n          <property name=\"text\">\n           <string>actions_rename</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"delete_2\">\n          <property name=\"text\">\n           <string>actions_delete</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"quit\">\n          <property name=\"text\">\n           <string>profiles_quit</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <spacer name=\"verticalSpacer\">\n          <property name=\"orientation\">\n           <enum>Qt::Vertical</enum>\n          </property>\n          <property name=\"sizeHint\" stdset=\"0\">\n           <size>\n            <width>20</width>\n            <height>40</height>\n           </size>\n          </property>\n         </spacer>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"openBackup\">\n          <property name=\"text\">\n           <string>profiles_open_backup</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QPushButton\" name=\"downgrade_button\">\n          <property name=\"text\">\n           <string notr=\"true\">DOWNGRADE</string>\n          </property>\n         </widget>\n        </item>\n       </layout>\n      </item>\n     </layout>\n    </item>\n   </layout>\n  </widget>\n  <widget class=\"QMenuBar\" name=\"menubar\">\n   <property name=\"enabled\">\n    <bool>false</bool>\n   </property>\n   <property name=\"geometry\">\n    <rect>\n     <x>0</x>\n     <y>0</y>\n     <width>423</width>\n     <height>22</height>\n    </rect>\n   </property>\n  </widget>\n  <widget class=\"QStatusBar\" name=\"statusbar\">\n   <property name=\"enabled\">\n    <bool>false</bool>\n   </property>\n  </widget>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/progress.py",
    "content": "from _aqt.forms.progress_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/progress.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>310</width>\n    <height>69</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Dialog</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <property name=\"margin\">\n    <number>6</number>\n   </property>\n   <item>\n    <spacer name=\"verticalSpacer_2\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>0</width>\n       <height>0</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string/>\n     </property>\n     <property name=\"alignment\">\n      <set>Qt::AlignCenter</set>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QProgressBar\" name=\"progressBar\">\n     <property name=\"value\">\n      <number>24</number>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeType\">\n      <enum>QSizePolicy::MinimumExpanding</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>0</width>\n       <height>0</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/reposition.py",
    "content": "from _aqt.forms.reposition_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/reposition.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>299</width>\n    <height>229</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>browsing_reposition_new_cards</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string/>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <layout class=\"QGridLayout\" name=\"gridLayout\">\n     <item row=\"0\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_2\">\n       <property name=\"text\">\n        <string>browsing_start_position</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"0\" column=\"1\">\n      <widget class=\"QSpinBox\" name=\"start\">\n       <property name=\"minimum\">\n        <number>0</number>\n       </property>\n       <property name=\"maximum\">\n        <number>1000000</number>\n       </property>\n       <property name=\"value\">\n        <number>0</number>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"0\">\n      <widget class=\"QLabel\" name=\"label_3\">\n       <property name=\"text\">\n        <string>browsing_step</string>\n       </property>\n      </widget>\n     </item>\n     <item row=\"1\" column=\"1\">\n      <widget class=\"QSpinBox\" name=\"step\">\n       <property name=\"minimum\">\n        <number>1</number>\n       </property>\n       <property name=\"maximum\">\n        <number>10000</number>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item alignment=\"Qt::AlignLeft\">\n    <widget class=\"QCheckBox\" name=\"randomize\">\n     <property name=\"text\">\n      <string>browsing_randomize_order</string>\n     </property>\n    </widget>\n   </item>\n   <item alignment=\"Qt::AlignLeft\">\n    <widget class=\"QCheckBox\" name=\"shift\">\n     <property name=\"text\">\n      <string>browsing_shift_position_of_existing_cards</string>\n     </property>\n     <property name=\"checked\">\n      <bool>false</bool>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>start</tabstop>\n  <tabstop>step</tabstop>\n  <tabstop>randomize</tabstop>\n  <tabstop>shift</tabstop>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/setgroup.py",
    "content": "from _aqt.forms.setgroup_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/setgroup.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>433</width>\n    <height>143</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Anki</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>browsing_move_cards_to_deck</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <spacer name=\"verticalSpacer\">\n     <property name=\"orientation\">\n      <enum>Qt::Vertical</enum>\n     </property>\n     <property name=\"sizeHint\" stdset=\"0\">\n      <size>\n       <width>20</width>\n       <height>40</height>\n      </size>\n     </property>\n    </spacer>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <tabstops>\n  <tabstop>buttonBox</tabstop>\n </tabstops>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>224</x>\n     <y>192</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>213</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>292</x>\n     <y>198</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>213</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/setlang.py",
    "content": "from _aqt.forms.setlang_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/setlang.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>400</width>\n    <height>300</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Anki</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>preferences_language</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QListWidget\" name=\"lang\"/>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/stats.py",
    "content": "from _aqt.forms.stats_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/stats.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>607</width>\n    <height>556</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>statistics_title</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <property name=\"spacing\">\n    <number>0</number>\n   </property>\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <widget class=\"StatsWebView\" name=\"web\" native=\"true\">\n     <property name=\"url\" stdset=\"0\">\n      <url>\n       <string notr=\"true\">about:blank</string>\n      </url>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout_3\">\n     <property name=\"spacing\">\n      <number>8</number>\n     </property>\n     <property name=\"leftMargin\">\n      <number>16</number>\n     </property>\n     <property name=\"topMargin\">\n      <number>6</number>\n     </property>\n     <property name=\"rightMargin\">\n      <number>16</number>\n     </property>\n     <property name=\"bottomMargin\">\n      <number>6</number>\n     </property>\n     <item>\n      <widget class=\"QGroupBox\" name=\"groupBox_2\">\n       <property name=\"title\">\n        <string/>\n       </property>\n       <layout class=\"QHBoxLayout\" name=\"horizontalLayout_2\">\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"groups\">\n          <property name=\"text\">\n           <string notr=\"true\">deck</string>\n          </property>\n          <property name=\"checked\">\n           <bool>true</bool>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"all\">\n          <property name=\"text\">\n           <string notr=\"true\">collection</string>\n          </property>\n         </widget>\n        </item>\n       </layout>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QGroupBox\" name=\"groupBox\">\n       <property name=\"title\">\n        <string/>\n       </property>\n       <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"month\">\n          <property name=\"text\">\n           <string notr=\"true\">1 month</string>\n          </property>\n          <property name=\"checked\">\n           <bool>true</bool>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"year\">\n          <property name=\"text\">\n           <string notr=\"true\">1 year</string>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"life\">\n          <property name=\"text\">\n           <string notr=\"true\">deck life</string>\n          </property>\n         </widget>\n        </item>\n       </layout>\n      </widget>\n     </item>\n     <item>\n      <layout class=\"QHBoxLayout\" name=\"horizontalLayout_4\">\n       <property name=\"topMargin\">\n        <number>4</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>0</number>\n       </property>\n       <item alignment=\"Qt::AlignLeft\">\n        <widget class=\"QWidget\" name=\"deckArea\" native=\"true\"/>\n       </item>\n      </layout>\n     </item>\n     <item alignment=\"Qt::AlignRight\">\n      <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"standardButtons\">\n        <set>QDialogButtonBox::Close</set>\n       </property>\n       <property name=\"centerButtons\">\n        <bool>true</bool>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n  </layout>\n </widget>\n <customwidgets>\n  <customwidget>\n   <class>StatsWebView</class>\n   <extends>QWidget</extends>\n   <header location=\"global\">aqt/webview</header>\n   <container>1</container>\n  </customwidget>\n </customwidgets>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/studydeck.py",
    "content": "from _aqt.forms.studydeck_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/studydeck.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>400</width>\n    <height>300</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>decks_study_deck</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n     <item>\n      <widget class=\"QLabel\" name=\"label\">\n       <property name=\"text\">\n        <string>decks_filter</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QLineEdit\" name=\"filter\"/>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QListWidget\" name=\"list\"/>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Help</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/synclog.py",
    "content": "from _aqt.forms.synclog_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/synclog.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>482</width>\n    <height>90</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string/>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <widget class=\"QLabel\" name=\"log_label\">\n     <property name=\"text\">\n      <string notr=\"true\">TextLabel</string>\n     </property>\n     <property name=\"textFormat\">\n      <enum>Qt::PlainText</enum>\n     </property>\n     <property name=\"alignment\">\n      <set>Qt::AlignCenter</set>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/taglimit.py",
    "content": "from _aqt.forms.taglimit_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/taglimit.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>361</width>\n    <height>394</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>custom_study_selective_study</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item alignment=\"Qt::AlignLeft\">\n    <widget class=\"QCheckBox\" name=\"activeCheck\">\n     <property name=\"text\">\n      <string>custom_study_require_one_or_more_of_these</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QListWidget\" name=\"activeList\">\n     <property name=\"enabled\">\n      <bool>false</bool>\n     </property>\n     <property name=\"sizePolicy\">\n      <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n       <horstretch>0</horstretch>\n       <verstretch>2</verstretch>\n      </sizepolicy>\n     </property>\n     <property name=\"selectionMode\">\n      <enum>QAbstractItemView::MultiSelection</enum>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QLabel\" name=\"label\">\n     <property name=\"text\">\n      <string>custom_study_select_tags_to_exclude</string>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QListWidget\" name=\"inactiveList\">\n     <property name=\"enabled\">\n      <bool>true</bool>\n     </property>\n     <property name=\"sizePolicy\">\n      <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Expanding\">\n       <horstretch>0</horstretch>\n       <verstretch>2</verstretch>\n      </sizepolicy>\n     </property>\n     <property name=\"selectionMode\">\n      <enum>QAbstractItemView::MultiSelection</enum>\n     </property>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttonBox\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>358</x>\n     <y>264</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttonBox</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>activeCheck</sender>\n   <signal>toggled(bool)</signal>\n   <receiver>activeList</receiver>\n   <slot>setEnabled(bool)</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>133</x>\n     <y>18</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>133</x>\n     <y>85</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/template.py",
    "content": "from _aqt.forms.template_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/template.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Form</class>\n <widget class=\"QWidget\" name=\"Form\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>786</width>\n    <height>1081</height>\n   </rect>\n  </property>\n  <property name=\"sizePolicy\">\n   <sizepolicy hsizetype=\"Expanding\" vsizetype=\"Preferred\">\n    <horstretch>0</horstretch>\n    <verstretch>0</verstretch>\n   </sizepolicy>\n  </property>\n  <property name=\"windowTitle\">\n   <string>card_templates_form</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <widget class=\"QGroupBox\" name=\"template_box\">\n     <property name=\"title\">\n      <string notr=\"true\">GroupBox</string>\n     </property>\n     <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n      <property name=\"leftMargin\">\n       <number>12</number>\n      </property>\n      <property name=\"topMargin\">\n       <number>12</number>\n      </property>\n      <property name=\"rightMargin\">\n       <number>12</number>\n      </property>\n      <property name=\"bottomMargin\">\n       <number>12</number>\n      </property>\n      <item>\n       <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"front_button\">\n          <property name=\"toolTip\">\n           <string notr=\"true\"/>\n          </property>\n          <property name=\"text\">\n           <string notr=\"true\">FRONT</string>\n          </property>\n          <property name=\"checked\">\n           <bool>true</bool>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"back_button\">\n          <property name=\"toolTip\">\n           <string notr=\"true\"/>\n          </property>\n          <property name=\"text\">\n           <string notr=\"true\">BACK</string>\n          </property>\n         </widget>\n        </item>\n        <item alignment=\"Qt::AlignLeft\">\n         <widget class=\"QRadioButton\" name=\"style_button\">\n          <property name=\"toolTip\">\n           <string notr=\"true\"/>\n          </property>\n          <property name=\"text\">\n           <string notr=\"true\">STYLE</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <spacer name=\"horizontalSpacer\">\n          <property name=\"orientation\">\n           <enum>Qt::Horizontal</enum>\n          </property>\n          <property name=\"sizeHint\" stdset=\"0\">\n           <size>\n            <width>40</width>\n            <height>20</height>\n           </size>\n          </property>\n         </spacer>\n        </item>\n       </layout>\n      </item>\n      <item>\n       <widget class=\"QLineEdit\" name=\"search_edit\"/>\n      </item>\n      <item>\n       <widget class=\"QLabel\" name=\"changes_affect_label\">\n        <property name=\"text\">\n         <string notr=\"true\">CHANGES_WILL_AFFECT</string>\n        </property>\n        <property name=\"wordWrap\">\n         <bool>true</bool>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <widget class=\"QTextEdit\" name=\"edit_area\"/>\n      </item>\n     </layout>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources>\n  <include location=\"icons.qrc\"/>\n </resources>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/forms/widgets.py",
    "content": "from _aqt.forms.widgets_qt6 import *\n"
  },
  {
    "path": "qt/aqt/forms/widgets.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>925</width>\n    <height>822</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string notr=\"true\">Qt Widget Gallery</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"topLayout\">\n     <item>\n      <widget class=\"QLabel\" name=\"styleLabel\">\n       <property name=\"text\">\n        <string notr=\"true\">Style</string>\n       </property>\n       <property name=\"alignment\">\n        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QComboBox\" name=\"styleComboBox\">\n       <property name=\"currentText\">\n        <string notr=\"true\"/>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <spacer name=\"horizontalSpacer\">\n       <property name=\"orientation\">\n        <enum>Qt::Horizontal</enum>\n       </property>\n       <property name=\"sizeHint\" stdset=\"0\">\n        <size>\n         <width>40</width>\n         <height>20</height>\n        </size>\n       </property>\n      </spacer>\n     </item>\n     <item>\n      <widget class=\"QCheckBox\" name=\"disableCheckBox\">\n       <property name=\"text\">\n        <string notr=\"true\">Disable Widgets</string>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QWidget\" name=\"testGrid\" native=\"true\">\n     <layout class=\"QGridLayout\" name=\"gridLayout\">\n      <item row=\"0\" column=\"0\">\n       <widget class=\"QGroupBox\" name=\"checkButtonsGroup\">\n        <property name=\"title\">\n         <string notr=\"true\">Check Buttons</string>\n        </property>\n        <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n         <item>\n          <widget class=\"QRadioButton\" name=\"radioButtonNotCheckable\">\n           <property name=\"text\">\n            <string notr=\"true\">RadioButton (not checkable)</string>\n           </property>\n           <property name=\"checkable\">\n            <bool>false</bool>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QRadioButton\" name=\"radioButtonChecked\">\n           <property name=\"text\">\n            <string notr=\"true\">RadioButton (checked)</string>\n           </property>\n           <property name=\"checked\">\n            <bool>true</bool>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QRadioButton\" name=\"radioButtonUnchecked\">\n           <property name=\"text\">\n            <string notr=\"true\">RadioButton (unchecked)</string>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QCheckBox\" name=\"checkBoxTristate\">\n           <property name=\"text\">\n            <string notr=\"true\">CheckBox (tristate)</string>\n           </property>\n           <property name=\"checked\">\n            <bool>true</bool>\n           </property>\n           <property name=\"tristate\">\n            <bool>true</bool>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"0\" column=\"1\">\n       <widget class=\"QGroupBox\" name=\"buttonsGroup\">\n        <property name=\"title\">\n         <string notr=\"true\">Buttons</string>\n        </property>\n        <layout class=\"QVBoxLayout\" name=\"verticalLayout_4\">\n         <item>\n          <widget class=\"QPushButton\" name=\"pushButton\">\n           <property name=\"text\">\n            <string notr=\"true\">PushButton</string>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QPushButton\" name=\"pushButtonCheckable\">\n           <property name=\"text\">\n            <string notr=\"true\">PushButton (checkable)</string>\n           </property>\n           <property name=\"checkable\">\n            <bool>true</bool>\n           </property>\n           <property name=\"checked\">\n            <bool>true</bool>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QPushButton\" name=\"pushButtonFlat\">\n           <property name=\"text\">\n            <string notr=\"true\">PushButton (flat)</string>\n           </property>\n           <property name=\"autoDefault\">\n            <bool>true</bool>\n           </property>\n           <property name=\"flat\">\n            <bool>true</bool>\n           </property>\n          </widget>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"0\">\n       <widget class=\"QGroupBox\" name=\"calendarGroup\">\n        <property name=\"title\">\n         <string notr=\"true\">CalendarWidget</string>\n        </property>\n        <layout class=\"QGridLayout\" name=\"gridLayout_3\">\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QCalendarWidget\" name=\"calendarWidget\"/>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"1\" column=\"1\">\n       <widget class=\"QGroupBox\" name=\"textInputsGroup\">\n        <property name=\"title\">\n         <string notr=\"true\">Text Inputs</string>\n        </property>\n        <layout class=\"QVBoxLayout\" name=\"verticalLayout_8\">\n         <item>\n          <widget class=\"QComboBox\" name=\"comboBox\">\n           <property name=\"editable\">\n            <bool>true</bool>\n           </property>\n           <property name=\"currentText\">\n            <string notr=\"true\">ComboBox (editable)</string>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QLineEdit\" name=\"lineEdit\">\n           <property name=\"text\">\n            <string notr=\"true\"/>\n           </property>\n           <property name=\"placeholderText\">\n            <string notr=\"true\">LineEdit</string>\n           </property>\n          </widget>\n         </item>\n         <item>\n          <widget class=\"QSplitter\" name=\"splitter\">\n           <property name=\"orientation\">\n            <enum>Qt::Horizontal</enum>\n           </property>\n           <widget class=\"QPlainTextEdit\" name=\"plainTextEdit\">\n            <property name=\"plainText\">\n             <string notr=\"true\"/>\n            </property>\n            <property name=\"placeholderText\">\n             <string notr=\"true\">PlainTextEdit</string>\n            </property>\n           </widget>\n           <widget class=\"QTextEdit\" name=\"textEdit\">\n            <property name=\"documentTitle\">\n             <string notr=\"true\"/>\n            </property>\n            <property name=\"placeholderText\">\n             <string notr=\"true\">TextEdit</string>\n            </property>\n           </widget>\n          </widget>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"2\" column=\"0\">\n       <widget class=\"QGroupBox\" name=\"otherInputsGroup\">\n        <property name=\"title\">\n         <string notr=\"true\">Other Inputs</string>\n        </property>\n        <layout class=\"QGridLayout\" name=\"gridLayout_2\">\n         <item row=\"2\" column=\"1\" colspan=\"2\">\n          <widget class=\"QKeySequenceEdit\" name=\"keySequenceEdit\">\n           <property name=\"keySequence\">\n            <string notr=\"true\"/>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"1\" colspan=\"2\">\n          <widget class=\"QSpinBox\" name=\"spinBox\">\n           <property name=\"suffix\">\n            <string notr=\"true\"/>\n           </property>\n           <property name=\"prefix\">\n            <string notr=\"true\"/>\n           </property>\n           <property name=\"value\">\n            <number>1</number>\n           </property>\n          </widget>\n         </item>\n         <item row=\"2\" column=\"0\">\n          <widget class=\"QLabel\" name=\"keySequenceLabel\">\n           <property name=\"text\">\n            <string notr=\"true\">KeySequenceEdit</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"1\" column=\"0\">\n          <widget class=\"QLabel\" name=\"dateTimeLabel\">\n           <property name=\"text\">\n            <string notr=\"true\">DateTimeEdit</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"0\" column=\"0\">\n          <widget class=\"QLabel\" name=\"spinBoxLabel\">\n           <property name=\"text\">\n            <string notr=\"true\">SpinBox</string>\n           </property>\n          </widget>\n         </item>\n         <item row=\"3\" column=\"0\" colspan=\"3\">\n          <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n           <item>\n            <widget class=\"QLabel\" name=\"sliderLabel\">\n             <property name=\"text\">\n              <string notr=\"true\">Slider</string>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QSlider\" name=\"horizontalSlider\">\n             <property name=\"orientation\">\n              <enum>Qt::Horizontal</enum>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QLabel\" name=\"label\">\n             <property name=\"text\">\n              <string notr=\"true\">Dial</string>\n             </property>\n             <property name=\"alignment\">\n              <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QDial\" name=\"dial\"/>\n           </item>\n          </layout>\n         </item>\n         <item row=\"1\" column=\"1\" colspan=\"2\">\n          <widget class=\"QDateTimeEdit\" name=\"dateTimeEdit\"/>\n         </item>\n        </layout>\n       </widget>\n      </item>\n      <item row=\"2\" column=\"1\">\n       <widget class=\"QTabWidget\" name=\"tabWidget\">\n        <property name=\"currentIndex\">\n         <number>0</number>\n        </property>\n        <widget class=\"QWidget\" name=\"listTab\">\n         <attribute name=\"title\">\n          <string notr=\"true\">ListWidget</string>\n         </attribute>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_6\">\n          <item>\n           <widget class=\"QListWidget\" name=\"listWidget\"/>\n          </item>\n         </layout>\n        </widget>\n        <widget class=\"QWidget\" name=\"treeTab\">\n         <attribute name=\"title\">\n          <string notr=\"true\">TreeWidget</string>\n         </attribute>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_7\">\n          <item>\n           <widget class=\"QTreeWidget\" name=\"treeWidget\">\n            <column>\n             <property name=\"text\">\n              <string notr=\"true\">1</string>\n             </property>\n            </column>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n        <widget class=\"QWidget\" name=\"tableTab\">\n         <attribute name=\"title\">\n          <string notr=\"true\">TableWidget</string>\n         </attribute>\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_5\">\n          <item>\n           <widget class=\"QTableWidget\" name=\"tableWidget\">\n            <property name=\"rowCount\">\n             <number>0</number>\n            </property>\n            <property name=\"columnCount\">\n             <number>0</number>\n            </property>\n           </widget>\n          </item>\n         </layout>\n        </widget>\n       </widget>\n      </item>\n      <item row=\"3\" column=\"0\" colspan=\"2\">\n       <layout class=\"QHBoxLayout\" name=\"progressBarLayout\">\n        <item>\n         <widget class=\"QLabel\" name=\"progressBarLabel\">\n          <property name=\"text\">\n           <string notr=\"true\">ProgressBar</string>\n          </property>\n         </widget>\n        </item>\n        <item>\n         <widget class=\"QProgressBar\" name=\"progressBar\">\n          <property name=\"value\">\n           <number>24</number>\n          </property>\n         </widget>\n        </item>\n       </layout>\n      </item>\n     </layout>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "qt/aqt/gui_hooks.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nSee pylib/anki/hooks.py\n\"\"\"\n\nfrom __future__ import annotations\n\n# You can find the definitions in ../tools/genhooks_gui.py\nfrom _aqt.hooks import *\n"
  },
  {
    "path": "qt/aqt/import_export/__init__.py",
    "content": ""
  },
  {
    "path": "qt/aqt/import_export/exporting.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport time\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Sequence\nfrom dataclasses import dataclass\n\nimport aqt.forms\nimport aqt.main\nfrom anki.collection import (\n    DeckIdLimit,\n    ExportAnkiPackageOptions,\n    ExportLimit,\n    NoteIdsLimit,\n    Progress,\n)\nfrom anki.decks import DeckId, DeckNameId\nfrom anki.notes import NoteId\nfrom aqt import gui_hooks\nfrom aqt.errors import show_exception\nfrom aqt.operations import QueryOp\nfrom aqt.progress import ProgressUpdate\nfrom aqt.qt import *\nfrom aqt.utils import (\n    checkInvalidFilename,\n    disable_help_button,\n    getSaveFile,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass ExportDialog(QDialog):\n    def __init__(\n        self,\n        mw: aqt.main.AnkiQt,\n        did: DeckId | None = None,\n        nids: Sequence[NoteId] | None = None,\n        parent: QWidget | None = None,\n    ):\n        QDialog.__init__(self, parent or mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.col = mw.col.weakref()\n        self.frm = aqt.forms.exporting.Ui_ExportDialog()\n        self.frm.setupUi(self)\n        self.exporter: Exporter\n        self.nids = nids\n        disable_help_button(self)\n        self.setup(did)\n        self.open()\n\n    def setup(self, did: DeckId | None) -> None:\n        self.exporter_classes: list[type[Exporter]] = [\n            ApkgExporter,\n            ColpkgExporter,\n            NoteCsvExporter,\n            CardCsvExporter,\n        ]\n        gui_hooks.exporters_list_did_initialize(self.exporter_classes)\n        self.frm.format.insertItems(\n            0, [f\"{e.name()} (.{e.extension})\" for e in self.exporter_classes]\n        )\n        qconnect(self.frm.format.activated, self.exporter_changed)\n        if self.nids is None and not did:\n            # file>export defaults to colpkg\n            default_exporter_idx = 1\n        else:\n            default_exporter_idx = 0\n        self.frm.format.setCurrentIndex(default_exporter_idx)\n        self.exporter_changed(default_exporter_idx)\n        # deck list\n        if self.nids is None:\n            self.all_decks = self.col.decks.all_names_and_ids()\n            decks = [tr.exporting_all_decks()]\n            decks.extend(d.name for d in self.all_decks)\n        else:\n            decks = [tr.exporting_selected_notes()]\n        self.frm.deck.addItems(decks)\n        # save button\n        b = QPushButton(tr.exporting_export())\n        self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)\n        self.frm.includeHTML.setChecked(True)\n        # set default option if accessed through deck button\n        if did:\n            deck = self.mw.col.decks.get(did)\n            assert deck is not None\n            name = deck[\"name\"]\n            index = self.frm.deck.findText(name)\n            self.frm.deck.setCurrentIndex(index)\n            self.frm.includeSched.setChecked(False)\n\n    def exporter_changed(self, idx: int) -> None:\n        self.exporter = self.exporter_classes[idx]()\n        self.frm.includeSched.setVisible(self.exporter.show_include_scheduling)\n        self.frm.include_deck_configs.setVisible(\n            self.exporter.show_include_deck_configs\n        )\n        self.frm.includeMedia.setVisible(self.exporter.show_include_media)\n        self.frm.includeTags.setVisible(self.exporter.show_include_tags)\n        self.frm.includeHTML.setVisible(self.exporter.show_include_html)\n        self.frm.includeDeck.setVisible(self.exporter.show_include_deck)\n        self.frm.includeNotetype.setVisible(self.exporter.show_include_notetype)\n        self.frm.includeGuid.setVisible(self.exporter.show_include_guid)\n        self.frm.legacy_support.setVisible(self.exporter.show_legacy_support)\n        self.frm.deck.setVisible(self.exporter.show_deck_list)\n\n    def accept(self) -> None:\n        if not (out_path := self.get_out_path()):\n            return\n        self.exporter.export(self.mw, self.options(out_path))\n        QDialog.reject(self)\n\n    def get_out_path(self) -> str | None:\n        filename = self.filename()\n        while True:\n            path = getSaveFile(\n                parent=self,\n                title=tr.actions_export(),\n                dir_description=\"export\",\n                key=self.exporter.name(),\n                ext=\".\" + self.exporter.extension,\n                fname=filename,\n            )\n            if not path:\n                return None\n            if checkInvalidFilename(os.path.basename(path), dirsep=False):\n                continue\n            path = os.path.normpath(path)\n            if os.path.commonprefix([self.mw.pm.base, path]) == self.mw.pm.base:\n                showWarning(\"Please choose a different export location.\")\n                continue\n            break\n        return path\n\n    def options(self, out_path: str) -> ExportOptions:\n        limit: ExportLimit | None = None\n        if self.nids:\n            limit = NoteIdsLimit(self.nids)\n        elif current_deck_id := self.current_deck_id():\n            limit = DeckIdLimit(current_deck_id)\n\n        return ExportOptions(\n            out_path=out_path,\n            include_scheduling=self.frm.includeSched.isChecked(),\n            include_deck_configs=self.frm.include_deck_configs.isChecked(),\n            include_media=self.frm.includeMedia.isChecked(),\n            include_tags=self.frm.includeTags.isChecked(),\n            include_html=self.frm.includeHTML.isChecked(),\n            include_deck=self.frm.includeDeck.isChecked(),\n            include_notetype=self.frm.includeNotetype.isChecked(),\n            include_guid=self.frm.includeGuid.isChecked(),\n            legacy_support=self.frm.legacy_support.isChecked(),\n            limit=limit,\n        )\n\n    def current_deck_id(self) -> DeckId | None:\n        return (deck := self.current_deck()) and DeckId(deck.id) or None\n\n    def current_deck(self) -> DeckNameId | None:\n        if self.exporter.show_deck_list:\n            if idx := self.frm.deck.currentIndex():\n                return self.all_decks[idx - 1]\n        return None\n\n    def filename(self) -> str:\n        if self.exporter.show_deck_list:\n            deck_name = self.frm.deck.currentText()\n            stem = re.sub('[\\\\\\\\/?<>:*|\"^]', \"_\", deck_name)\n        else:\n            time_str = time.strftime(\"%Y-%m-%d@%H-%M-%S\", time.localtime(time.time()))\n            stem = f\"{tr.exporting_collection()}-{time_str}\"\n        return f\"{stem}.{self.exporter.extension}\"\n\n\n@dataclass\nclass ExportOptions:\n    out_path: str\n    include_scheduling: bool\n    include_deck_configs: bool\n    include_media: bool\n    include_tags: bool\n    include_html: bool\n    include_deck: bool\n    include_notetype: bool\n    include_guid: bool\n    legacy_support: bool\n    limit: ExportLimit\n\n\nclass Exporter(ABC):\n    extension: str\n    show_deck_list = False\n    show_include_scheduling = False\n    show_include_deck_configs = False\n    show_include_media = False\n    show_include_tags = False\n    show_include_html = False\n    show_legacy_support = False\n    show_include_deck = False\n    show_include_notetype = False\n    show_include_guid = False\n\n    @abstractmethod\n    def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None:\n        pass\n\n    @staticmethod\n    @abstractmethod\n    def name() -> str:\n        pass\n\n\nclass ColpkgExporter(Exporter):\n    extension = \"colpkg\"\n    show_include_media = True\n    show_legacy_support = True\n\n    @staticmethod\n    def name() -> str:\n        return tr.exporting_anki_collection_package()\n\n    def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None:\n        options = gui_hooks.exporter_will_export(options, self)\n\n        def on_success(_: None) -> None:\n            mw.reopen()\n            gui_hooks.exporter_did_export(options, self)\n            tooltip(tr.exporting_collection_exported(), parent=mw)\n\n        def on_failure(exception: Exception) -> None:\n            mw.reopen()\n            show_exception(parent=mw, exception=exception)\n\n        gui_hooks.collection_will_temporarily_close(mw.col)\n        QueryOp(\n            parent=mw,\n            op=lambda col: col.export_collection_package(\n                options.out_path,\n                include_media=options.include_media,\n                legacy=options.legacy_support,\n            ),\n            success=on_success,\n        ).with_backend_progress(export_progress_update).failure(\n            on_failure\n        ).run_in_background()\n\n\nclass ApkgExporter(Exporter):\n    extension = \"apkg\"\n    show_deck_list = True\n    show_include_scheduling = True\n    show_include_deck_configs = True\n    show_include_media = True\n    show_legacy_support = True\n\n    @staticmethod\n    def name() -> str:\n        return tr.exporting_anki_deck_package()\n\n    def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None:\n        options = gui_hooks.exporter_will_export(options, self)\n\n        def on_success(count: int) -> None:\n            gui_hooks.exporter_did_export(options, self)\n            tooltip(tr.exporting_note_exported(count=count), parent=mw)\n\n        QueryOp(\n            parent=mw,\n            op=lambda col: col.export_anki_package(\n                out_path=options.out_path,\n                limit=options.limit,\n                options=ExportAnkiPackageOptions(\n                    with_scheduling=options.include_scheduling,\n                    with_deck_configs=options.include_deck_configs,\n                    with_media=options.include_media,\n                    legacy=options.legacy_support,\n                ),\n            ),\n            success=on_success,\n        ).with_backend_progress(export_progress_update).run_in_background()\n\n\nclass NoteCsvExporter(Exporter):\n    extension = \"txt\"\n    show_deck_list = True\n    show_include_html = True\n    show_include_tags = True\n    show_include_deck = True\n    show_include_notetype = True\n    show_include_guid = True\n\n    @staticmethod\n    def name() -> str:\n        return tr.exporting_notes_in_plain_text()\n\n    def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None:\n        options = gui_hooks.exporter_will_export(options, self)\n\n        def on_success(count: int) -> None:\n            gui_hooks.exporter_did_export(options, self)\n            tooltip(tr.exporting_note_exported(count=count), parent=mw)\n\n        QueryOp(\n            parent=mw,\n            op=lambda col: col.export_note_csv(\n                out_path=options.out_path,\n                limit=options.limit,\n                with_html=options.include_html,\n                with_tags=options.include_tags,\n                with_deck=options.include_deck,\n                with_notetype=options.include_notetype,\n                with_guid=options.include_guid,\n            ),\n            success=on_success,\n        ).with_backend_progress(export_progress_update).run_in_background()\n\n\nclass CardCsvExporter(Exporter):\n    extension = \"txt\"\n    show_deck_list = True\n    show_include_html = True\n\n    @staticmethod\n    def name() -> str:\n        return tr.exporting_cards_in_plain_text()\n\n    def export(self, mw: aqt.main.AnkiQt, options: ExportOptions) -> None:\n        options = gui_hooks.exporter_will_export(options, self)\n\n        def on_success(count: int) -> None:\n            gui_hooks.exporter_did_export(options, self)\n            tooltip(tr.exporting_card_exported(count=count), parent=mw)\n\n        QueryOp(\n            parent=mw,\n            op=lambda col: col.export_card_csv(\n                out_path=options.out_path,\n                limit=options.limit,\n                with_html=options.include_html,\n            ),\n            success=on_success,\n        ).with_backend_progress(export_progress_update).run_in_background()\n\n\ndef export_progress_update(progress: Progress, update: ProgressUpdate) -> None:\n    if not progress.HasField(\"exporting\"):\n        return\n    update.label = progress.exporting\n    if update.user_wants_abort:\n        update.abort = True\n"
  },
  {
    "path": "qt/aqt/import_export/import_dialog.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import dataclass\nfrom urllib.parse import quote\n\nimport aqt\nimport aqt.deckconf\nimport aqt.main\nimport aqt.operations\nfrom aqt.qt import *\nfrom aqt.utils import disable_help_button, restoreGeom, saveGeom, tr\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\n@dataclass\nclass ImportArgs:\n    path: str\n    title = \"importLog\"\n    kind = AnkiWebViewKind.IMPORT_LOG\n    ts_page = \"import-page\"\n\n    def args_json(self) -> str:\n        return json.dumps(self.path)\n\n\nclass JsonFileArgs(ImportArgs):\n    def args_json(self) -> str:\n        return json.dumps(dict(type=\"json_file\", path=self.path))\n\n\nclass CsvArgs(ImportArgs):\n    title = \"csv import\"\n    kind = AnkiWebViewKind.IMPORT_CSV\n    ts_page = \"import-csv\"\n\n\nclass AnkiPackageArgs(ImportArgs):\n    title = \"anki package import\"\n    kind = AnkiWebViewKind.IMPORT_ANKI_PACKAGE\n    ts_page = \"import-anki-package\"\n\n\nclass ImportDialog(QDialog):\n    DEFAULT_SIZE = (800, 600)\n    MIN_SIZE = (400, 300)\n    silentlyClose = True\n\n    def __init__(self, mw: aqt.main.AnkiQt, args: ImportArgs) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.args = args\n        self._setup_ui()\n        self.show()\n\n    def _setup_ui(self) -> None:\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self.mw.garbage_collect_on_dialog_finish(self)\n        self.setMinimumSize(*self.MIN_SIZE)\n        disable_help_button(self)\n        restoreGeom(self, self.args.title, default_size=self.DEFAULT_SIZE)\n\n        self.web: AnkiWebView | None = AnkiWebView(kind=self.args.kind)\n        self.web.setVisible(False)\n        self.web.load_sveltekit_page(f\"{self.args.ts_page}/{quote(self.args.path)}\")\n        layout = QVBoxLayout()\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.addWidget(self.web)\n        self.setLayout(layout)\n        restoreGeom(self, self.args.title, default_size=(800, 800))\n\n        self.setWindowTitle(tr.decks_import_file())\n\n    def reject(self) -> None:\n        if self.mw.col and self.windowModality() == Qt.WindowModality.ApplicationModal:\n            self.mw.col.set_wants_abort()\n        assert self.web is not None\n        self.web.cleanup()\n        self.web = None\n        saveGeom(self, self.args.title)\n        QDialog.reject(self)\n"
  },
  {
    "path": "qt/aqt/import_export/importing.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Callable\nfrom itertools import chain\n\nimport aqt.main\nfrom anki.collection import Collection, Progress\nfrom anki.errors import Interrupted\nfrom anki.foreign_data import mnemosyne\nfrom anki.lang import without_unicode_isolation\nfrom anki.utils import tmpdir\nfrom aqt.import_export.import_dialog import (\n    AnkiPackageArgs,\n    CsvArgs,\n    ImportDialog,\n    JsonFileArgs,\n)\nfrom aqt.operations import QueryOp\nfrom aqt.progress import ProgressUpdate\nfrom aqt.qt import *\nfrom aqt.utils import askUser, getFile, showWarning, tooltip, tr\n\n\nclass Importer(ABC):\n    accepted_file_endings: list[str]\n\n    @classmethod\n    def can_import(cls, lowercase_filename: str) -> bool:\n        return any(\n            lowercase_filename.endswith(ending) for ending in cls.accepted_file_endings\n        )\n\n    @classmethod\n    @abstractmethod\n    def do_import(cls, mw: aqt.main.AnkiQt, path: str) -> None: ...\n\n\nclass ColpkgImporter(Importer):\n    accepted_file_endings = [\".apkg\", \".colpkg\"]\n\n    @staticmethod\n    def can_import(filename: str) -> bool:\n        return (\n            filename == \"collection.apkg\"\n            or (filename.startswith(\"backup-\") and filename.endswith(\".apkg\"))\n            or filename.endswith(\".colpkg\")\n        )\n\n    @staticmethod\n    def do_import(mw: aqt.main.AnkiQt, path: str) -> None:\n        if askUser(\n            tr.importing_this_will_delete_your_existing_collection(),\n            msgfunc=QMessageBox.warning,\n            defaultno=True,\n        ):\n            ColpkgImporter._import(mw, path)\n\n    @staticmethod\n    def _import(mw: aqt.main.AnkiQt, file: str) -> None:\n        def on_success() -> None:\n            mw.loadCollection()\n            tooltip(tr.importing_importing_complete())\n\n        def on_failure(err: Exception) -> None:\n            mw.loadCollection()\n            if not isinstance(err, Interrupted):\n                showWarning(str(err))\n\n        QueryOp(\n            parent=mw,\n            op=lambda _: mw.create_backup_now(),\n            success=lambda _: mw.unloadCollection(\n                lambda: import_collection_package_op(mw, file, on_success)\n                .failure(on_failure)\n                .run_in_background()\n            ),\n        ).with_progress().run_in_background()\n\n\nclass ApkgImporter(Importer):\n    accepted_file_endings = [\".apkg\", \".zip\"]\n\n    @staticmethod\n    def do_import(mw: aqt.main.AnkiQt, path: str) -> None:\n        ImportDialog(mw, AnkiPackageArgs(path))\n\n\nclass MnemosyneImporter(Importer):\n    accepted_file_endings = [\".db\"]\n\n    @staticmethod\n    def do_import(mw: aqt.main.AnkiQt, path: str) -> None:\n        def on_success(json: str) -> None:\n            json_path = os.path.join(tmpdir(), os.path.basename(path))\n            with open(json_path, \"wb\") as file:\n                file.write(json.encode(\"utf8\"))\n            ImportDialog(mw, JsonFileArgs(path=json_path))\n\n        QueryOp(\n            parent=mw,\n            op=lambda col: mnemosyne.serialize(path, col.decks.current()[\"id\"]),\n            success=on_success,\n        ).with_progress().run_in_background()\n\n\nclass CsvImporter(Importer):\n    accepted_file_endings = [\".csv\", \".tsv\", \".txt\"]\n\n    @staticmethod\n    def do_import(mw: aqt.main.AnkiQt, path: str) -> None:\n        ImportDialog(mw, CsvArgs(path))\n\n\nclass JsonImporter(Importer):\n    accepted_file_endings = [\".anki-json\"]\n\n    @staticmethod\n    def do_import(mw: aqt.main.AnkiQt, path: str) -> None:\n        ImportDialog(mw, JsonFileArgs(path=path))\n\n\nIMPORTERS: list[type[Importer]] = [\n    ColpkgImporter,\n    ApkgImporter,\n    MnemosyneImporter,\n    CsvImporter,\n]\n\n\ndef legacy_file_endings(col: Collection) -> list[str]:\n    from anki.importing import AnkiPackageImporter, TextImporter, importers\n    from anki.importing import MnemosyneImporter as LegacyMnemosyneImporter\n\n    return [\n        ext\n        for (text, importer) in importers(col)\n        if importer not in (TextImporter, AnkiPackageImporter, LegacyMnemosyneImporter)\n        for ext in re.findall(r\"[( ]?\\*(\\..+?)[) ]\", text)\n    ]\n\n\ndef import_file(mw: aqt.main.AnkiQt, path: str) -> None:\n    filename = os.path.basename(path).lower()\n\n    if any(filename.endswith(ext) for ext in legacy_file_endings(mw.col)):\n        import aqt.importing\n\n        aqt.importing.importFile(mw, path)\n        return\n\n    for importer in IMPORTERS:\n        if importer.can_import(filename):\n            importer.do_import(mw, path)\n            return\n\n    showWarning(\"Unsupported file type.\")\n\n\ndef prompt_for_file_then_import(mw: aqt.main.AnkiQt) -> None:\n    if path := get_file_path(mw):\n        import_file(mw, path)\n\n\ndef get_file_path(mw: aqt.main.AnkiQt) -> str | None:\n    filter = without_unicode_isolation(\n        tr.importing_all_supported_formats(\n            val=\"({})\".format(\n                \" \".join(f\"*{ending}\" for ending in all_accepted_file_endings(mw))\n            )\n        )\n    )\n    if file := getFile(mw, tr.actions_import(), None, key=\"import\", filter=filter):\n        return str(file)\n    return None\n\n\ndef all_accepted_file_endings(mw: aqt.main.AnkiQt) -> set[str]:\n    return set(\n        chain(\n            *(importer.accepted_file_endings for importer in IMPORTERS),\n            legacy_file_endings(mw.col),\n        )\n    )\n\n\ndef import_collection_package_op(\n    mw: aqt.main.AnkiQt, path: str, success: Callable[[], None]\n) -> QueryOp[None]:\n    def op(_: Collection) -> None:\n        col_path = mw.pm.collectionPath()\n        media_folder = os.path.join(mw.pm.profileFolder(), \"collection.media\")\n        media_db = os.path.join(mw.pm.profileFolder(), \"collection.media.db2\")\n        mw.backend.import_collection_package(\n            col_path=col_path,\n            backup_path=path,\n            media_folder=media_folder,\n            media_db=media_db,\n        )\n\n    return QueryOp(parent=mw, op=op, success=lambda _: success()).with_backend_progress(\n        import_progress_update\n    )\n\n\ndef import_progress_update(progress: Progress, update: ProgressUpdate) -> None:\n    if not progress.HasField(\"importing\"):\n        return\n    update.label = progress.importing\n    if update.user_wants_abort:\n        update.abort = True\n"
  },
  {
    "path": "qt/aqt/importing.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport os\nimport re\nimport sys\nimport traceback\nimport zipfile\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom typing import Any\n\nimport aqt.deckchooser\nimport aqt.forms\nimport aqt.modelchooser\nfrom anki import importing\nfrom anki.importing.anki2 import MediaMapInvalid, V2ImportIntoV1\nfrom anki.importing.apkg import AnkiPackageImporter\nfrom aqt.import_export.importing import ColpkgImporter\nfrom aqt.main import AnkiQt, gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import (\n    HelpPage,\n    disable_help_button,\n    getFile,\n    getText,\n    openHelp,\n    showInfo,\n    showText,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\nclass ChangeMap(QDialog):\n    def __init__(self, mw: AnkiQt, model: dict, current: str) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.model = model\n        self.frm = aqt.forms.changemap.Ui_ChangeMap()\n        self.frm.setupUi(self)\n        disable_help_button(self)\n        n = 0\n        setCurrent = False\n        for field in self.model[\"flds\"]:\n            item = QListWidgetItem(tr.importing_map_to(val=field[\"name\"]))\n            self.frm.fields.addItem(item)\n            if current == field[\"name\"]:\n                setCurrent = True\n                self.frm.fields.setCurrentRow(n)\n            n += 1\n        self.frm.fields.addItem(QListWidgetItem(tr.importing_map_to_tags()))\n        self.frm.fields.addItem(QListWidgetItem(tr.importing_ignore_field()))\n        if not setCurrent:\n            if current == \"_tags\":\n                self.frm.fields.setCurrentRow(n)\n            else:\n                self.frm.fields.setCurrentRow(n + 1)\n        self.field: str | None = None\n\n    def getField(self) -> str | None:\n        self.exec()\n        return self.field\n\n    def accept(self) -> None:\n        row = self.frm.fields.currentRow()\n        if row < len(self.model[\"flds\"]):\n            self.field = self.model[\"flds\"][row][\"name\"]\n        elif row == self.frm.fields.count() - 2:\n            self.field = \"_tags\"\n        else:\n            self.field = None\n        QDialog.accept(self)\n\n    def reject(self) -> None:\n        self.accept()\n\n\n# called by importFile() when importing a mappable file like .csv\n# ImportType = Union[Importer,AnkiPackageImporter, TextImporter]\n\n\nclass ImportDialog(QDialog):\n    _DEFAULT_FILE_DELIMITER = \"\\t\"\n\n    def __init__(self, mw: AnkiQt, importer: Any) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.importer = importer\n        self.frm = aqt.forms.importing.Ui_ImportDialog()\n        self.frm.setupUi(self)\n        help_button = self.frm.buttonBox.button(QDialogButtonBox.StandardButton.Help)\n        assert help_button is not None\n        qconnect(\n            help_button.clicked,\n            self.helpRequested,\n        )\n        disable_help_button(self)\n        self.setupMappingFrame()\n        self.setupOptions()\n        self.modelChanged()\n        self.frm.autoDetect.setVisible(self.importer.needDelimiter)\n        gui_hooks.current_note_type_did_change.append(self.modelChanged)\n        qconnect(self.frm.autoDetect.clicked, self.onDelimiter)\n        self.updateDelimiterButtonText()\n        assert self.mw.pm.profile is not None\n        self.frm.allowHTML.setChecked(self.mw.pm.profile.get(\"allowHTML\", True))\n        qconnect(self.frm.importMode.currentIndexChanged, self.importModeChanged)\n        self.frm.importMode.setCurrentIndex(self.mw.pm.profile.get(\"importMode\", 1))\n        self.frm.tagModified.setText(self.mw.pm.profile.get(\"tagModified\", \"\"))\n        self.frm.tagModified.setCol(self.mw.col)\n        # import button\n        b = QPushButton(tr.actions_import())\n        self.frm.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.AcceptRole)\n        self.exec()\n\n    def setupOptions(self) -> None:\n        self.model = self.mw.col.models.current()\n        self.modelChooser = aqt.modelchooser.ModelChooser(\n            self.mw, self.frm.modelArea, label=False\n        )\n        self.deck = aqt.deckchooser.DeckChooser(self.mw, self.frm.deckArea, label=False)\n\n    def modelChanged(self, unused: Any | None = None) -> None:\n        self.importer.model = self.mw.col.models.current()\n        self.importer.initMapping()\n        self.showMapping()\n\n    def onDelimiter(self) -> None:\n        # Open a modal dialog to enter an delimiter\n        # Todo/Idea Constrain the maximum width, so it doesn't take up that much screen space\n        delim, ok = getText(\n            tr.importing_by_default_anki_will_detect_the(),\n            self,\n            help=HelpPage.IMPORTING,\n        )\n\n        # If the modal dialog has been confirmed, update the delimiter\n        if ok:\n            # Check if the entered value is valid and if not fallback to default\n            # at the moment every single character entry as well as '\\t' is valid\n\n            delim = delim if len(delim) > 0 else self._DEFAULT_FILE_DELIMITER\n            delim = delim.replace(\"\\\\t\", \"\\t\")  # un-escape it\n            if len(delim) > 1:\n                showWarning(\n                    tr.importing_multicharacter_separators_are_not_supported_please()\n                )\n                return\n            self.hideMapping()\n\n            def updateDelim() -> None:\n                self.importer.delimiter = delim\n                self.importer.updateDelimiter()\n                self.updateDelimiterButtonText()\n\n            self.showMapping(hook=updateDelim)\n\n        else:\n            # If the operation has been canceled, do not do anything\n            pass\n\n    def updateDelimiterButtonText(self) -> None:\n        if not self.importer.needDelimiter:\n            return\n        if self.importer.delimiter:\n            d = self.importer.delimiter\n        else:\n            d = self.importer.dialect.delimiter\n        if d == \"\\t\":\n            d = tr.importing_tab()\n        elif d == \",\":\n            d = tr.importing_comma()\n        elif d == \" \":\n            d = tr.studying_space()\n        elif d == \";\":\n            d = tr.importing_semicolon()\n        elif d == \":\":\n            d = tr.importing_colon()\n        else:\n            d = repr(d)\n        txt = tr.importing_fields_separated_by(val=d)\n        self.frm.autoDetect.setText(txt)\n\n    def accept(self) -> None:\n        self.importer.mapping = self.mapping\n        if not self.importer.mappingOk():\n            showWarning(tr.importing_the_first_field_of_the_note())\n            return\n        self.importer.importMode = self.frm.importMode.currentIndex()\n        assert self.mw.pm.profile is not None\n        self.mw.pm.profile[\"importMode\"] = self.importer.importMode\n        self.importer.allowHTML = self.frm.allowHTML.isChecked()\n        self.mw.pm.profile[\"allowHTML\"] = self.importer.allowHTML\n        self.importer.tagModified = self.frm.tagModified.text()\n        self.mw.pm.profile[\"tagModified\"] = self.importer.tagModified\n        self.mw.col.set_aux_notetype_config(\n            self.importer.model[\"id\"], \"lastDeck\", self.deck.selected_deck_id\n        )\n        self.mw.col.models.save(self.importer.model, updateReqs=False)\n        self.mw.progress.start()\n\n        def on_done(future: Future) -> None:\n            self.mw.progress.finish()\n\n            try:\n                future.result()\n            except UnicodeDecodeError:\n                showUnicodeWarning()\n                return\n            except Exception as e:\n                msg = f\"{tr.importing_failed_debug_info()}\\n\"\n                err = repr(str(e))\n                if \"1-character string\" in err:\n                    msg += err\n                elif \"invalidTempFolder\" in err:\n                    msg += self.mw.errorHandler.tempFolderMsg()\n                else:\n                    msg += traceback.format_exc()\n                showText(msg)\n                return\n            else:\n                txt = f\"{tr.importing_importing_complete()}\\n\"\n                if self.importer.log:\n                    txt += \"\\n\".join(self.importer.log)\n                self.close()\n                showText(txt, plain_text_edit=True)\n                self.mw.reset()\n\n        self.mw.taskman.run_in_background(self.importer.run, on_done)\n\n    def setupMappingFrame(self) -> None:\n        # qt seems to have a bug with adding/removing from a grid, so we add\n        # to a separate object and add/remove that instead\n        self.frame = QFrame(self.frm.mappingArea)\n        self.frm.mappingArea.setWidget(self.frame)\n        self.mapbox = QVBoxLayout(self.frame)\n        self.mapbox.setContentsMargins(0, 0, 0, 0)\n        self.mapwidget: QWidget | None = None\n\n    def hideMapping(self) -> None:\n        self.frm.mappingGroup.hide()\n\n    def showMapping(\n        self, keepMapping: bool = False, hook: Callable | None = None\n    ) -> None:\n        if hook:\n            hook()\n        if not keepMapping:\n            self.mapping = self.importer.mapping\n        self.frm.mappingGroup.show()\n        assert self.importer.fields()\n        # set up the mapping grid\n        if self.mapwidget:\n            self.mapbox.removeWidget(self.mapwidget)\n            self.mapwidget.deleteLater()\n        self.mapwidget = QWidget()\n        self.mapbox.addWidget(self.mapwidget)\n        self.grid = QGridLayout(self.mapwidget)\n        self.mapwidget.setLayout(self.grid)\n        self.grid.setContentsMargins(3, 3, 3, 3)\n        self.grid.setSpacing(6)\n        for num in range(len(self.mapping)):\n            text = tr.importing_field_of_file_is(val=num + 1)\n            self.grid.addWidget(QLabel(text), num, 0)\n            if self.mapping[num] == \"_tags\":\n                text = tr.importing_mapped_to_tags()\n            elif self.mapping[num]:\n                text = tr.importing_mapped_to(val=self.mapping[num])\n            else:\n                text = tr.importing_ignored()\n            self.grid.addWidget(QLabel(text), num, 1)\n            button = QPushButton(tr.importing_change())\n            self.grid.addWidget(button, num, 2)\n            qconnect(button.clicked, lambda _, s=self, n=num: s.changeMappingNum(n))\n\n    def changeMappingNum(self, n: int) -> None:\n        f = ChangeMap(self.mw, self.importer.model, self.mapping[n]).getField()\n        try:\n            # make sure we don't have it twice\n            index = self.mapping.index(f)\n            self.mapping[index] = None\n        except ValueError:\n            pass\n        self.mapping[n] = f\n        if getattr(self.importer, \"delimiter\", False):\n            self.savedDelimiter = self.importer.delimiter\n\n            def updateDelim() -> None:\n                self.importer.delimiter = self.savedDelimiter\n\n            self.showMapping(hook=updateDelim, keepMapping=True)\n        else:\n            self.showMapping(keepMapping=True)\n\n    def reject(self) -> None:\n        self.modelChooser.cleanup()\n        self.deck.cleanup()\n        gui_hooks.current_note_type_did_change.remove(self.modelChanged)\n        QDialog.reject(self)\n\n    def helpRequested(self) -> None:\n        openHelp(HelpPage.IMPORTING)\n\n    def importModeChanged(self, newImportMode: int) -> None:\n        if newImportMode == 0:\n            self.frm.tagModified.setEnabled(True)\n        else:\n            self.frm.tagModified.setEnabled(False)\n\n\ndef showUnicodeWarning() -> None:\n    \"\"\"Shorthand to show a standard warning.\"\"\"\n    showWarning(tr.importing_selected_file_was_not_in_utf8())\n\n\ndef onImport(mw: AnkiQt) -> None:\n    filt = \";;\".join([x[0] for x in importing.importers(mw.col)])\n    file = getFile(mw, tr.actions_import(), None, key=\"import\", filter=filt)\n    if not file:\n        return\n    file = str(file)\n\n    head, ext = os.path.splitext(file)\n    ext = ext.lower()\n    if ext == \".anki\":\n        showInfo(tr.importing_anki_files_are_from_a_very())\n        return\n    elif ext == \".anki2\":\n        showInfo(tr.importing_anki2_files_are_not_directly_importable())\n        return\n\n    importFile(mw, file)\n\n\ndef importFile(mw: AnkiQt, file: str) -> None:\n    importerClass = None\n    done = False\n    for i in importing.importers(mw.col):\n        if done:\n            break\n        for mext in re.findall(r\"[( ]?\\*\\.(.+?)[) ]\", i[0]):\n            if file.endswith(f\".{mext}\"):\n                importerClass = i[1]\n                done = True\n                break\n    if not importerClass:\n        # if no matches, assume TSV\n        importerClass = importing.importers(mw.col)[0][1]\n    importer = importerClass(mw.col, file)\n    # need to show import dialog?\n    if importer.needMapper:\n        # make sure we can load the file first\n        mw.progress.start(immediate=True)\n        try:\n            importer.open()\n            mw.progress.finish()\n            ImportDialog(mw, importer)\n        except UnicodeDecodeError:\n            mw.progress.finish()\n            showUnicodeWarning()\n            return\n        except Exception as e:\n            mw.progress.finish()\n            msg = repr(str(e))\n            if msg == \"'unknownFormat'\":\n                showWarning(tr.importing_unknown_file_format())\n            else:\n                msg = f\"{tr.importing_failed_debug_info()}\\n\"\n                msg += str(traceback.format_exc())\n                showText(msg)\n            return\n        finally:\n            importer.close()\n    else:\n        # if it's an apkg/zip, first test it's a valid file\n        if isinstance(importer, AnkiPackageImporter):\n            # we need to ask whether to import/replace; if it's\n            # a colpkg file then the rest of the import process\n            # will happen in setupApkgImport()\n            if not setupApkgImport(mw, importer):\n                return\n\n        # importing non-colpkg files\n        mw.progress.start(immediate=True)\n\n        def on_done(future: Future) -> None:\n            mw.progress.finish()\n            try:\n                future.result()\n            except zipfile.BadZipfile:\n                showWarning(invalidZipMsg())\n            except MediaMapInvalid:\n                showWarning(\n                    \"Unable to read file. It probably requires a newer version of Anki to import.\"\n                )\n            except V2ImportIntoV1:\n                showWarning(\n                    \"\"\"\\\nTo import this deck, please click the Update button at the top of the deck list, then try again.\"\"\"\n                )\n            except Exception as e:\n                err = repr(str(e))\n                if \"invalidFile\" in err:\n                    msg = tr.importing_invalid_file_please_restore_from_backup()\n                    showWarning(msg)\n                elif \"invalidTempFolder\" in err:\n                    showWarning(mw.errorHandler.tempFolderMsg())\n                elif \"readonly\" in err:\n                    showWarning(tr.importing_unable_to_import_from_a_readonly())\n                else:\n                    msg = f\"{tr.importing_failed_debug_info()}\\n\"\n                    traceback.print_exc(file=sys.stdout)\n                    msg += str(e)\n                    showText(msg)\n            else:\n                log = \"\\n\".join(importer.log)\n                if \"\\n\" not in log:\n                    tooltip(log)\n                else:\n                    showText(log, plain_text_edit=True)\n\n            mw.reset()\n\n        mw.taskman.run_in_background(importer.run, on_done)\n\n\ndef invalidZipMsg() -> str:\n    return tr.importing_this_file_does_not_appear_to()\n\n\ndef setupApkgImport(mw: AnkiQt, importer: AnkiPackageImporter) -> bool:\n    base = os.path.basename(importer.file).lower()\n    full = (\n        (base == \"collection.apkg\")\n        or re.match(\"backup-.*\\\\.apkg\", base)\n        or base.endswith(\".colpkg\")\n    )\n    if not full:\n        # adding\n        return True\n    ColpkgImporter.do_import(mw, importer.file)\n    return False\n    return False\n"
  },
  {
    "path": "qt/aqt/legacy.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nLegacy support\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport anki\nimport anki.sound\nimport anki.utils\nimport aqt\nfrom aqt.theme import theme_manager\n\n# Routines removed from pylib/\n##########################################################################\n\n\ndef bodyClass(col, card) -> str:  # type: ignore\n    print(\"bodyClass() deprecated\")\n    return theme_manager.body_classes_for_card_ord(card.ord)\n\n\ndef allSounds(text) -> list:  # type: ignore\n    print(\"allSounds() deprecated\")\n    return aqt.mw.col.media._extract_filenames(text)\n\n\ndef stripSounds(text) -> str:  # type: ignore\n    print(\"stripSounds() deprecated\")\n    return aqt.mw.col.media.strip_av_tags(text)\n\n\ndef fmtTimeSpan(\n    time: Any,\n    pad: Any = 0,\n    point: Any = 0,\n    short: Any = False,\n    inTime: Any = False,\n    unit: Any = 99,\n) -> Any:\n    print(\"fmtTimeSpan() has become col.format_timespan()\")\n    return aqt.mw.col.format_timespan(time)\n\n\ndef install_pylib_legacy() -> None:\n    anki.utils.bodyClass = bodyClass  # type: ignore\n    anki.utils.fmtTimeSpan = fmtTimeSpan  # type: ignore\n    anki.sound._soundReg = r\"\\[sound:(.+?)\\]\"  # type: ignore\n    anki.sound.allSounds = allSounds  # type: ignore\n    anki.sound.stripSounds = stripSounds  # type: ignore\n"
  },
  {
    "path": "qt/aqt/log.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport logging\nimport sys\nfrom logging.handlers import TimedRotatingFileHandler\nfrom pathlib import Path\nfrom typing import Optional, cast\n\n# All loggers with the following prefix will be treated as add-on loggers\n#\n# To instatiate a logger with this prefix, use aqt.AddonManager.get_logger()\n#\n# NOTE: Add-ons might also directly instantiate a logger with this prefix, e.g. in\n#       order to avoid depending on the Anki codebase, so this prefix should not\n#       be changed.\nADDON_LOGGER_PREFIX = \"addon.\"\n\n# Formatter used for all loggers\nFORMATTER = logging.Formatter(\"%(asctime)s:%(levelname)s:%(name)s: %(message)s\")\n\n\nclass AnkiLoggerManager(logging.Manager):\n    # inspired by: https://github.com/abdnh/ankiutils/blob/master/src/ankiutils/log.py\n\n    def __init__(\n        self,\n        logs_path: Path | str,\n        existing_loggers: dict[str, logging.Logger | logging.PlaceHolder],\n        rootnode: logging.RootLogger,\n    ):\n        super().__init__(rootnode)\n        self.loggerDict = existing_loggers\n        self.logs_path = Path(logs_path)\n\n    def getLogger(self, name: str) -> logging.Logger:\n        if not name.startswith(ADDON_LOGGER_PREFIX) or name in self.loggerDict:\n            return super().getLogger(name)\n\n        # Create a new add-on logger\n        logger = super().getLogger(name)\n\n        module = name.split(ADDON_LOGGER_PREFIX)[1].partition(\".\")[0]\n        path = get_addon_logs_folder(self.logs_path, module=module) / f\"{module}.log\"\n        path.parent.mkdir(parents=True, exist_ok=True)\n\n        # Keep the last 10 days of logs\n        handler = TimedRotatingFileHandler(\n            filename=path, when=\"D\", interval=1, backupCount=10, encoding=\"utf-8\"\n        )\n        handler.setFormatter(FORMATTER)\n\n        logger.addHandler(handler)\n\n        return logger\n\n\ndef get_addon_logs_folder(logs_path: Path | str, module: str) -> Path:\n    return Path(logs_path) / \"addons\" / module\n\n\ndef find_addon_logger(module: str) -> logging.Logger | None:\n    return cast(\n        Optional[logging.Logger],\n        logging.Logger.manager.loggerDict.get(f\"{ADDON_LOGGER_PREFIX}{module}\"),\n    )\n\n\ndef setup_logging(path: Path | str, **kwargs) -> None:\n    \"\"\"\n    Set up logging for the application.\n\n    Configures the root logger to output logs to stdout by default, with custom\n    handling for add-on logs. The add-on logs are saved to a separate folder and file\n    for each add-on, under the path provided.\n\n    Args:\n        path (Path): The path where the log files should be stored.\n        **kwargs: Arbitrary keyword arguments for logging.basicConfig\n    \"\"\"\n\n    # Patch root logger manager to handle add-on loggers\n    logger_manager = AnkiLoggerManager(\n        path, existing_loggers=logging.Logger.manager.loggerDict, rootnode=logging.root\n    )\n    logging.Logger.manager = logger_manager\n\n    stdout_handler = logging.StreamHandler(stream=sys.stdout)\n    stdout_handler.setFormatter(FORMATTER)\n    logging.basicConfig(handlers=[stdout_handler], force=True, **kwargs)\n    logging.captureWarnings(True)\n\n    # Silence some loggers of external libraries:\n    silenced_loggers = [\n        \"waitress.queue\",\n    ]\n    for logger in silenced_loggers:\n        logging.getLogger(logger).setLevel(logging.CRITICAL)\n        logging.getLogger(logger).propagate = False\n"
  },
  {
    "path": "qt/aqt/main.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport enum\nimport gc\nimport os\nimport re\nimport signal\nimport sys\nimport traceback\nimport weakref\nfrom argparse import Namespace\nfrom collections.abc import Callable, Sequence\nfrom concurrent.futures import Future\nfrom typing import Any, Literal, TypeVar, cast\n\nimport anki\nimport anki.cards\nimport anki.sound\nimport aqt\nimport aqt.forms\nimport aqt.mediasrv\nimport aqt.mpv\nimport aqt.operations\nimport aqt.progress\nimport aqt.sound\nimport aqt.stats\nimport aqt.toolbar\nimport aqt.webview\nfrom anki import hooks\nfrom anki._backend import RustBackend as _RustBackend\nfrom anki._legacy import deprecated\nfrom anki.collection import Collection, Config, OpChanges, UndoStatus\nfrom anki.decks import DeckDict, DeckId\nfrom anki.hooks import runHook\nfrom anki.notes import NoteId\nfrom anki.sound import AVTag, SoundOrVideoTag\nfrom anki.utils import (\n    dev_mode,\n    ids2str,\n    int_time,\n    int_version,\n    is_lin,\n    is_mac,\n    is_win,\n    split_fields,\n)\nfrom aqt import gui_hooks\nfrom aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user\nfrom aqt.dbcheck import check_db\nfrom aqt.debug_console import show_debug_console\nfrom aqt.emptycards import show_empty_cards\nfrom aqt.flags import FlagManager\nfrom aqt.import_export.exporting import ExportDialog\nfrom aqt.import_export.importing import (\n    import_collection_package_op,\n    import_file,\n    prompt_for_file_then_import,\n)\nfrom aqt.legacy import install_pylib_legacy\nfrom aqt.mediacheck import check_media_db\nfrom aqt.mediasync import MediaSyncer\nfrom aqt.operations import QueryOp\nfrom aqt.operations.collection import redo, undo\nfrom aqt.operations.deck import set_current_deck\nfrom aqt.profiles import ProfileManager as ProfileManagerType\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.sync import sync_collection, sync_login\nfrom aqt.taskman import TaskManager\nfrom aqt.theme import Theme, theme_manager\nfrom aqt.toolbar import BottomWebView, Toolbar, TopWebView\nfrom aqt.undo import UndoActionsInfo\nfrom aqt.utils import (\n    HelpPage,\n    KeyboardModifiersPressed,\n    askUser,\n    checkInvalidFilename,\n    current_window,\n    disallow_full_screen,\n    getFile,\n    getOnlyText,\n    openHelp,\n    openLink,\n    restoreGeom,\n    restoreState,\n    saveGeom,\n    saveState,\n    showInfo,\n    showWarning,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\ninstall_pylib_legacy()\n\nMainWindowState = Literal[\n    \"startup\", \"deckBrowser\", \"overview\", \"review\", \"resetRequired\", \"profileManager\"\n]\n\n\nT = TypeVar(\"T\")\n\n\nclass MainWebView(AnkiWebView):\n    def __init__(self, mw: AnkiQt) -> None:\n        AnkiWebView.__init__(self, kind=AnkiWebViewKind.MAIN)\n        self.mw = mw\n        self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)\n        self.setMinimumWidth(400)\n        self.setAcceptDrops(True)\n\n    # Importing files via drag & drop\n    ##########################################################################\n\n    def dragEnterEvent(self, event: QDragEnterEvent) -> None:\n        if self.mw.state != \"deckBrowser\":\n            return super().dragEnterEvent(event)\n        mime = event.mimeData()\n        if not mime.hasUrls():\n            return\n        for url in mime.urls():\n            path = url.toLocalFile()\n            if not os.path.exists(path) or os.path.isdir(path):\n                return\n        event.accept()\n\n    def dropEvent(self, event: QDropEvent) -> None:\n        import aqt.importing\n\n        if self.mw.state != \"deckBrowser\":\n            return super().dropEvent(event)\n        mime = event.mimeData()\n        paths = [url.toLocalFile() for url in mime.urls()]\n        deck_paths = filter(lambda p: not p.endswith(\".colpkg\"), paths)\n        for path in deck_paths:\n            if not self.mw.pm.legacy_import_export():\n                import_file(self.mw, path)\n            else:\n                aqt.importing.importFile(self.mw, path)\n\n            # importing continues after the above call returns, so it is not\n            # currently safe for us to import more than one file at once\n            return\n\n    # Main webview specific event handling\n    def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool:\n        if handled := super().eventFilter(obj, evt):\n            return handled\n\n        if evt.type() == QEvent.Type.Leave:\n            handled_leave = False\n\n            # Show menubar when mouse moves outside main webview in fullscreen\n            if self.mw.fullscreen:\n                self.mw.show_menubar()\n                handled_leave = True\n\n            # Show toolbar when mouse moves outside main webview\n            # and automatically hide it with delay after mouse has entered again\n            # The toolbar's hide timer will also trigger menubar hiding when in fullscreen mode\n            if self.mw.pm.hide_top_bar() or self.mw.pm.hide_bottom_bar():\n                self.mw.toolbarWeb.show()\n                self.mw.bottomWeb.show()\n                handled_leave = True\n\n            return handled_leave\n\n        if evt.type() == QEvent.Type.Enter:\n            self.mw.toolbarWeb.hide_timer.start()\n            self.mw.bottomWeb.hide_timer.start()\n            return True\n\n        return False\n\n\nclass AnkiQt(QMainWindow):\n    col: Collection\n    pm: ProfileManagerType\n    web: MainWebView\n    bottomWeb: BottomWebView\n\n    def __init__(\n        self,\n        app: aqt.AnkiApp,\n        profileManager: ProfileManagerType,\n        backend: _RustBackend,\n        opts: Namespace,\n        args: list[Any],\n    ) -> None:\n        QMainWindow.__init__(self)\n        self.backend = backend\n        self.state: MainWindowState = \"startup\"\n        self.opts = opts\n        self.col: Collection | None = None\n        self.taskman = TaskManager(self)\n        self.media_syncer = MediaSyncer(self)\n        aqt.mw = self\n        self.app = app\n        self.pm = profileManager\n        self.fullscreen = False\n        # init rest of app\n        self.safeMode = (\n            bool(self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier)\n            or self.opts.safemode\n        )\n        try:\n            self.setupUI()\n            self.setupAddons(args)\n            self.finish_ui_setup()\n        except Exception:\n            showInfo(tr.qt_misc_error_during_startup(val=traceback.format_exc()))\n            sys.exit(1)\n        # must call this after ui set up\n        if self.safeMode:\n            tooltip(tr.qt_misc_shift_key_was_held_down_skipping())\n        # were we given a file to import?\n        if args and args[0] and not self._isAddon(args[0]):\n            self.onAppMsg(args[0])\n        # Load profile in a timer so we can let the window finish init and not\n        # close on profile load error.\n        if is_win:\n            fn = self.setupProfileAfterWebviewsLoaded\n        else:\n            fn = self.setupProfile\n\n        def on_window_init() -> None:\n            fn()\n            gui_hooks.main_window_did_init()\n\n        self.progress.single_shot(10, on_window_init, False)\n\n    def setupUI(self) -> None:\n        self.col = None\n        self.disable_automatic_garbage_collection()\n        self.setupAppMsg()\n        self.setupKeys()\n        self.setupThreads()\n        self.setupMediaServer()\n        self.setupSpellCheck()\n        self.setupProgress()\n        self.setupStyle()\n        self.setupMainWindow()\n        self.setupSystemSpecific()\n        self.setupMenus()\n        self.setupErrorHandler()\n        self.setupSignals()\n        self.setupHooks()\n        self.setup_timers()\n        self.updateTitleBar()\n        self.setup_focus()\n        # screens\n        self.setupDeckBrowser()\n        self.setupOverview()\n        self.setupReviewer()\n\n    def finish_ui_setup(self) -> None:\n        \"Actions that are deferred until after add-on loading.\"\n        self.toolbar.draw()\n        # add-ons are only available here after setupAddons\n        gui_hooks.reviewer_did_init(self.reviewer)\n\n    def setupProfileAfterWebviewsLoaded(self) -> None:\n        for w in (self.web, self.bottomWeb):\n            if not w._domDone:\n                self.progress.single_shot(\n                    10,\n                    self.setupProfileAfterWebviewsLoaded,\n                    False,\n                )\n                return\n            else:\n                w.requiresCol = True\n\n        self.setupProfile()\n\n    def weakref(self) -> AnkiQt:\n        \"Shortcut to create a weak reference that doesn't break code completion.\"\n        return weakref.proxy(self)  # type: ignore\n\n    def setup_focus(self) -> None:\n        qconnect(self.app.focusChanged, self.on_focus_changed)\n\n    def on_focus_changed(self, old: QWidget, new: QWidget) -> None:\n        gui_hooks.focus_did_change(new, old)\n\n    # Profiles\n    ##########################################################################\n\n    class ProfileManager(QMainWindow):\n        onClose = pyqtSignal()\n        closeFires = True\n\n        def closeEvent(self, evt: QCloseEvent) -> None:\n            if self.closeFires:\n                self.onClose.emit()  # type: ignore\n            evt.accept()\n\n        def closeWithoutQuitting(self) -> None:\n            self.closeFires = False\n            self.close()\n            self.closeFires = True\n\n    def setupProfile(self) -> None:\n        if self.pm.meta[\"firstRun\"]:\n            # load the new deck user profile\n            self.pm.load(self.pm.profiles()[0])\n            self.pm.meta[\"firstRun\"] = False\n            self.pm.save()\n\n        self.pendingImport: str | None = None\n        self.restoring_backup = False\n        # - if a valid profile was provided on commandline, we load it\n        # - if an invalid profile was provided, we skip this step and show the picker\n        # - if no profile was provided, we use this step\n        if not self.pm.name and not self.pm.invalid_profile_provided_on_commandline:\n            profs = self.pm.profiles()\n            name = self.pm.last_loaded_profile_name()\n            if len(profs) == 1:\n                self.pm.load(profs[0])\n            elif name in profs:\n                self.pm.load(name)\n\n        if not self.pm.name:\n            self.showProfileManager()\n        else:\n            self.loadProfile()\n\n    def showProfileManager(self) -> None:\n        self.pm.profile = None\n        self.moveToState(\"profileManager\")\n        d = self.profileDiag = self.ProfileManager()\n        f = self.profileForm = aqt.forms.profiles.Ui_MainWindow()\n        f.setupUi(d)\n        qconnect(f.login.clicked, self.onOpenProfile)\n        qconnect(f.profiles.itemDoubleClicked, self.onOpenProfile)\n        qconnect(f.openBackup.clicked, self.onOpenBackup)\n        qconnect(f.quit.clicked, d.close)\n        qconnect(d.onClose, self.cleanupAndExit)\n        qconnect(f.add.clicked, self.onAddProfile)\n        qconnect(f.rename.clicked, self.onRenameProfile)\n        qconnect(f.delete_2.clicked, self.onRemProfile)\n        qconnect(f.profiles.currentRowChanged, self.onProfileRowChange)\n        f.statusbar.setVisible(False)\n        qconnect(f.downgrade_button.clicked, self._on_downgrade)\n        f.downgrade_button.setText(tr.profiles_downgrade_and_quit())\n        # enter key opens profile\n        QShortcut(QKeySequence(\"Return\"), d, activated=self.onOpenProfile)  # type: ignore\n        self.refreshProfilesList()\n        # raise first, for osx testing\n        d.show()\n        d.activateWindow()\n        d.raise_()\n\n    def refreshProfilesList(self) -> None:\n        f = self.profileForm\n        f.profiles.clear()\n        profs = self.pm.profiles()\n        f.profiles.addItems(profs)\n        try:\n            idx = profs.index(self.pm.name)\n        except Exception:\n            idx = 0\n        f.profiles.setCurrentRow(idx)\n\n    def onProfileRowChange(self, n: int) -> None:\n        if n < 0:\n            # called on .clear()\n            return\n        name = self.pm.profiles()[n]\n        self.pm.load(name)\n\n    def openProfile(self) -> None:\n        name = self.pm.profiles()[self.profileForm.profiles.currentRow()]\n        self.pm.load(name)\n\n    def onOpenProfile(self, *, callback: Callable[[], None] | None = None) -> None:\n        def on_done() -> None:\n            self.profileDiag.closeWithoutQuitting()\n            if callback:\n                callback()\n\n        self.profileDiag.hide()\n        # code flow is confusing here - if load fails, profile dialog\n        # will be shown again\n        self.loadProfile(on_done)\n\n    def profileNameOk(self, name: str) -> bool:\n        return not checkInvalidFilename(name) and name != \"addons21\"\n\n    def onAddProfile(self) -> None:\n        name = getOnlyText(tr.actions_name()).strip()\n        if name:\n            if name in self.pm.profiles():\n                showWarning(tr.qt_misc_name_exists())\n                return\n            if not self.profileNameOk(name):\n                return\n            self.pm.create(name)\n            self.pm.name = name\n            self.refreshProfilesList()\n\n    def onRenameProfile(self) -> None:\n        name = getOnlyText(tr.actions_new_name(), default=self.pm.name).strip()\n        if not name:\n            return\n        if name == self.pm.name:\n            return\n        if name in self.pm.profiles():\n            showWarning(tr.qt_misc_name_exists())\n            return\n        if not self.profileNameOk(name):\n            return\n        self.pm.rename(name)\n        self.refreshProfilesList()\n\n    def onRemProfile(self) -> None:\n        profs = self.pm.profiles()\n        if len(profs) < 2:\n            showWarning(tr.qt_misc_there_must_be_at_least_one())\n            return\n        # sure?\n        if not askUser(\n            tr.qt_misc_all_cards_notes_and_media_for2(name=self.pm.name),\n            msgfunc=QMessageBox.warning,\n            defaultno=True,\n        ):\n            return\n        self.pm.remove(self.pm.name)\n        self.refreshProfilesList()\n\n    def _handle_load_backup_success(self) -> None:\n        \"\"\"\n        Actions that occur when profile backup has been loaded successfully\n        \"\"\"\n        if self.state == \"profileManager\":\n            self.profileDiag.closeWithoutQuitting()\n\n        self.loadProfile()\n\n    def _handle_load_backup_failure(self, error: Exception) -> None:\n        \"\"\"\n        Actions that occur when a profile has loaded unsuccessfully\n        \"\"\"\n        showWarning(str(error))\n        if self.state != \"profileManager\":\n            self.loadProfile()\n\n    def onOpenBackup(self) -> None:\n        def do_open(path: str) -> None:\n            if not askUser(\n                tr.qt_misc_replace_your_collection_with_an_earlier2(\n                    os.path.basename(path)\n                ),\n                msgfunc=QMessageBox.warning,\n                defaultno=True,\n            ):\n                return\n\n            showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been())\n\n            # Collection is still loaded if called from main window, so we unload. This is already\n            # unloaded if called from the ProfileManager window.\n            if self.col:\n                self.unloadProfile(lambda: self._start_restore_backup(path))\n                return\n\n            self._start_restore_backup(path)\n\n        getFile(\n            self.profileDiag if self.state == \"profileManager\" else self,\n            tr.qt_misc_revert_to_backup(),\n            cb=do_open,  # type: ignore\n            filter=\"*.colpkg\",\n            dir=self.pm.backupFolder(),\n        )\n\n    def _start_restore_backup(self, path: str):\n        self.restoring_backup = True\n\n        import_collection_package_op(\n            self, path, success=self._handle_load_backup_success\n        ).failure(self._handle_load_backup_failure).run_in_background()\n\n    def _on_downgrade(self) -> None:\n        self.progress.start()\n        profiles = self.pm.profiles()\n\n        def downgrade() -> list[str]:\n            return self.pm.downgrade(profiles)\n\n        def on_done(future: Future) -> None:\n            self.progress.finish()\n            problems = future.result()\n            if not problems:\n                showInfo(\"Profiles can now be opened with an older version of Anki.\")\n            else:\n                showWarning(\n                    \"The following profiles could not be downgraded: {}\".format(\n                        \", \".join(problems)\n                    )\n                )\n                return\n            self.profileDiag.close()\n\n        self.taskman.run_in_background(downgrade, on_done)\n\n    def loadProfile(self, onsuccess: Callable | None = None) -> None:\n        if not self.loadCollection():\n            return\n\n        self.setup_sound()\n        self.flags = FlagManager(self)\n        # show main window\n        restoreGeom(self, \"mainWindow\")\n        restoreState(self, \"mainWindow\")\n        # titlebar\n        self.setWindowTitle(f\"{self.pm.name} - Anki\")\n        # show and raise window for osx\n        self.show()\n        self.activateWindow()\n        self.raise_()\n\n        # import pending?\n        if self.pendingImport:\n            if self._isAddon(self.pendingImport):\n                self.installAddon(self.pendingImport)\n            else:\n                self.handleImport(self.pendingImport)\n            self.pendingImport = None\n\n        def _onsuccess(synced: bool) -> None:\n            if synced:\n                self._refresh_after_sync()\n            if onsuccess:\n                onsuccess()\n            if not self.safeMode:\n                self.maybe_check_for_addon_updates(self.setup_auto_update)\n\n        last_day_cutoff = self.col.sched.day_cutoff\n\n        def refresh_reviewer_on_day_rollover_change():\n            from aqt.reviewer import RefreshNeeded\n\n            # need to refresh?\n            nonlocal last_day_cutoff\n            current_cutoff = self.col.sched.day_cutoff\n            if self.state == \"review\" and last_day_cutoff != current_cutoff:\n                last_day_cutoff = self.col.sched.day_cutoff\n                self.reviewer._refresh_needed = RefreshNeeded.QUEUES\n                self.reviewer.refresh_if_needed()\n            if last_day_cutoff != current_cutoff:\n                gui_hooks.day_did_change()\n\n            # schedule another check\n            secs_until_cutoff = current_cutoff - int_time()\n            self._reviewer_refresh_timer = self.progress.timer(\n                secs_until_cutoff * 1000,\n                refresh_reviewer_on_day_rollover_change,\n                repeat=False,\n                parent=self,\n            )\n\n        refresh_reviewer_on_day_rollover_change()\n        gui_hooks.profile_did_open()\n        self.maybe_auto_sync_on_open_close(_onsuccess)\n\n    def unloadProfile(self, onsuccess: Callable) -> None:\n        def callback() -> None:\n            self._unloadProfile()\n            onsuccess()\n\n        gui_hooks.profile_will_close()\n        self.unloadCollection(callback)\n\n    def _unloadProfile(self) -> None:\n        self.cleanup_sound()\n        saveGeom(self, \"mainWindow\")\n        saveState(self, \"mainWindow\")\n        self.pm.save()\n        self.hide()\n\n        self.restoring_backup = False\n\n        # at this point there should be no windows left\n        self._checkForUnclosedWidgets()\n        self._reviewer_refresh_timer.deleteLater()\n\n    def _checkForUnclosedWidgets(self) -> None:\n        for w in self.app.topLevelWidgets():\n            if w.isVisible():\n                # windows with this property are safe to close immediately\n                if getattr(w, \"silentlyClose\", None):\n                    w.close()\n                else:\n                    print(f\"Window should have been closed: {w}\")\n\n    def unloadProfileAndExit(self) -> None:\n        self.unloadProfile(self.cleanupAndExit)\n\n    def unloadProfileAndShowProfileManager(self) -> None:\n        self.unloadProfile(self.showProfileManager)\n\n    def cleanupAndExit(self) -> None:\n        self.errorHandler.unload()\n        self.mediaServer.shutdown()\n        # Rust background jobs are not awaited implicitly\n        self.backend.await_backup_completion()\n        self.deleteLater()\n        app = self.app\n        app._unset_windows_shutdown_block_reason()\n\n        def exit():\n            # try to ensure Qt objects are deleted in a logical order,\n            # to prevent crashes on shutdown\n            gc.collect()\n            app.exit(0)\n\n        self.progress.single_shot(100, exit, False)\n\n    # Sound/video\n    ##########################################################################\n\n    def setup_sound(self) -> None:\n        aqt.sound.setup_audio(self.taskman, self.pm.base, self.col.media.dir())\n\n    def cleanup_sound(self) -> None:\n        aqt.sound.cleanup_audio()\n\n    def _add_play_buttons(self, text: str) -> str:\n        \"Return card text with play buttons added, or stripped.\"\n        if self.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS):\n            return anki.sound.strip_av_refs(text)\n        else:\n            return aqt.sound.av_refs_to_play_icons(text)\n\n    def prepare_card_text_for_display(self, text: str) -> str:\n        text = self.col.media.escape_media_filenames(text)\n        text = self._add_play_buttons(text)\n        return text\n\n    # Collection load/unload\n    ##########################################################################\n\n    def loadCollection(self) -> bool:\n        try:\n            self._loadCollection()\n        except Exception as e:\n            if \"FileTooNew\" in str(e):\n                showWarning(\n                    \"This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?\"\n                )\n            else:\n                showWarning(\n                    f\"{tr.errors_unable_open_collection()}\\n{traceback.format_exc()}\"\n                )\n            # clean up open collection if possible\n            try:\n                self.backend.close_collection(downgrade_to_schema11=False)\n            except Exception as e:\n                print(\"unable to close collection:\", e)\n            self.col = None\n            # return to profile manager\n            self.hide()\n            self.showProfileManager()\n            return False\n\n        # make sure we don't get into an inconsistent state if an add-on\n        # has broken the deck browser or the did_load hook\n        try:\n            self.update_undo_actions()\n            gui_hooks.collection_did_load(self.col)\n            self.apply_collection_options()\n            self.moveToState(\"deckBrowser\")\n        except Exception:\n            # dump error to stderr so it gets picked up by errors.py\n            traceback.print_exc()\n\n        return True\n\n    def _loadCollection(self) -> None:\n        cpath = self.pm.collectionPath()\n        self.col = Collection(cpath, backend=self.backend)\n        self.setEnabled(True)\n\n    def reopen(self, after_full_sync: bool = False) -> None:\n        self.col.reopen(after_full_sync=after_full_sync)\n        gui_hooks.collection_did_temporarily_close(self.col)\n\n    def unloadCollection(self, onsuccess: Callable) -> None:\n        def after_media_sync() -> None:\n            self._unloadCollection()\n            onsuccess()\n\n        def after_sync(synced: bool) -> None:\n            self.media_syncer.show_diag_until_finished(after_media_sync)\n\n        def before_sync() -> None:\n            self.setEnabled(False)\n            self.maybe_auto_sync_on_open_close(after_sync)\n\n        self.closeAllWindows(before_sync)\n\n    def _unloadCollection(self) -> None:\n        if not self.col:\n            return\n\n        label = (\n            tr.qt_misc_closing() if self.restoring_backup else tr.qt_misc_backing_up()\n        )\n        self.progress.start(label=label)\n\n        corrupt = False\n\n        try:\n            self.maybeOptimize()\n            if not dev_mode:\n                corrupt = self.col.db.scalar(\"pragma quick_check\") != \"ok\"\n        except Exception:\n            corrupt = True\n\n        try:\n            if not corrupt and not dev_mode and not self.restoring_backup:\n                try:\n                    # default 5 minute throttle\n                    self.col.create_backup(\n                        backup_folder=self.pm.backupFolder(),\n                        force=False,\n                        wait_for_completion=False,\n                    )\n                except Exception:\n                    print(\"backup on close failed\")\n            self.col.close(downgrade=False)\n        except Exception as e:\n            print(e)\n            corrupt = True\n        finally:\n            self.col = None\n            self.progress.finish()\n\n        if corrupt:\n            showWarning(tr.qt_misc_your_collection_file_appears_to_be())\n\n    def apply_collection_options(self) -> None:\n        \"Setup audio after collection loaded.\"\n        aqt.sound.av_player.interrupt_current_audio = self.col.get_config_bool(\n            Config.Bool.INTERRUPT_AUDIO_WHEN_ANSWERING\n        )\n\n    # Auto-optimize\n    ##########################################################################\n\n    def maybeOptimize(self) -> None:\n        # have two weeks passed?\n        if (last_optimize := self.pm.profile.get(\"lastOptimize\")) is not None:\n            if (int_time() - last_optimize) < 86400 * 14:\n                return\n        self.progress.start(label=tr.qt_misc_optimizing())\n        self.col.optimize()\n        self.pm.profile[\"lastOptimize\"] = int_time()\n        self.pm.save()\n        self.progress.finish()\n\n    # Tracking main window state (deck browser, reviewer, etc)\n    ##########################################################################\n\n    def moveToState(self, state: MainWindowState, *args: Any) -> None:\n        # print(\"-> move from\", self.state, \"to\", state)\n        oldState = self.state\n        cleanup = getattr(self, f\"_{oldState}Cleanup\", None)\n        if cleanup:\n            cleanup(state)\n        self.clearStateShortcuts()\n        self.state = state\n        gui_hooks.state_will_change(state, oldState)\n        getattr(self, f\"_{state}State\", lambda *_: None)(oldState, *args)\n        if state != \"resetRequired\":\n            self.bottomWeb.adjustHeightToFit()\n        gui_hooks.state_did_change(state, oldState)\n\n    def _deckBrowserState(self, oldState: MainWindowState) -> None:\n        self.deckBrowser.show()\n\n    def _selectedDeck(self) -> DeckDict | None:\n        did = self.col.decks.selected()\n        if not self.col.decks.name_if_exists(did):\n            showInfo(tr.qt_misc_please_select_a_deck())\n            return None\n        return self.col.decks.get(did)\n\n    def _overviewState(self, oldState: MainWindowState) -> None:\n        if not self._selectedDeck():\n            return self.moveToState(\"deckBrowser\")\n        self.overview.show()\n\n    def _reviewState(self, oldState: MainWindowState) -> None:\n        self.reviewer.show()\n\n        fullscreen_was_checked = False\n\n        if self.pm.hide_top_bar():\n            self.toolbarWeb.hide_timer.setInterval(500)\n            self.toolbarWeb.hide_timer.start()\n\n            # check the `hide_if_allowed` method in `qt/aqt/toolbar.py`\n            fullscreen_was_checked = True\n        else:\n            self.toolbarWeb.flatten()\n\n        if not fullscreen_was_checked and self.fullscreen:\n            self.hide_menubar()\n\n        if self.pm.hide_bottom_bar():\n            self.bottomWeb.hide_timer.setInterval(500)\n            self.bottomWeb.hide_timer.start()\n\n    def _reviewCleanup(self, newState: MainWindowState) -> None:\n        if newState not in {\"resetRequired\", \"review\"}:\n            self.reviewer.auto_advance_enabled = False\n            self.reviewer.cleanup()\n            self.toolbarWeb.elevate()\n            self.toolbarWeb.show()\n            self.bottomWeb.show()\n\n    # Resetting state\n    ##########################################################################\n\n    def _increase_background_ops(self) -> None:\n        if not self._background_op_count:\n            gui_hooks.backend_will_block()\n        self._background_op_count += 1\n\n    def _decrease_background_ops(self) -> None:\n        self._background_op_count -= 1\n        if not self._background_op_count:\n            gui_hooks.backend_did_block()\n        if self._background_op_count < 0:\n            raise Exception(\"no background ops active\")\n\n    def _synthesize_op_did_execute_from_reset(self) -> None:\n        \"\"\"Fire the `operation_did_execute` hook with everything marked as changed,\n        after legacy code has called .reset()\"\"\"\n        op = OpChanges()\n        for field in op.DESCRIPTOR.fields:\n            if field.name != \"kind\":\n                setattr(op, field.name, True)\n        gui_hooks.operation_did_execute(op, None)\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        \"Notify current screen of changes.\"\n        focused = current_window() == self\n        if self.state == \"review\":\n            dirty = self.reviewer.op_executed(changes, handler, focused)\n        elif self.state == \"overview\":\n            dirty = self.overview.op_executed(changes, handler, focused)\n        elif self.state == \"deckBrowser\":\n            dirty = self.deckBrowser.op_executed(changes, handler, focused)\n        else:\n            dirty = False\n\n        if not focused and dirty:\n            self.fade_out_webview()\n\n        if changes.mtime:\n            self.toolbar.update_sync_status()\n\n        if changes.notetype:\n            self.col.models._clear_cache()\n\n    def on_focus_did_change(\n        self, new_focus: QWidget | None, _old: QWidget | None\n    ) -> None:\n        \"If main window has received focus, ensure current UI state is updated.\"\n        if new_focus and new_focus.window() == self:\n            if self.state == \"review\":\n                self.reviewer.refresh_if_needed()\n            elif self.state == \"overview\":\n                self.overview.refresh_if_needed()\n            elif self.state == \"deckBrowser\":\n                self.deckBrowser.refresh_if_needed()\n\n    def fade_out_webview(self) -> None:\n        self.web.eval(\"document.body.style.opacity = 0.3\")\n\n    def fade_in_webview(self) -> None:\n        self.web.eval(\"document.body.style.opacity = 1\")\n\n    def reset(self, unused_arg: bool = False) -> None:\n        \"\"\"Legacy method of telling UI to refresh after changes made to DB.\n\n        New code should use CollectionOp() instead.\"\"\"\n        if self.col:\n            # fire new `operation_did_execute` hook first. If the overview\n            # or review screen are currently open, they will rebuild the study\n            # queues (via mw.col.reset())\n            self._synthesize_op_did_execute_from_reset()\n            # fire the old reset hook\n            gui_hooks.state_did_reset()\n            self.update_undo_actions()\n\n    # legacy\n\n    def requireReset(\n        self,\n        modal: bool = False,\n        reason: Any | None = None,\n        context: Any | None = None,\n    ) -> None:\n        traceback.print_stack(file=sys.stdout)\n        print(\"requireReset() is obsolete; please use CollectionOp()\")\n        self.reset()\n\n    def maybeReset(self) -> None:\n        pass\n\n    def delayedMaybeReset(self) -> None:\n        pass\n\n    def _resetRequiredState(self, oldState: MainWindowState) -> None:\n        pass\n\n    # HTML helpers\n    ##########################################################################\n\n    def button(\n        self,\n        link: str,\n        name: str,\n        key: str | None = None,\n        class_: str = \"\",\n        id: str = \"\",\n        extra: str = \"\",\n    ) -> str:\n        class_ = f\"but {class_}\"\n        if key:\n            key = tr.actions_shortcut_key(val=key)\n        else:\n            key = \"\"\n        return \"\"\"\n<button id=\"{}\" class=\"{}\" onclick=\"pycmd('{}');return false;\"\ntitle=\"{}\" {}>{}</button>\"\"\".format(\n            id,\n            class_,\n            link,\n            key,\n            extra,\n            name,\n        )\n\n    # Main window setup\n    ##########################################################################\n\n    def setupMainWindow(self) -> None:\n        # main window\n        self.form = aqt.forms.main.Ui_MainWindow()\n        self.form.setupUi(self)\n        # toolbar\n        tweb = self.toolbarWeb = TopWebView(self)\n        self.toolbar = Toolbar(self, tweb)\n        # main area\n        self.web = MainWebView(self)\n        # bottom area\n        sweb = self.bottomWeb = BottomWebView(self)\n        sweb.setFocusPolicy(Qt.FocusPolicy.WheelFocus)\n        sweb.disable_zoom()\n        # add in a layout\n        self.mainLayout = QVBoxLayout()\n        self.mainLayout.setContentsMargins(0, 0, 0, 0)\n        self.mainLayout.setSpacing(0)\n        self.mainLayout.addWidget(tweb)\n        self.mainLayout.addWidget(self.web)\n        self.mainLayout.addWidget(sweb)\n        self.form.centralwidget.setLayout(self.mainLayout)\n\n        # force webengine processes to load before cwd is changed\n        if is_win:\n            for webview in self.web, self.bottomWeb:\n                webview.force_load_hack()\n\n        gui_hooks.card_review_webview_did_init(self.web, AnkiWebViewKind.MAIN)\n\n    def closeAllWindows(self, onsuccess: Callable) -> None:\n        aqt.dialogs.closeAll(onsuccess)\n\n    # Components\n    ##########################################################################\n\n    def setupSignals(self) -> None:\n        signal.signal(signal.SIGINT, self.onUnixSignal)\n        signal.signal(signal.SIGTERM, self.onUnixSignal)\n\n    def onUnixSignal(self, signum: Any, frame: Any) -> None:\n        def quit() -> None:\n            self.close()\n\n        self.progress.single_shot(100, quit)\n\n    def setupProgress(self) -> None:\n        self.progress = aqt.progress.ProgressManager(self)\n\n    def setupErrorHandler(self) -> None:\n        import aqt.errors\n\n        self.errorHandler = aqt.errors.ErrorHandler(self)\n\n    def setupAddons(self, args: list | None) -> None:\n        import aqt.addons\n\n        self.addonManager = aqt.addons.AddonManager(self)\n\n        if args and args[0] and self._isAddon(args[0]):\n            self.installAddon(args[0], startup=True)\n\n        if not self.safeMode:\n            self.addonManager.loadAddons()\n\n    def maybe_check_for_addon_updates(\n        self, on_done: Callable[[list[DownloadLogEntry]], None] | None = None\n    ) -> None:\n        if not self.pm.check_for_addon_updates():\n            if on_done:\n                on_done([])\n            return\n\n        last_check = self.pm.last_addon_update_check()\n        elap = int_time() - last_check\n\n        if elap > 86_400 or self.pm.last_run_version != int_version():\n            self.check_for_addon_updates(by_user=False, on_done=on_done)\n        elif on_done:\n            on_done([])\n\n    def check_for_addon_updates(\n        self,\n        by_user: bool,\n        on_done: Callable[[list[DownloadLogEntry]], None] | None = None,\n    ) -> None:\n        def wrap_on_updates_installed(log: list[DownloadLogEntry]) -> None:\n            self.on_updates_installed(log)\n            self.pm.set_last_addon_update_check(int_time())\n            if on_done:\n                on_done(log)\n\n        check_and_prompt_for_updates(\n            self,\n            self.addonManager,\n            wrap_on_updates_installed,\n            requested_by_user=by_user,\n        )\n\n    def on_updates_installed(self, log: list[DownloadLogEntry]) -> None:\n        if log:\n            show_log_to_user(self, log)\n\n    def setupSpellCheck(self) -> None:\n        os.environ[\"QTWEBENGINE_DICTIONARIES_PATH\"] = os.path.join(\n            self.pm.base, \"dictionaries\"\n        )\n\n    def setupThreads(self) -> None:\n        self._mainThread = QThread.currentThread()\n        self._background_op_count = 0\n\n    def inMainThread(self) -> bool:\n        return self._mainThread == QThread.currentThread()\n\n    def setupDeckBrowser(self) -> None:\n        from aqt.deckbrowser import DeckBrowser\n\n        self.deckBrowser = DeckBrowser(self)\n\n    def setupOverview(self) -> None:\n        from aqt.overview import Overview\n\n        self.overview = Overview(self)\n\n    def setupReviewer(self) -> None:\n        from aqt.reviewer import Reviewer\n\n        self.reviewer = Reviewer(self)\n\n    # Syncing\n    ##########################################################################\n\n    def on_sync_button_clicked(self) -> None:\n        if self.media_syncer.is_syncing():\n            self.media_syncer.show_sync_log()\n        else:\n            auth = self.pm.sync_auth()\n            if not auth:\n                sync_login(\n                    self,\n                    lambda: self._sync_collection_and_media(self._refresh_after_sync),\n                )\n            else:\n                self._sync_collection_and_media(self._refresh_after_sync)\n\n    def _refresh_after_sync(self) -> None:\n        self.toolbar.redraw()\n        self.flags.require_refresh()\n\n    def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None:\n        \"Caller should ensure auth available.\"\n\n        def on_collection_sync_finished() -> None:\n            self.col.models._clear_cache()\n            gui_hooks.sync_did_finish()\n            self.reset()\n\n            after_sync()\n\n        gui_hooks.sync_will_start()\n        sync_collection(self, on_done=on_collection_sync_finished)\n\n    def maybe_auto_sync_on_open_close(self, after_sync: Callable[[bool], None]) -> None:\n        \"If disabled, after_sync() is called immediately.\"\n        if self.can_auto_sync():\n            self._sync_collection_and_media(lambda: after_sync(True))\n        else:\n            after_sync(False)\n\n    def can_auto_sync(self) -> bool:\n        \"True if syncing on startup/shutdown enabled.\"\n        return self._can_sync_unattended() and self.pm.auto_syncing_enabled()\n\n    def _can_sync_unattended(self) -> bool:\n        return (\n            bool(self.pm.sync_auth())\n            and not self.safeMode\n            and not self.restoring_backup\n        )\n\n    # legacy\n    def _sync(self) -> None:\n        pass\n\n    onSync = on_sync_button_clicked\n\n    # Tools\n    ##########################################################################\n\n    def raiseMain(self) -> bool:\n        if not self.app.activeWindow():\n            # make sure window is shown\n            self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized)  # type: ignore\n        return True\n\n    def setupStyle(self) -> None:\n        theme_manager.apply_style()\n        if is_lin:\n            # On Linux, the check requires invoking an external binary,\n            # and can potentially produce verbose logs on systems where\n            # the preferred theme cannot be determined,\n            # which we don't want to be doing frequently\n            interval_secs = 300\n        else:\n            interval_secs = 2\n        self.progress.timer(\n            interval_secs * 1000,\n            theme_manager.apply_style,\n            True,\n            False,\n            parent=self,\n        )\n\n    def set_theme(self, theme: Theme) -> None:\n        self.pm.set_theme(theme)\n        self.setupStyle()\n\n    # Key handling\n    ##########################################################################\n\n    def setupKeys(self) -> None:\n        globalShortcuts = [\n            (\"Ctrl+:\", show_debug_console),\n            (\"d\", lambda: self.moveToState(\"deckBrowser\")),\n            (\"s\", self.onStudyKey),\n            (\"a\", self.onAddCard),\n            (\"b\", self.onBrowse),\n            (\"t\", self.onStats),\n            (\"Shift+t\", self.onStats),\n            (\"y\", self.on_sync_button_clicked),\n        ]\n        self.applyShortcuts(globalShortcuts)\n        self.stateShortcuts: list[QShortcut] = []\n\n    def _close_active_window(self) -> None:\n        window = (\n            QApplication.activeModalWidget()\n            or current_window()\n            or self.app.activeWindow()\n        )\n        if not window or window is self:\n            return\n        if window is getattr(self, \"profileDiag\", None):\n            # Do not allow closing of ProfileManager\n            return\n        if isinstance(window, QDialog):\n            window.reject()\n        else:\n            window.close()\n\n    def _normalize_shortcuts(\n        self, shortcuts: Sequence[tuple[str, Callable]]\n    ) -> Sequence[tuple[QKeySequence, Callable]]:\n        \"\"\"\n        Remove duplicate shortcuts (possibly added by add-ons)\n        by normalizing them and filtering through a dictionary.\n        The last duplicate shortcut wins, so add-ons will override\n        standard shortcuts if they append to the shortcut list.\n        \"\"\"\n        return tuple({QKeySequence(key): fn for key, fn in shortcuts}.items())\n\n    def applyShortcuts(\n        self, shortcuts: Sequence[tuple[str, Callable]]\n    ) -> list[QShortcut]:\n        qshortcuts = []\n        for key, fn in self._normalize_shortcuts(shortcuts):\n            scut = QShortcut(key, self, activated=fn)  # type: ignore\n            scut.setAutoRepeat(False)\n            qshortcuts.append(scut)\n        return qshortcuts\n\n    def setStateShortcuts(self, shortcuts: list[tuple[str, Callable]]) -> None:\n        gui_hooks.state_shortcuts_will_change(self.state, shortcuts)\n        # legacy hook\n        runHook(f\"{self.state}StateShortcuts\", shortcuts)\n        self.stateShortcuts = self.applyShortcuts(shortcuts)\n\n    def clearStateShortcuts(self) -> None:\n        for qs in self.stateShortcuts:\n            sip.delete(qs)  # type: ignore\n        self.stateShortcuts = []\n\n    def onStudyKey(self) -> None:\n        if self.state == \"overview\":\n            self.col.startTimebox()\n            self.moveToState(\"review\")\n        else:\n            self.moveToState(\"overview\")\n\n    # App exit\n    ##########################################################################\n\n    def closeEvent(self, event: QCloseEvent) -> None:\n        if self.state == \"profileManager\":\n            # if profile manager active, this event may fire via OS X menu bar's\n            # quit option\n            self.profileDiag.close()\n            event.accept()\n        else:\n            # ignore the event for now, as we need time to clean up\n            event.ignore()\n            self.unloadProfileAndExit()\n\n    # Undo & autosave\n    ##########################################################################\n\n    def undo(self) -> None:\n        \"Call operations/collection.py:undo() directly instead.\"\n        undo(parent=self)\n\n    def redo(self) -> None:\n        \"Call operations/collection.py:redo() directly instead.\"\n        redo(parent=self)\n\n    def undo_actions_info(self) -> UndoActionsInfo:\n        \"Info about the current undo/redo state for updating menus.\"\n        status = self.col.undo_status() if self.col else UndoStatus()\n        return UndoActionsInfo.from_undo_status(status)\n\n    def update_undo_actions(self) -> None:\n        \"\"\"Tell the UI to redraw the undo/redo menu actions based on the current state.\n\n        Usually you do not need to call this directly; it is called when a\n        CollectionOp is run, and will be called when the legacy .reset() or\n        .checkpoint() methods are used.\"\"\"\n        info = self.undo_actions_info()\n        self.form.actionUndo.setText(info.undo_text)\n        self.form.actionUndo.setEnabled(info.can_undo)\n        self.form.actionRedo.setText(info.redo_text)\n        self.form.actionRedo.setEnabled(info.can_redo)\n        self.form.actionRedo.setVisible(info.show_redo)\n        gui_hooks.undo_state_did_change(info)\n\n    @deprecated(info=\"checkpoints are no longer supported\")\n    def checkpoint(self, name: str) -> None:\n        pass\n\n    @deprecated(info=\"saving is automatic\")\n    def autosave(self) -> None:\n        pass\n\n    onUndo = undo\n\n    # Other menu operations\n    ##########################################################################\n\n    def onAddCard(self) -> None:\n        aqt.dialogs.open(\"AddCards\", self)\n\n    def onBrowse(self) -> None:\n        aqt.dialogs.open(\"Browser\", self, card=self.reviewer.card)\n\n    def onEditCurrent(self) -> None:\n        aqt.dialogs.open(\"EditCurrent\", self)\n\n    def onOverview(self) -> None:\n        self.moveToState(\"overview\")\n\n    def onStats(self) -> None:\n        deck = self._selectedDeck()\n        if not deck:\n            return\n        want_old = KeyboardModifiersPressed().shift\n        if want_old:\n            aqt.dialogs.open(\"DeckStats\", self)\n        else:\n            aqt.dialogs.open(\"NewDeckStats\", self)\n\n    def onPrefs(self) -> None:\n        aqt.dialogs.open(\"Preferences\", self)\n\n    def on_upgrade_downgrade(self) -> None:\n        if not askUser(tr.qt_misc_open_anki_launcher()):\n            return\n\n        from aqt.package import update_and_restart\n\n        update_and_restart()\n\n    def onNoteTypes(self) -> None:\n        import aqt.models\n\n        aqt.models.Models(self, self, fromMain=True)\n\n    def onAbout(self) -> None:\n        aqt.dialogs.open(\"About\", self)\n\n    def onDonate(self) -> None:\n        openLink(aqt.appDonate)\n\n    def onDocumentation(self) -> None:\n        openHelp(HelpPage.INDEX)\n\n    # legacy\n\n    def onDeckConf(self, deck: DeckDict | None = None) -> None:\n        pass\n\n    # Importing & exporting\n    ##########################################################################\n\n    def handleImport(self, path: str) -> None:\n        \"Importing triggered via file double-click, or dragging file onto Anki icon.\"\n        import aqt.importing\n\n        if not os.path.exists(path):\n            # there were instances in the distant past where the received filename was not\n            # valid (encoding issues?), so this was added to direct users to try\n            # file>import instead.\n            showInfo(f\"{tr.qt_misc_please_use_fileimport_to_import_this()} ({path})\")\n            return None\n\n        if not self.pm.legacy_import_export():\n            import_file(self, path)\n        else:\n            aqt.importing.importFile(self, path)\n\n    def onImport(self) -> None:\n        \"Importing triggered via File>Import.\"\n        import aqt.importing\n\n        if not self.pm.legacy_import_export():\n            prompt_for_file_then_import(self)\n        else:\n            aqt.importing.onImport(self)\n\n    def onExport(self, did: DeckId | None = None) -> None:\n        import aqt.exporting\n\n        if not self.pm.legacy_import_export():\n            ExportDialog(self, did=did)\n        else:\n            aqt.exporting.ExportDialog(self, did=did)\n\n    # Installing add-ons from CLI / mimetype handler\n    ##########################################################################\n\n    def installAddon(self, path: str, startup: bool = False) -> None:\n        from aqt.addons import installAddonPackages\n\n        installAddonPackages(\n            self.addonManager,\n            [path],\n            warn=True,\n            advise_restart=not startup,\n            strictly_modal=startup,\n            parent=None if startup else self,\n            force_enable=True,\n        )\n\n    # Cramming\n    ##########################################################################\n\n    def onCram(self) -> None:\n        aqt.dialogs.open(\"FilteredDeckConfigDialog\", self)\n\n    # Menu, title bar & status\n    ##########################################################################\n\n    def setupMenus(self) -> None:\n        from aqt.package import launcher_executable\n\n        m = self.form\n\n        # File\n        qconnect(\n            m.actionSwitchProfile.triggered, self.unloadProfileAndShowProfileManager\n        )\n        qconnect(m.actionImport.triggered, self.onImport)\n        qconnect(m.actionExport.triggered, self.onExport)\n        qconnect(m.action_create_backup.triggered, self.on_create_backup_now)\n        qconnect(m.action_open_backup.triggered, self.onOpenBackup)\n        qconnect(m.actionExit.triggered, self.close)\n\n        # Help\n        qconnect(m.actionDocumentation.triggered, self.onDocumentation)\n        qconnect(m.actionDonate.triggered, self.onDonate)\n        qconnect(m.actionAbout.triggered, self.onAbout)\n        m.actionAbout.setText(tr.qt_accel_about_mac())\n\n        # Edit\n        qconnect(m.actionUndo.triggered, self.undo)\n        qconnect(m.actionRedo.triggered, self.redo)\n\n        # Tools\n        qconnect(m.actionFullDatabaseCheck.triggered, self.onCheckDB)\n        qconnect(m.actionCheckMediaDatabase.triggered, self.on_check_media_db)\n        qconnect(m.actionStudyDeck.triggered, self.onStudyDeck)\n        qconnect(m.actionCreateFiltered.triggered, self.onCram)\n        qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)\n        qconnect(m.actionNoteTypes.triggered, self.onNoteTypes)\n        qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade)\n        if not launcher_executable():\n            m.action_upgrade_downgrade.setVisible(False)\n        qconnect(m.actionPreferences.triggered, self.onPrefs)\n\n        # View\n        qconnect(\n            m.actionZoomIn.triggered,\n            lambda: self.web.setZoomFactor(self.web.zoomFactor() + 0.1),\n        )\n        qconnect(\n            m.actionZoomOut.triggered,\n            lambda: self.web.setZoomFactor(self.web.zoomFactor() - 0.1),\n        )\n        qconnect(m.actionResetZoom.triggered, lambda: self.web.setZoomFactor(1))\n        # app-wide shortcut\n        qconnect(m.actionFullScreen.triggered, self.on_toggle_full_screen)\n        m.actionFullScreen.setShortcut(\n            QKeySequence(\"F11\") if is_lin else QKeySequence.StandardKey.FullScreen\n        )\n        m.actionFullScreen.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)\n\n    def updateTitleBar(self) -> None:\n        self.setWindowTitle(\"Anki\")\n\n    # View\n    ##########################################################################\n\n    def on_toggle_full_screen(self) -> None:\n        if disallow_full_screen():\n            showWarning(\n                tr.actions_fullscreen_unsupported(),\n                parent=self,\n                help=HelpPage.FULL_SCREEN_ISSUE,\n            )\n            return\n        else:\n            window = self.app.activeWindow()\n            window.setWindowState(\n                window.windowState() ^ Qt.WindowState.WindowFullScreen\n            )\n\n        # Hide Menubar on Windows and Linux\n        if window.windowState() & Qt.WindowState.WindowFullScreen and not is_mac:\n            self.fullscreen = True\n            self.hide_menubar()\n        else:\n            self.fullscreen = False\n            self.show_menubar()\n\n        # Update Toolbar states\n        self.toolbarWeb.hide_if_allowed()\n        self.bottomWeb.hide_if_allowed()\n\n    def hide_menubar(self) -> None:\n        self.form.menubar.setFixedHeight(0)\n\n    def show_menubar(self) -> None:\n        self.form.menubar.setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX)\n        self.form.menubar.setMinimumSize(0, 0)\n\n    # Auto update\n    ##########################################################################\n\n    def setup_auto_update(self, _log: list[DownloadLogEntry]) -> None:\n        from aqt.update import check_for_update\n\n        if aqt.mw.pm.check_for_updates():\n            check_for_update()\n\n    # Timers\n    ##########################################################################\n\n    def setup_timers(self) -> None:\n        # refresh decks every 10 minutes\n        self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True, parent=self)\n        # check media sync every 5 minutes\n        self.progress.timer(\n            5 * 60 * 1000, self.on_periodic_sync_timer, True, parent=self\n        )\n        # periodic garbage collection\n        self.progress.timer(\n            15 * 60 * 1000, self.garbage_collect_now, True, False, parent=self\n        )\n        # ensure Python interpreter runs at least once per second, so that\n        # SIGINT/SIGTERM is processed without a long delay\n        self.progress.timer(1000, lambda: None, True, False, parent=self)\n        # periodic backups are checked every 5 minutes\n        self.progress.timer(\n            5 * 60 * 1000,\n            self.on_periodic_backup_timer,\n            True,\n            parent=self,\n        )\n\n    def onRefreshTimer(self) -> None:\n        if self.state == \"deckBrowser\":\n            self.deckBrowser.refresh()\n        elif self.state == \"overview\":\n            self.overview.refresh()\n\n    def on_periodic_sync_timer(self) -> None:\n        elap = self.media_syncer.seconds_since_last_sync()\n        minutes = self.pm.periodic_sync_media_minutes()\n        if not minutes:\n            return\n        if elap > minutes * 60:\n            if not self._can_sync_unattended():\n                return\n            # media_syncer takes care of media syncing preference check\n            self.media_syncer.start(True)\n\n    # Backups\n    ##########################################################################\n\n    def on_periodic_backup_timer(self) -> None:\n        \"\"\"Create a backup if enough time has elapsed and collection changed.\"\"\"\n        self._create_backup_with_progress(user_initiated=False)\n\n    def on_create_backup_now(self) -> None:\n        self._create_backup_with_progress(user_initiated=True)\n\n    def create_backup_now(self) -> None:\n        \"\"\"Create a backup immediately, regardless of when the last one was created.\n        Waits until the backup completes. Intended to be used as part of a longer-running\n        CollectionOp/QueryOp.\"\"\"\n        self.col.create_backup(\n            backup_folder=self.pm.backupFolder(),\n            force=True,\n            wait_for_completion=True,\n        )\n\n    def _create_backup_with_progress(self, user_initiated: bool) -> None:\n        # The initial copy will display a progress window if it takes too long\n        def backup(col: Collection) -> bool:\n            return col.create_backup(\n                backup_folder=self.pm.backupFolder(),\n                force=user_initiated,\n                wait_for_completion=False,\n            )\n\n        def on_success(val: None) -> None:\n            if user_initiated:\n                tooltip(tr.profiles_backup_created(), parent=self)\n\n        def on_failure(exc: Exception) -> None:\n            showWarning(\n                tr.profiles_backup_creation_failed(reason=str(exc)), parent=self\n            )\n\n        def after_backup_started(created: bool) -> None:\n            self.update_undo_actions()\n\n            if user_initiated and not created:\n                tooltip(tr.profiles_backup_unchanged(), parent=self)\n                return\n\n            # We await backup completion to confirm it was successful, but this step\n            # does not block collection access, so we don't need to show the progress\n            # window anymore.\n            QueryOp(\n                parent=self,\n                op=lambda col: col.await_backup_completion(),\n                success=on_success,\n            ).failure(on_failure).without_collection().run_in_background()\n\n        QueryOp(parent=self, op=backup, success=after_backup_started).failure(\n            on_failure\n        ).with_progress(tr.profiles_creating_backup()).run_in_background()\n\n    # Permanent hooks\n    ##########################################################################\n\n    def setupHooks(self) -> None:\n        hooks.schema_will_change.append(self.onSchemaMod)\n        hooks.notes_will_be_deleted.append(self.onRemNotes)\n        hooks.card_odue_was_invalid.append(self.onOdueInvalid)\n\n        gui_hooks.av_player_will_play.append(self.on_av_player_will_play)\n        gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing)\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n        gui_hooks.focus_did_change.append(self.on_focus_did_change)\n\n        self._activeWindowOnPlay: QWidget | None = None\n\n    def onOdueInvalid(self) -> None:\n        showWarning(tr.qt_misc_invalid_property_found_on_card_please())\n\n    def _isVideo(self, tag: AVTag) -> bool:\n        if isinstance(tag, SoundOrVideoTag):\n            head, ext = os.path.splitext(tag.filename.lower())\n            return ext in (\".mp4\", \".mov\", \".mpg\", \".mpeg\", \".mkv\", \".avi\")\n\n        return False\n\n    def on_av_player_will_play(self, tag: AVTag) -> None:\n        \"Record active window to restore after video playing.\"\n        if not self._isVideo(tag):\n            return\n\n        self._activeWindowOnPlay = self.app.activeWindow() or self._activeWindowOnPlay\n\n    def on_av_player_did_end_playing(self, player: Any) -> None:\n        \"Restore window focus after a video was played.\"\n        w = self._activeWindowOnPlay\n        if not self.app.activeWindow() and w and not sip.isdeleted(w) and w.isVisible():\n            w.activateWindow()\n            w.raise_()\n        self._activeWindowOnPlay = None\n\n    # Log note deletion\n    ##########################################################################\n\n    def onRemNotes(self, col: Collection, nids: Sequence[NoteId]) -> None:\n        path = os.path.join(self.pm.profileFolder(), \"deleted.txt\")\n        existed = os.path.exists(path)\n        with open(path, \"ab\") as f:\n            if not existed:\n                f.write(b\"#guid column:1\\n\")\n                f.write(b\"#notetype column:2\\n\")\n                f.write(b\"#nid\\tmid\\tfields\\n\")\n            for id, mid, flds in col.db.execute(\n                f\"select id, mid, flds from notes where id in {ids2str(nids)}\"\n            ):\n                fields = split_fields(flds)\n                f.write((\"\\t\".join([str(id), str(mid)] + fields)).encode(\"utf8\"))\n                f.write(b\"\\n\")\n\n    # Schema modifications\n    ##########################################################################\n\n    # this will gradually be phased out\n    def onSchemaMod(self, arg: bool) -> bool:\n        if not self.inMainThread():\n            raise Exception(\"not in main thread\")\n        progress_shown = self.progress.busy()\n        if progress_shown:\n            self.progress.finish()\n        ret = askUser(tr.qt_misc_the_requested_change_will_require_a())\n        if progress_shown:\n            self.progress.start()\n        return ret\n\n    # in favour of this\n    def confirm_schema_modification(self) -> bool:\n        \"\"\"If schema unmodified, ask user to confirm change.\n        True if confirmed or already modified.\"\"\"\n        if self.col.schema_changed():\n            return True\n        return askUser(tr.qt_misc_the_requested_change_will_require_a())\n\n    # Advanced features\n    ##########################################################################\n\n    def onCheckDB(self) -> None:\n        check_db(self)\n\n    def on_check_media_db(self) -> None:\n        gui_hooks.media_check_will_start()\n        check_media_db(self)\n\n    def onStudyDeck(self) -> None:\n        from aqt.studydeck import StudyDeck\n\n        def callback(ret: StudyDeck) -> None:\n            if not ret.name:\n                return\n            deck_id = self.col.decks.id(ret.name)\n            set_current_deck(parent=self, deck_id=deck_id).success(\n                lambda out: self.moveToState(\"overview\")\n            ).run_in_background()\n\n        StudyDeck(\n            self,\n            parent=self,\n            dyn=True,\n            current=self.col.decks.current()[\"name\"],\n            callback=callback,\n        )\n\n    def onEmptyCards(self) -> None:\n        show_empty_cards(self)\n\n    # System specific code\n    ##########################################################################\n\n    def setupSystemSpecific(self) -> None:\n        self.hideMenuAccels = False\n        if is_mac:\n            # mac users expect a minimize option\n            self.minimizeShortcut = QShortcut(\"Ctrl+M\", self)\n            qconnect(self.minimizeShortcut.activated, self.onMacMinimize)\n            self.hideMenuAccels = True\n            self.maybeHideAccelerators()\n            self.hideStatusTips()\n        elif is_win:\n            self._setupWin32()\n\n    def _setupWin32(self):\n        \"\"\"Fix taskbar display/pinning\"\"\"\n        if sys.platform != \"win32\":\n            return\n\n        launcher_path = os.environ.get(\"ANKI_LAUNCHER\")\n        if not launcher_path:\n            return\n\n        from win32com.propsys import propsys, pscon\n        from win32com.propsys.propsys import PROPVARIANTType\n\n        hwnd = int(self.winId())\n        prop_store = propsys.SHGetPropertyStoreForWindow(hwnd)  # type: ignore[call-arg]\n        prop_store.SetValue(\n            pscon.PKEY_AppUserModel_ID, PROPVARIANTType(\"Ankitects.Anki\")\n        )\n        prop_store.SetValue(\n            pscon.PKEY_AppUserModel_RelaunchCommand,\n            PROPVARIANTType(f'\"{launcher_path}\"'),\n        )\n        prop_store.SetValue(\n            pscon.PKEY_AppUserModel_RelaunchDisplayNameResource, PROPVARIANTType(\"Anki\")\n        )\n        prop_store.SetValue(\n            pscon.PKEY_AppUserModel_RelaunchIconResource,\n            PROPVARIANTType(f\"{launcher_path},0\"),\n        )\n        prop_store.Commit()\n\n    def maybeHideAccelerators(self, tgt: Any | None = None) -> None:\n        if not self.hideMenuAccels:\n            return\n        tgt = tgt or self\n        for action_ in tgt.findChildren(QAction):\n            action = cast(QAction, action_)\n            txt = str(action.text())\n            m = re.match(r\"^(.+)\\(&.+\\)(.+)?\", txt)\n            if m:\n                action.setText(m.group(1) + (m.group(2) or \"\"))\n\n    def hideStatusTips(self) -> None:\n        for action in self.findChildren(QAction):\n            # On Windows, this next line gives a 'redundant cast' error after moving to\n            # PyQt6.5.2.\n            cast(QAction, action).setStatusTip(\"\")  # type: ignore\n\n    def onMacMinimize(self) -> None:\n        self.setWindowState(self.windowState() | Qt.WindowState.WindowMinimized)  # type: ignore\n\n    # Single instance support\n    ##########################################################################\n\n    def setupAppMsg(self) -> None:\n        qconnect(self.app.appMsg, self.onAppMsg)\n\n    def onAppMsg(self, buf: str) -> None:\n        is_addon = self._isAddon(buf)\n\n        if self.state == \"startup\":\n            # try again in a second\n            self.progress.single_shot(\n                1000,\n                lambda: self.onAppMsg(buf),\n                False,\n            )\n            return\n        elif self.state == \"profileManager\":\n            # can't raise window while in profile manager\n            if buf == \"raise\":\n                return None\n            self.pendingImport = buf\n            if is_addon:\n                msg = tr.qt_misc_addon_will_be_installed_when_a()\n            else:\n                msg = tr.qt_misc_deck_will_be_imported_when_a()\n            tooltip(msg)\n            return\n        if not self.interactiveState() or self.progress.busy():\n            # we can't raise the main window while in profile dialog, syncing, etc\n            if buf != \"raise\":\n                showInfo(\n                    tr.qt_misc_please_ensure_a_profile_is_open(),\n                    parent=None,\n                )\n            return None\n        # raise window\n        if is_win:\n            # on windows we can raise the window by minimizing and restoring\n            self.showMinimized()\n            self.setWindowState(Qt.WindowState.WindowActive)\n            self.showNormal()\n        else:\n            # on osx we can raise the window. on unity the icon in the tray will just flash.\n            self.activateWindow()\n            self.raise_()\n        if buf == \"raise\":\n            return None\n\n        # import / add-on installation\n        if is_addon:\n            self.installAddon(buf)\n        else:\n            self.handleImport(buf)\n\n        return None\n\n    def _isAddon(self, buf: str) -> bool:\n        # only accept primary extension here to avoid conflicts with deck packages\n        return buf.endswith(self.addonManager.exts[0])\n\n    def interactiveState(self) -> bool:\n        \"True if not in profile manager, syncing, etc.\"\n        return self.state in (\"overview\", \"review\", \"deckBrowser\")\n\n    # GC\n    ##########################################################################\n    # The default Python garbage collection can trigger on any thread. This can\n    # cause crashes if Qt objects are garbage-collected, as Qt expects access\n    # only on the main thread. So Anki disables the default GC on startup, and\n    # instead runs it on a timer, and after dialog close.\n    # The gc after dialog close is necessary to free up the memory and extra\n    # processes that webviews spawn, as a lot of the GUI code creates ref cycles.\n\n    def garbage_collect_on_dialog_finish(self, dialog: QDialog) -> None:\n        qconnect(\n            dialog.finished, lambda: self.deferred_delete_and_garbage_collect(dialog)\n        )\n\n    def deferred_delete_and_garbage_collect(self, obj: QObject) -> None:\n        obj.deleteLater()\n        self.progress.single_shot(1000, self.garbage_collect_now, False)\n\n    def disable_automatic_garbage_collection(self) -> None:\n        gc.collect()\n        gc.disable()\n\n    def garbage_collect_now(self) -> None:\n        # gc.collect() has optional arguments that will cause problems if\n        # it's passed directly to a QTimer, and pylint complains if we\n        # wrap it in a lambda, so we use this trivial wrapper\n        gc.collect()\n\n    # legacy aliases\n\n    setupDialogGC = garbage_collect_on_dialog_finish\n    gcWindow = deferred_delete_and_garbage_collect\n\n    # Media server\n    ##########################################################################\n\n    def setupMediaServer(self) -> None:\n        self.mediaServer = aqt.mediasrv.MediaServer(self)\n        self.mediaServer.start()\n\n    def baseHTML(self) -> str:\n        return f'<base href=\"{self.serverURL()}\">'\n\n    def serverURL(self) -> str:\n        return \"http://127.0.0.1:%d/\" % self.mediaServer.getPort()\n\n\n# legacy\nclass ResetReason(enum.Enum):\n    Unknown = \"unknown\"\n    AddCardsAddNote = \"addCardsAddNote\"\n    EditCurrentInit = \"editCurrentInit\"\n    EditorBridgeCmd = \"editorBridgeCmd\"\n    BrowserSetDeck = \"browserSetDeck\"\n    BrowserAddTags = \"browserAddTags\"\n    BrowserRemoveTags = \"browserRemoveTags\"\n    BrowserSuspend = \"browserSuspend\"\n    BrowserReposition = \"browserReposition\"\n    BrowserReschedule = \"browserReschedule\"\n    BrowserFindReplace = \"browserFindReplace\"\n    BrowserTagDupes = \"browserTagDupes\"\n    BrowserDeleteDeck = \"browserDeleteDeck\"\n"
  },
  {
    "path": "qt/aqt/mediacheck.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport itertools\nimport time\nfrom collections.abc import Iterable, Sequence\nfrom concurrent.futures import Future\nfrom typing import TypeVar\n\nimport aqt\nimport aqt.progress\nfrom anki.collection import Collection, SearchNode\nfrom anki.errors import Interrupted\nfrom anki.media import CheckMediaResponse\nfrom anki.notes import NoteId\nfrom aqt import gui_hooks\nfrom aqt.operations import QueryOp\nfrom aqt.operations.tag import add_tags_to_notes\nfrom aqt.qt import *\nfrom aqt.utils import (\n    askUser,\n    disable_help_button,\n    openFolder,\n    restoreGeom,\n    saveGeom,\n    showText,\n    tooltip,\n    tr,\n)\n\nT = TypeVar(\"T\")\n\n\ndef chunked_list(l: Iterable[T], n: int) -> Iterable[list[T]]:\n    l = iter(l)\n    while True:\n        res = list(itertools.islice(l, n))\n        if not res:\n            return\n        yield res\n\n\ndef check_media_db(mw: aqt.AnkiQt) -> None:\n    c = MediaChecker(mw)\n    c.check()\n\n\nclass MediaChecker:\n    progress_dialog: aqt.progress.ProgressDialog | None\n\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        self.mw = mw\n        self._progress_timer: QTimer | None = None\n\n    def check(self) -> None:\n        self.progress_dialog = self.mw.progress.start()\n        self._set_progress_enabled(True)\n        self.mw.taskman.run_in_background(self._check, self._on_finished)\n\n    def _set_progress_enabled(self, enabled: bool) -> None:\n        if self._progress_timer:\n            self._progress_timer.stop()\n            self._progress_timer.deleteLater()\n            self._progress_timer = None\n        if enabled:\n            self._progress_timer = timer = QTimer()\n            timer.setSingleShot(False)\n            timer.setInterval(100)\n            qconnect(timer.timeout, self._on_progress)\n            timer.start()\n\n    def _on_progress(self) -> None:\n        if not self.mw.col:\n            return\n        progress = self.mw.col.latest_progress()\n        if not progress.HasField(\"media_check\"):\n            return\n        label = progress.media_check\n\n        try:\n            assert self.progress_dialog is not None\n            if self.progress_dialog.wantCancel:\n                self.mw.col.set_wants_abort()\n        except AttributeError:\n            # dialog may not be active\n            pass\n\n        self.mw.taskman.run_on_main(lambda: self.mw.progress.update(label=label))\n\n    def _check(self) -> CheckMediaResponse:\n        \"Run the check on a background thread.\"\n        return self.mw.col.media.check()\n\n    def _on_finished(self, future: Future) -> None:\n        self._set_progress_enabled(False)\n        self.mw.progress.finish()\n        self.progress_dialog = None\n\n        exc = future.exception()\n        if isinstance(exc, Interrupted):\n            return\n\n        output: CheckMediaResponse = future.result()\n        gui_hooks.media_check_did_finish(output)\n        report = output.report\n\n        # show report and offer to delete\n        diag = QDialog(self.mw)\n        diag.setWindowTitle(tr.media_check_window_title())\n        disable_help_button(diag)\n        layout = QVBoxLayout(diag)\n        diag.setLayout(layout)\n        text = QPlainTextEdit()\n        text.setReadOnly(True)\n        text.setPlainText(report)\n        text.setWordWrapMode(QTextOption.WrapMode.NoWrap)\n        layout.addWidget(text)\n        box = QDialogButtonBox()\n        layout.addWidget(box)\n\n        if output.unused:\n            b = QPushButton(tr.media_check_delete_unused())\n            b.setAutoDefault(False)\n            box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole)\n            qconnect(b.clicked, lambda c: self._on_trash_files(output.unused))\n\n        if output.missing:\n            b = QPushButton(tr.media_check_add_tag())\n            b.setAutoDefault(False)\n            box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole)\n            qconnect(\n                b.clicked,\n                lambda: add_missing_media_tag(self.mw, output.missing_media_notes),\n            )\n\n            if any(map(lambda x: x.startswith(\"latex-\"), output.missing)):\n                b = QPushButton(tr.media_check_render_latex())\n                b.setAutoDefault(False)\n                box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole)\n                qconnect(b.clicked, self._on_render_latex)\n\n        if output.have_trash:\n            b = QPushButton(tr.media_check_empty_trash())\n            b.setAutoDefault(False)\n            box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole)\n            qconnect(b.clicked, lambda c: self._on_empty_trash())\n\n            b = QPushButton(tr.media_check_restore_trash())\n            b.setAutoDefault(False)\n            box.addButton(b, QDialogButtonBox.ButtonRole.RejectRole)\n            qconnect(b.clicked, lambda c: self._on_restore_trash())\n\n        b = QPushButton(tr.addons_view_files())\n        b.setAutoDefault(False)\n        box.addButton(b, QDialogButtonBox.ButtonRole.ActionRole)\n        qconnect(b.clicked, lambda c: self._on_view_files())\n\n        qconnect(box.rejected, diag.reject)\n        diag.setMinimumHeight(400)\n        diag.setMinimumWidth(500)\n        restoreGeom(diag, \"checkmediadb\", default_size=(800, 800))\n        diag.exec()\n        saveGeom(diag, \"checkmediadb\")\n\n    def _on_render_latex(self) -> None:\n        self.progress_dialog = self.mw.progress.start()\n        assert self.progress_dialog is not None\n        try:\n            out = self.mw.col.media.render_all_latex(self._on_render_latex_progress)\n            if self.progress_dialog.wantCancel:\n                return\n        finally:\n            self.mw.progress.finish()\n            self.progress_dialog = None\n\n        if out is not None:\n            nid, err = out\n            aqt.dialogs.open(\"Browser\", self.mw, search=(SearchNode(nid=nid),))\n            showText(err, type=\"html\")\n        else:\n            tooltip(tr.media_check_all_latex_rendered())\n\n    def _on_render_latex_progress(self, count: int) -> bool:\n        assert self.progress_dialog is not None\n        if self.progress_dialog.wantCancel:\n            return False\n\n        self.mw.progress.update(tr.media_check_checked(count=count))\n        return True\n\n    def _on_trash_files(self, fnames: Sequence[str]) -> None:\n        if not askUser(tr.media_check_delete_unused_confirm()):\n            return\n\n        total = len(fnames)\n\n        def trash(col: Collection) -> None:\n            last_progress = 0.0\n            remaining = total\n\n            for chunk in chunked_list(fnames, 25):\n                col.media.trash_files(chunk)\n                remaining -= len(chunk)\n                if time.time() - last_progress >= 0.1:\n                    self.mw.taskman.run_on_main(\n                        lambda: self.mw.progress.update(\n                            label=tr.media_check_files_remaining(count=remaining),\n                            value=total - remaining,\n                            max=total,\n                        )\n                    )\n                    last_progress = time.time()\n\n        QueryOp(\n            parent=aqt.mw,\n            op=trash,\n            success=lambda _: tooltip(\n                tr.media_check_delete_unused_complete(count=total)\n            ),\n        ).with_progress().run_in_background()\n\n    def _on_empty_trash(self) -> None:\n        self.progress_dialog = self.mw.progress.start()\n        self._set_progress_enabled(True)\n\n        def empty_trash() -> None:\n            self.mw.col.media.empty_trash()\n\n        def on_done(fut: Future) -> None:\n            self.mw.progress.finish()\n            self._set_progress_enabled(False)\n            # check for errors\n            fut.result()\n\n            tooltip(tr.media_check_trash_emptied())\n\n        self.mw.taskman.run_in_background(empty_trash, on_done)\n\n    def _on_restore_trash(self) -> None:\n        self.progress_dialog = self.mw.progress.start()\n        self._set_progress_enabled(True)\n\n        def restore_trash() -> None:\n            self.mw.col.media.restore_trash()\n\n        def on_done(fut: Future) -> None:\n            self.mw.progress.finish()\n            self._set_progress_enabled(False)\n            # check for errors\n            fut.result()\n\n            tooltip(tr.media_check_trash_restored())\n\n        self.mw.taskman.run_in_background(restore_trash, on_done)\n\n    def _on_view_files(self) -> None:\n        openFolder(self.mw.col.media.dir())\n\n\ndef add_missing_media_tag(parent: QWidget, missing_media_notes: Sequence[int]) -> None:\n    add_tags_to_notes(\n        parent=parent,\n        note_ids=list(map(NoteId, missing_media_notes)),\n        space_separated_tags=tr.media_check_missing_media_tag(),\n    ).run_in_background()\n"
  },
  {
    "path": "qt/aqt/mediasrv.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport enum\nimport logging\nimport mimetypes\nimport os\nimport re\nimport secrets\nimport sys\nimport threading\nimport traceback\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom errno import EPROTOTYPE\nfrom http import HTTPStatus\n\nimport flask\nimport flask_cors\nimport stringcase\nimport waitress.wasyncore\nfrom flask import Response, abort, request\nfrom waitress.server import create_server\n\nimport aqt\nimport aqt.main\nimport aqt.operations\nfrom anki import hooks\nfrom anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode\nfrom anki.decks import UpdateDeckConfigs\nfrom anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest\nfrom anki.utils import dev_mode\nfrom aqt.changenotetype import ChangeNotetypeDialog\nfrom aqt.deckoptions import DeckOptionsDialog\nfrom aqt.operations import on_op_finished\nfrom aqt.operations.deck import update_deck_configs as update_deck_configs_op\nfrom aqt.progress import ProgressUpdate\nfrom aqt.qt import *\nfrom aqt.utils import aqt_data_path, show_warning, tr\n\n# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266\nwaitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE})  # type: ignore\n\nlogger = logging.getLogger(__name__)\napp = flask.Flask(__name__, root_path=\"/fake\")\nflask_cors.CORS(app, resources={r\"/*\": {\"origins\": \"127.0.0.1\"}})\n\n\n@dataclass\nclass LocalFileRequest:\n    # base folder, eg media folder\n    root: str\n    # path to file relative to root folder\n    path: str\n\n\n@dataclass\nclass BundledFileRequest:\n    # path relative to aqt data folder\n    path: str\n\n\n@dataclass\nclass NotFound:\n    message: str\n\n\nDynamicRequest = Callable[[], Response]\n\n\nclass PageContext(enum.IntEnum):\n    UNKNOWN = enum.auto()\n    EDITOR = enum.auto()\n    REVIEWER = enum.auto()\n    PREVIEWER = enum.auto()\n    CARD_LAYOUT = enum.auto()\n    DECK_OPTIONS = enum.auto()\n    # something in /_anki/pages/\n    NON_LEGACY_PAGE = enum.auto()\n    # Do not use this if you present user content (e.g. content from cards), as it's a\n    # security issue.\n    ADDON_PAGE = enum.auto()\n\n\n@dataclass\nclass LegacyPage:\n    html: str\n    context: PageContext\n\n\nclass MediaServer(threading.Thread):\n    _ready = threading.Event()\n    daemon = True\n\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        super().__init__()\n        self.is_shutdown = False\n        # map of webview ids to pages\n        self._legacy_pages: dict[int, LegacyPage] = {}\n\n    def run(self) -> None:\n        try:\n            desired_host = os.getenv(\"ANKI_API_HOST\", \"127.0.0.1\")\n            desired_port = int(os.getenv(\"ANKI_API_PORT\") or 0)\n            self.server = create_server(\n                app,\n                host=desired_host,\n                port=desired_port,\n                clear_untrusted_proxy_headers=True,\n            )\n            logger.info(\n                \"Serving on http://%s:%s\",\n                self.server.effective_host,  # type: ignore[union-attr]\n                self.server.effective_port,  # type: ignore[union-attr]\n            )\n\n            self._ready.set()\n            self.server.run()\n\n        except Exception:\n            if not self.is_shutdown:\n                raise\n\n    def shutdown(self) -> None:\n        self.is_shutdown = True\n        sockets = list(self.server._map.values())  # type: ignore\n        for socket in sockets:\n            socket.handle_close()\n        # https://github.com/Pylons/webtest/blob/4b8a3ebf984185ff4fefb31b4d0cf82682e1fcf7/webtest/http.py#L93-L104\n        self.server.task_dispatcher.shutdown()\n\n    def getPort(self) -> int:\n        self._ready.wait()\n        return int(self.server.effective_port)  # type: ignore\n\n    def set_page_html(\n        self, id: int, html: str, context: PageContext = PageContext.UNKNOWN\n    ) -> None:\n        self._legacy_pages[id] = LegacyPage(html, context)\n\n    def get_page(self, id: int) -> LegacyPage | None:\n        return self._legacy_pages.get(id)\n\n    def get_page_html(self, id: int) -> str | None:\n        if page := self.get_page(id):\n            return page.html\n        else:\n            return None\n\n    def get_page_context(self, id: int) -> PageContext | None:\n        if page := self.get_page(id):\n            return page.context\n        else:\n            return None\n\n    def clear_page_html(self, id: int) -> None:\n        try:\n            del self._legacy_pages[id]\n        except KeyError:\n            pass\n\n\n@app.route(\"/favicon.ico\")\ndef favicon() -> Response:\n    request = BundledFileRequest(os.path.join(\"imgs\", \"favicon.ico\"))\n    return _handle_builtin_file_request(request)\n\n\ndef _mime_for_path(path: str) -> str:\n    \"Mime type for provided path/filename.\"\n\n    _, ext = os.path.splitext(path)\n    ext = ext.lower()\n\n    # Badly-behaved apps on Windows can alter the standard mime types in the registry, which can completely\n    # break Anki's UI. So we hard-code the most common extensions.\n    mime_types = {\n        \".css\": \"text/css\",\n        \".js\": \"application/javascript\",\n        \".mjs\": \"application/javascript\",\n        \".html\": \"text/html\",\n        \".htm\": \"text/html\",\n        \".svg\": \"image/svg+xml\",\n        \".png\": \"image/png\",\n        \".jpg\": \"image/jpeg\",\n        \".jpeg\": \"image/jpeg\",\n        \".gif\": \"image/gif\",\n        \".webp\": \"image/webp\",\n        \".ico\": \"image/x-icon\",\n        \".json\": \"application/json\",\n        \".woff\": \"font/woff\",\n        \".woff2\": \"font/woff2\",\n        \".ttf\": \"font/ttf\",\n        \".otf\": \"font/otf\",\n        \".mp3\": \"audio/mpeg\",\n        \".mp4\": \"video/mp4\",\n        \".webm\": \"video/webm\",\n        \".ogg\": \"audio/ogg\",\n        \".pdf\": \"application/pdf\",\n        \".txt\": \"text/plain\",\n    }\n\n    if mime := mime_types.get(ext):\n        return mime\n    else:\n        # fallback to mimetypes, which may consult the registry\n        mime, _encoding = mimetypes.guess_type(path)\n        return mime or \"application/octet-stream\"\n\n\ndef _text_response(code: HTTPStatus, text: str) -> Response:\n    \"\"\"Return an error message.\n\n    Response is returned as text/plain, so no escaping of untrusted\n    input is required.\"\"\"\n    resp = flask.make_response(text, code)\n    resp.headers[\"Content-type\"] = \"text/plain\"\n    return resp\n\n\ndef _handle_local_file_request(request: LocalFileRequest) -> Response:\n    directory = request.root\n    path = request.path\n    try:\n        isdir = os.path.isdir(os.path.join(directory, path))\n    except ValueError:\n        return _text_response(\n            HTTPStatus.BAD_REQUEST, f\"Path for '{directory} - {path}' is too long!\"\n        )\n\n    directory = os.path.realpath(directory)\n    path = os.path.normpath(path)\n    fullpath = os.path.abspath(os.path.join(directory, path))\n\n    # protect against directory transversal: https://security.openstack.org/guidelines/dg_using-file-paths.html\n    if not fullpath.startswith(directory):\n        return _text_response(\n            HTTPStatus.FORBIDDEN, f\"Path for '{directory} - {path}' is a security leak!\"\n        )\n\n    if isdir:\n        return _text_response(\n            HTTPStatus.FORBIDDEN,\n            f\"Path for '{directory} - {path}' is a directory (not supported)!\",\n        )\n\n    try:\n        mimetype = _mime_for_path(fullpath)\n        if os.path.exists(fullpath):\n            if fullpath.endswith(\".css\"):\n                # caching css files prevents flicker in the webview, but we want\n                # a short cache\n                max_age = 10\n            elif fullpath.endswith(\".js\"):\n                # don't cache js files\n                max_age = 0\n            else:\n                max_age = 60 * 60\n            return flask.send_file(\n                fullpath,\n                mimetype=mimetype,\n                conditional=True,\n                max_age=max_age,\n                download_name=\"foo\",  # type: ignore[call-arg]\n            )\n        else:\n            print(f\"Not found: {path}\")\n            return _text_response(HTTPStatus.NOT_FOUND, f\"Invalid path: {path}\")\n\n    except Exception as error:\n        if dev_mode:\n            print(\n                \"Caught HTTP server exception,\\n%s\"\n                % \"\".join(traceback.format_exception(*sys.exc_info())),\n            )\n\n        # swallow it - user likely surfed away from\n        # review screen before an image had finished\n        # downloading\n        return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(error))\n\n\ndef _builtin_data(path: str) -> bytes:\n    \"\"\"Return data from file in aqt/data folder.\n    Path must use forward slash separators.\"\"\"\n    full_path = aqt_data_path() / \"..\" / path\n    return full_path.read_bytes()\n\n\ndef _handle_builtin_file_request(request: BundledFileRequest) -> Response:\n    path = request.path\n    # do we need to serve the fallback page?\n    immutable = \"immutable\" in path\n    if path.startswith(\"sveltekit/\") and not immutable:\n        path = \"sveltekit/index.html\"\n    mimetype = _mime_for_path(path)\n    data_path = f\"data/web/{path}\"\n    try:\n        data = _builtin_data(data_path)\n        response = Response(data, mimetype=mimetype)\n        if immutable:\n            response.headers[\"Cache-Control\"] = \"max-age=31536000\"\n        return response\n    except FileNotFoundError:\n        if dev_mode:\n            print(f\"404: {data_path}\")\n        resp = _text_response(HTTPStatus.NOT_FOUND, f\"Invalid path: {path}\")\n        # we're including the path verbatim in our response, so we need to either use\n        # plain text, or escape HTML characters to avoid reflecting untrusted input\n        resp.headers[\"Content-type\"] = \"text/plain\"\n        return resp\n    except Exception as error:\n        if dev_mode:\n            print(\n                \"Caught HTTP server exception,\\n%s\"\n                % \"\".join(traceback.format_exception(*sys.exc_info())),\n            )\n\n        # swallow it - user likely surfed away from\n        # review screen before an image had finished\n        # downloading\n        return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(error))\n\n\n@app.route(\"/<path:pathin>\", methods=[\"GET\", \"POST\"])\ndef handle_request(pathin: str) -> Response:\n    host = request.headers.get(\"Host\", \"\").lower()\n    allowed_prefixes = (\"127.0.0.1:\", \"localhost:\", \"[::1]:\")\n    if not any(host.startswith(prefix) for prefix in allowed_prefixes):\n        # while we only bind to localhost, this request may have come from a local browser\n        # via a DNS rebinding attack; deny it unless we're doing non-local testing\n        if os.environ.get(\"ANKI_API_HOST\") != \"0.0.0.0\":\n            print(\"deny non-local host\", host)\n            abort(403)\n\n    req = _extract_request(pathin)\n    logger.debug(\"%s /%s\", flask.request.method, pathin)\n\n    if isinstance(req, NotFound):\n        print(req.message)\n        return _text_response(HTTPStatus.NOT_FOUND, f\"Invalid path: {pathin}\")\n    elif callable(req):\n        return _handle_dynamic_request(req)\n    elif isinstance(req, BundledFileRequest):\n        return _handle_builtin_file_request(req)\n    elif isinstance(req, LocalFileRequest):\n        return _handle_local_file_request(req)\n    else:\n        return _text_response(HTTPStatus.FORBIDDEN, f\"unexpected request: {pathin}\")\n\n\ndef is_sveltekit_page(path: str) -> bool:\n    page_name = path.split(\"/\")[0]\n    return page_name in [\n        \"graphs\",\n        \"congrats\",\n        \"card-info\",\n        \"change-notetype\",\n        \"deck-options\",\n        \"import-anki-package\",\n        \"import-csv\",\n        \"import-page\",\n        \"image-occlusion\",\n    ]\n\n\ndef _extract_internal_request(\n    path: str,\n) -> BundledFileRequest | DynamicRequest | NotFound | None:\n    \"Catch /_anki references and rewrite them to web export folder.\"\n    if is_sveltekit_page(path):\n        path = f\"_anki/sveltekit/_app/{path}\"\n    if path.startswith(\"_app/\"):\n        path = path.replace(\"_app\", \"_anki/sveltekit/_app\")\n\n    prefix = \"_anki/\"\n    if not path.startswith(prefix):\n        return None\n\n    dirname = os.path.dirname(path)\n    filename = os.path.basename(path)\n    additional_prefix = None\n\n    if dirname == \"_anki\":\n        if flask.request.method == \"POST\":\n            return _extract_collection_post_request(filename)\n        elif get_handler := _extract_dynamic_get_request(filename):\n            return get_handler\n\n        # remap legacy top-level references\n        base, ext = os.path.splitext(filename)\n        if ext == \".css\":\n            additional_prefix = \"css/\"\n        elif ext == \".js\":\n            if base in (\"jquery-ui\", \"jquery\", \"plot\"):\n                additional_prefix = \"js/vendor/\"\n            else:\n                additional_prefix = \"js/\"\n    # handle requests for vendored libraries\n    elif dirname == \"_anki/js/vendor\":\n        base, ext = os.path.splitext(filename)\n\n        if base == \"jquery\":\n            base = \"jquery.min\"\n            additional_prefix = \"js/vendor/\"\n\n        elif base == \"jquery-ui\":\n            base = \"jquery-ui.min\"\n            additional_prefix = \"js/vendor/\"\n\n    if additional_prefix:\n        oldpath = path\n        path = f\"{prefix}{additional_prefix}{base}{ext}\"\n        print(f\"legacy {oldpath} remapped to {path}\")\n\n    return BundledFileRequest(path=path[len(prefix) :])\n\n\ndef _extract_addon_request(path: str) -> LocalFileRequest | NotFound | None:\n    \"Catch /_addons references and rewrite them to addons folder.\"\n    prefix = \"_addons/\"\n    if not path.startswith(prefix):\n        return None\n\n    addon_path = path[len(prefix) :]\n\n    try:\n        manager = aqt.mw.addonManager\n    except AttributeError as error:\n        if dev_mode:\n            print(f\"_redirectWebExports: {error}\")\n        return None\n\n    try:\n        addon, sub_path = addon_path.split(\"/\", 1)\n    except ValueError:\n        return None\n    if not addon:\n        return None\n\n    pattern = manager.getWebExports(addon)\n    if not pattern:\n        return None\n\n    if re.fullmatch(pattern, sub_path):\n        return LocalFileRequest(root=manager.addonsFolder(), path=addon_path)\n\n    return NotFound(message=f\"couldn't locate item in add-on folder {path}\")\n\n\ndef _extract_request(\n    path: str,\n) -> LocalFileRequest | BundledFileRequest | DynamicRequest | NotFound:\n    if internal := _extract_internal_request(path):\n        return internal\n    elif addon := _extract_addon_request(path):\n        return addon\n\n    if not aqt.mw.col:\n        return NotFound(message=f\"collection not open, ignore request for {path}\")\n\n    path = hooks.media_file_filter(path)\n    return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)\n\n\ndef congrats_info() -> bytes:\n    if not aqt.mw.col.sched._is_finished():\n        aqt.mw.taskman.run_on_main(lambda: aqt.mw.moveToState(\"overview\"))\n    return raw_backend_request(\"congrats_info\")()\n\n\ndef get_deck_configs_for_update() -> bytes:\n    return aqt.mw.col._backend.get_deck_configs_for_update_raw(request.data)\n\n\ndef update_deck_configs() -> bytes:\n    # the regular change tracking machinery expects to be started on the main\n    # thread and uses a callback on success, so we need to run this op on\n    # main, and return immediately from the web request\n\n    input = UpdateDeckConfigs()\n    input.ParseFromString(request.data)\n\n    def on_progress(progress: Progress, update: ProgressUpdate) -> None:\n        if progress.HasField(\"compute_memory\"):\n            val = progress.compute_memory\n            update.max = val.total_cards\n            update.value = val.current_cards\n            update.label = val.label\n        elif progress.HasField(\"compute_params\"):\n            val2 = progress.compute_params\n            # prevent an indeterminate progress bar from appearing at the start of each preset\n            update.max = max(val2.total, 1)\n            update.value = val2.current\n            pct = str(int(val2.current / val2.total * 100) if val2.total > 0 else 0)\n            label = tr.deck_config_optimizing_preset(\n                current_count=val2.current_preset, total_count=val2.total_presets\n            )\n            if val2.reviews:\n                reviews = tr.deck_config_percent_of_reviews(\n                    pct=pct, reviews=val2.reviews\n                )\n            else:\n                reviews = tr.qt_misc_processing()\n\n            update.label = label + \"\\n\" + reviews\n        else:\n            return\n        if update.user_wants_abort:\n            update.abort = True\n\n    def on_success(changes: OpChanges) -> None:\n        if isinstance(window := aqt.mw.app.activeModalWidget(), DeckOptionsDialog):\n            window.reject()\n\n    def handle_on_main() -> None:\n        update_deck_configs_op(parent=aqt.mw, input=input).success(\n            on_success\n        ).with_backend_progress(on_progress).run_in_background()\n\n    aqt.mw.taskman.run_on_main(handle_on_main)\n    return b\"\"\n\n\ndef get_scheduling_states_with_context() -> bytes:\n    return SchedulingStatesWithContext(\n        states=aqt.mw.reviewer.get_scheduling_states(),\n        context=aqt.mw.reviewer.get_scheduling_context(),\n    ).SerializeToString()\n\n\ndef set_scheduling_states() -> bytes:\n    states = SetSchedulingStatesRequest()\n    states.ParseFromString(request.data)\n    aqt.mw.reviewer.set_scheduling_states(states)\n    return b\"\"\n\n\ndef import_done() -> bytes:\n    def update_window_modality() -> None:\n        if window := aqt.mw.app.activeModalWidget():\n            from aqt.import_export.import_dialog import ImportDialog\n\n            if isinstance(window, ImportDialog):\n                window.hide()\n                window.setWindowModality(Qt.WindowModality.NonModal)\n                window.show()\n\n    aqt.mw.taskman.run_on_main(update_window_modality)\n    return b\"\"\n\n\ndef import_request(endpoint: str) -> bytes:\n    output = raw_backend_request(endpoint)()\n    response = OpChangesOnly()\n    response.ParseFromString(output)\n\n    def handle_on_main() -> None:\n        window = aqt.mw.app.activeModalWidget()\n        on_op_finished(aqt.mw, response, window)\n\n    aqt.mw.taskman.run_on_main(handle_on_main)\n\n    return output\n\n\ndef import_csv() -> bytes:\n    return import_request(\"import_csv\")\n\n\ndef import_anki_package() -> bytes:\n    return import_request(\"import_anki_package\")\n\n\ndef import_json_file() -> bytes:\n    return import_request(\"import_json_file\")\n\n\ndef import_json_string() -> bytes:\n    return import_request(\"import_json_string\")\n\n\ndef search_in_browser() -> bytes:\n    node = SearchNode()\n    node.ParseFromString(request.data)\n\n    def handle_on_main() -> None:\n        aqt.dialogs.open(\"Browser\", aqt.mw, search=(node,))\n\n    aqt.mw.taskman.run_on_main(handle_on_main)\n\n    return b\"\"\n\n\ndef change_notetype() -> bytes:\n    data = request.data\n\n    def handle_on_main() -> None:\n        window = aqt.mw.app.activeModalWidget()\n        if isinstance(window, ChangeNotetypeDialog):\n            window.save(data)\n\n    aqt.mw.taskman.run_on_main(handle_on_main)\n    return b\"\"\n\n\ndef deck_options_require_close() -> bytes:\n    def handle_on_main() -> None:\n        window = aqt.mw.app.activeModalWidget()\n        if isinstance(window, DeckOptionsDialog):\n            window.require_close()\n\n    # on certain linux systems, askUser's QMessageBox.question unsets the active window\n    # so we wait for the next event loop before querying the next current active window\n    aqt.mw.taskman.run_on_main(lambda: QTimer.singleShot(0, handle_on_main))\n    return b\"\"\n\n\ndef deck_options_ready() -> bytes:\n    def handle_on_main() -> None:\n        window = aqt.mw.app.activeModalWidget()\n        if isinstance(window, DeckOptionsDialog):\n            window.set_ready()\n\n    aqt.mw.taskman.run_on_main(handle_on_main)\n    return b\"\"\n\n\ndef save_custom_colours() -> bytes:\n    colors = [\n        QColorDialog.customColor(i).name(QColor.NameFormat.HexRgb)\n        for i in range(QColorDialog.customCount())\n    ]\n    aqt.mw.col.set_config(\"customColorPickerPalette\", colors)\n    return b\"\"\n\n\npost_handler_list = [\n    congrats_info,\n    get_deck_configs_for_update,\n    update_deck_configs,\n    get_scheduling_states_with_context,\n    set_scheduling_states,\n    change_notetype,\n    import_done,\n    import_csv,\n    import_anki_package,\n    import_json_file,\n    import_json_string,\n    search_in_browser,\n    deck_options_require_close,\n    deck_options_ready,\n    save_custom_colours,\n]\n\n\nexposed_backend_list = [\n    # CollectionService\n    \"latest_progress\",\n    \"get_custom_colours\",\n    # DeckService\n    \"get_deck_names\",\n    # I18nService\n    \"i18n_resources\",\n    # ImportExportService\n    \"get_csv_metadata\",\n    \"get_import_anki_package_presets\",\n    # NotesService\n    \"get_field_names\",\n    \"get_note\",\n    # NotetypesService\n    \"get_notetype_names\",\n    \"get_change_notetype_info\",\n    # StatsService\n    \"card_stats\",\n    \"get_review_logs\",\n    \"graphs\",\n    \"get_graph_preferences\",\n    \"set_graph_preferences\",\n    # TagsService\n    \"complete_tag\",\n    # ImageOcclusionService\n    \"get_image_for_occlusion\",\n    \"add_image_occlusion_note\",\n    \"get_image_occlusion_note\",\n    \"update_image_occlusion_note\",\n    \"get_image_occlusion_fields\",\n    # SchedulerService\n    \"compute_fsrs_params\",\n    \"compute_optimal_retention\",\n    \"set_wants_abort\",\n    \"evaluate_params_legacy\",\n    \"get_optimal_retention_parameters\",\n    \"simulate_fsrs_review\",\n    \"simulate_fsrs_workload\",\n    # DeckConfigService\n    \"get_ignored_before_count\",\n    \"get_retention_workload\",\n]\n\n\ndef raw_backend_request(endpoint: str) -> Callable[[], bytes]:\n    # check for key at startup\n    from anki._backend import RustBackend\n\n    assert hasattr(RustBackend, f\"{endpoint}_raw\")\n\n    return lambda: getattr(aqt.mw.col._backend, f\"{endpoint}_raw\")(request.data)\n\n\n# all methods in here require a collection\npost_handlers = {\n    stringcase.camelcase(handler.__name__): handler for handler in post_handler_list\n} | {\n    stringcase.camelcase(handler): raw_backend_request(handler)\n    for handler in exposed_backend_list\n}\n\n\ndef _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:\n    if not aqt.mw.col:\n        return NotFound(message=f\"collection not open, ignore request for {path}\")\n    if handler := post_handlers.get(path):\n        # convert bytes/None into response\n        def wrapped() -> Response:\n            try:\n                if data := handler():\n                    response = flask.make_response(data)\n                    response.headers[\"Content-Type\"] = \"application/binary\"\n                else:\n                    response = _text_response(HTTPStatus.NO_CONTENT, \"\")\n            except Exception as exc:\n                print(traceback.format_exc())\n                response = _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))\n            return response\n\n        return wrapped\n    else:\n        return NotFound(message=f\"{path} not found\")\n\n\ndef _check_dynamic_request_permissions():\n    if request.method == \"GET\":\n        return\n\n    def warn() -> None:\n        show_warning(\n            \"Unexpected API access. Please report this message on the Anki forums.\"\n        )\n\n    # check content type header to ensure this isn't an opaque request from another origin\n    if request.headers[\"Content-type\"] != \"application/binary\":\n        aqt.mw.taskman.run_on_main(warn)\n        abort(403)\n\n    # does page have access to entire API?\n    if _have_api_access():\n        return\n\n    # whitelisted API endpoints for reviewer/previewer\n    if request.path in (\n        \"/_anki/getSchedulingStatesWithContext\",\n        \"/_anki/setSchedulingStates\",\n        \"/_anki/i18nResources\",\n        \"/_anki/congratsInfo\",\n    ):\n        pass\n    else:\n        # other legacy pages may contain third-party JS, so we do not\n        # allow them to access our API\n        aqt.mw.taskman.run_on_main(warn)\n        abort(403)\n\n\ndef _handle_dynamic_request(req: DynamicRequest) -> Response:\n    _check_dynamic_request_permissions()\n    try:\n        return req()\n    except Exception as e:\n        return _text_response(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))\n\n\ndef legacy_page_data() -> Response:\n    id = int(request.args[\"id\"])\n    page = aqt.mw.mediaServer.get_page(id)\n    if page:\n        response = Response(page.html, mimetype=\"text/html\")\n        # Prevent JS in field content from being executed in the editor, as it would\n        # have access to our internal API, and is a security risk.\n        if page.context == PageContext.EDITOR:\n            port = aqt.mw.mediaServer.getPort()\n            csp_paths = (\n                f\"http://127.0.0.1:{port}/_anki/\",\n                f\"http://127.0.0.1:{port}/_addons/\",\n            )\n            response.headers[\"Content-Security-Policy\"] = (\n                f\"script-src {' '.join(csp_paths)}\"\n            )\n        return response\n    else:\n        return _text_response(HTTPStatus.NOT_FOUND, \"page not found\")\n\n\n_APIKEY = secrets.token_urlsafe(32)\n\n\ndef _have_api_access() -> bool:\n    return (\n        request.headers.get(\"Authorization\") == f\"Bearer {_APIKEY}\"\n        or os.environ.get(\"ANKI_API_HOST\") == \"0.0.0.0\"\n    )\n\n\n# this currently only handles a single method; in the future, idempotent\n# requests like i18nResources should probably be moved here\ndef _extract_dynamic_get_request(path: str) -> DynamicRequest | None:\n    if path == \"legacyPageData\":\n        return legacy_page_data\n    else:\n        return None\n"
  },
  {
    "path": "qt/aqt/mediasync.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom datetime import datetime\nfrom typing import Any\n\nimport aqt\nimport aqt.forms\nimport aqt.main\nfrom anki.collection import Collection\nfrom anki.errors import Interrupted\nfrom anki.utils import int_time\nfrom aqt import gui_hooks\nfrom aqt.operations import QueryOp\nfrom aqt.qt import QDialog, QDialogButtonBox, QPushButton, Qt, QTimer, qconnect\nfrom aqt.utils import disable_help_button, show_info, tr\n\n\nclass MediaSyncer:\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        self.mw = mw\n        self._syncing: bool = False\n        self.last_progress = \"\"\n        self._last_progress_at = 0\n        gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)\n\n    def start(self, is_periodic_sync: bool = False) -> None:\n        \"Start media syncing in the background, if it's not already running.\"\n        if not self.mw.pm.media_syncing_enabled() or not (\n            auth := self.mw.pm.sync_auth()\n        ):\n            return\n\n        def run(col: Collection) -> None:\n            col.sync_media(auth)\n\n        # this will exit after the thread is spawned, but may block if there's an existing\n        # backend lock\n        QueryOp(parent=aqt.mw, op=run, success=lambda _: 1).failure(\n            lambda e: self._handle_sync_error(e, is_periodic_sync)\n        ).run_in_background()\n\n        self.start_monitoring(is_periodic_sync)\n\n    def start_monitoring(self, is_periodic_sync: bool = False) -> None:\n        if self._syncing:\n            return\n        self._syncing = True\n        gui_hooks.media_sync_did_start_or_stop(True)\n        self._update_progress(tr.sync_media_starting())\n\n        def monitor() -> None:\n            while True:\n                resp = self.mw.col.media_sync_status()\n                if not resp.active:\n                    return\n                if p := resp.progress:\n                    self._update_progress(f\"{p.added}, {p.removed}, {p.checked}\")\n\n                time.sleep(0.25)\n\n        self.mw.taskman.run_in_background(\n            monitor,\n            lambda fut: self._on_finished(fut, is_periodic_sync),\n            uses_collection=False,\n        )\n\n    def _update_progress(self, progress: str) -> None:\n        self.last_progress = progress\n        self.mw.taskman.run_on_main(lambda: gui_hooks.media_sync_did_progress(progress))\n\n    def _on_finished(self, future: Future, is_periodic_sync: bool = False) -> None:\n        self._syncing = False\n        self._last_progress_at = int_time()\n        gui_hooks.media_sync_did_start_or_stop(False)\n\n        exc = future.exception()\n        if exc is not None:\n            self._handle_sync_error(exc, is_periodic_sync)\n        else:\n            self._update_progress(tr.sync_media_complete())\n\n    def _handle_sync_error(\n        self, exc: BaseException, is_periodic_sync: bool = False\n    ) -> None:\n        if isinstance(exc, Interrupted):\n            self._update_progress(tr.sync_media_aborted())\n        elif is_periodic_sync:\n            print(str(exc))\n        else:\n            self._update_progress(tr.sync_media_failed())\n            show_info(str(exc), modality=Qt.WindowModality.NonModal)\n\n    def abort(self) -> None:\n        if not self.is_syncing():\n            return\n        self.mw.col.set_wants_abort()\n        self.mw.col.abort_media_sync()\n        self._update_progress(tr.sync_media_aborting())\n\n    def is_syncing(self) -> bool:\n        return self._syncing\n\n    def _on_start_stop(self, running: bool) -> None:\n        self.mw.toolbar.set_sync_active(running)\n\n    def show_sync_log(self) -> None:\n        aqt.dialogs.open(\"sync_log\", self.mw, self)\n\n    def show_diag_until_finished(self, on_finished: Callable[[], None]) -> None:\n        # nothing to do if not syncing\n        if not self.is_syncing():\n            return on_finished()\n\n        diag: MediaSyncDialog = aqt.dialogs.open(\"sync_log\", self.mw, self, True)\n        diag.show()\n\n        timer: QTimer\n\n        def check_finished() -> None:\n            if not self.is_syncing():\n                timer.deleteLater()\n                on_finished()\n\n        timer = self.mw.progress.timer(150, check_finished, True, False, parent=self.mw)\n\n    def seconds_since_last_sync(self) -> int:\n        if self.is_syncing():\n            return 0\n\n        return int_time() - self._last_progress_at\n\n\nclass MediaSyncDialog(QDialog):\n    silentlyClose = True\n\n    def __init__(\n        self, mw: aqt.main.AnkiQt, syncer: MediaSyncer, close_when_done: bool = False\n    ) -> None:\n        super().__init__(mw)\n        self.mw = mw\n        self._syncer = syncer\n        self._close_when_done = close_when_done\n        self.form = aqt.forms.synclog.Ui_Dialog()\n        self.form.setupUi(self)\n        self.setWindowTitle(tr.sync_media_log_title())\n        disable_help_button(self)\n        self.abort_button = QPushButton(tr.sync_abort_button())\n        qconnect(self.abort_button.clicked, self._on_abort)\n        self.abort_button.setAutoDefault(False)\n        self.form.buttonBox.addButton(\n            self.abort_button, QDialogButtonBox.ButtonRole.ActionRole\n        )\n        self.abort_button.setHidden(not self._syncer.is_syncing())\n\n        gui_hooks.media_sync_did_progress.append(self._on_log_entry)\n        gui_hooks.media_sync_did_start_or_stop.append(self._on_start_stop)\n\n        self._on_log_entry(syncer.last_progress)\n        self.show()\n\n    def reject(self) -> None:\n        if self._close_when_done and self._syncer.is_syncing():\n            # closing while syncing on close starts an abort\n            self._on_abort()\n            return\n\n        aqt.dialogs.markClosed(\"sync_log\")\n        QDialog.reject(self)\n\n    def reopen(\n        self, mw: aqt.AnkiQt, syncer: Any, close_when_done: bool = False\n    ) -> None:\n        self._close_when_done = close_when_done\n        self.show()\n\n    def _on_abort(self, *_args: Any) -> None:\n        self._syncer.abort()\n        self.abort_button.setHidden(True)\n\n    def _on_log_entry(self, entry: str) -> None:\n        dt = datetime.fromtimestamp(int_time())\n        time = dt.strftime(\"%H:%M:%S\")\n        text = f\"{time}: {entry}\"\n        self.form.log_label.setText(text)\n        if not self._syncer.is_syncing():\n            self.abort_button.setHidden(True)\n\n    def _on_start_stop(self, running: bool) -> None:\n        if not running and self._close_when_done:\n            aqt.dialogs.markClosed(\"sync_log\")\n            self._close_when_done = False\n            self.close()\n"
  },
  {
    "path": "qt/aqt/modelchooser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import HelpPage, shortcut, tr\n\n\nclass ModelChooser(QHBoxLayout):\n    \"New code should prefer NotetypeChooser.\"\n\n    def __init__(\n        self,\n        mw: AnkiQt,\n        widget: QWidget,\n        label: bool = True,\n        on_activated: Callable[[], None] | None = None,\n    ) -> None:\n        \"\"\"If provided, on_activated() will be called when the button is clicked,\n        and the caller can call .onModelChange() to pull up the dialog when they\n        are ready.\"\"\"\n        QHBoxLayout.__init__(self)\n        self._widget = widget  # type: ignore\n        self.mw = mw\n        self.deck = mw.col\n        self.label = label\n        if on_activated:\n            self.on_activated = on_activated\n        else:\n            self.on_activated = self.onModelChange\n        self.setContentsMargins(0, 0, 0, 0)\n        self.setSpacing(8)\n        self.setupModels()\n        gui_hooks.state_did_reset.append(self.onReset)\n        self._widget.setLayout(self)\n\n    def setupModels(self) -> None:\n        if self.label:\n            self.modelLabel = QLabel(tr.notetypes_type())\n            self.addWidget(self.modelLabel)\n        # models box\n        self.models = QPushButton()\n        self.models.setToolTip(shortcut(tr.qt_misc_change_note_type_ctrlandn()))\n        QShortcut(QKeySequence(\"Ctrl+N\"), self._widget, activated=self.on_activated)  # type: ignore\n        self.models.setAutoDefault(False)\n        self.addWidget(self.models)\n        qconnect(self.models.clicked, self.onModelChange)\n        # layout\n        sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0))\n        self.models.setSizePolicy(sizePolicy)\n        self.updateModels()\n\n    def cleanup(self) -> None:\n        gui_hooks.state_did_reset.remove(self.onReset)\n\n    def onReset(self) -> None:\n        self.updateModels()\n\n    def show(self) -> None:\n        self._widget.show()  # type: ignore\n\n    def hide(self) -> None:\n        self._widget.hide()  # type: ignore\n\n    def onEdit(self) -> None:\n        import aqt.models\n\n        aqt.models.Models(self.mw, self._widget)\n\n    def onModelChange(self) -> None:\n        from aqt.studydeck import StudyDeck\n\n        current = self.deck.models.current()[\"name\"]\n        # edit button\n        edit = QPushButton(tr.qt_misc_manage(), clicked=self.onEdit)  # type: ignore\n\n        def nameFunc() -> list[str]:\n            return [nt.name for nt in self.deck.models.all_names_and_ids()]\n\n        def callback(ret: StudyDeck) -> None:\n            if not ret.name:\n                return\n            m = self.deck.models.by_name(ret.name)\n            assert m is not None\n            self.deck.conf[\"curModel\"] = m[\"id\"]\n            cdeck = self.deck.decks.current()\n            cdeck[\"mid\"] = m[\"id\"]\n            self.deck.decks.save(cdeck)\n            gui_hooks.current_note_type_did_change(current)\n            self.mw.reset()\n\n        StudyDeck(\n            self.mw,\n            names=nameFunc,\n            accept=tr.actions_choose(),\n            title=tr.qt_misc_choose_note_type(),\n            help=HelpPage.NOTE_TYPE,\n            current=current,\n            parent=self._widget,\n            buttons=[edit],\n            cancel=True,\n            geomKey=\"selectModel\",\n            callback=callback,\n        )\n\n    def updateModels(self) -> None:\n        self.models.setText(self.deck.models.current()[\"name\"].replace(\"&\", \"&&\"))\n"
  },
  {
    "path": "qt/aqt/models.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Sequence\nfrom concurrent.futures import Future\nfrom operator import itemgetter\n\nimport aqt.clayout\nfrom anki import stdmodels\nfrom anki.collection import Collection, OpChangesWithId\nfrom anki.lang import without_unicode_isolation\nfrom anki.models import NotetypeDict, NotetypeId, NotetypeNameIdUseCount\nfrom anki.notes import Note\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.operations import QueryOp\nfrom aqt.operations.notetype import (\n    add_notetype_legacy,\n    remove_notetype,\n    update_notetype_legacy,\n)\nfrom aqt.qt import *\nfrom aqt.schema_change_tracker import ChangeTracker\nfrom aqt.utils import (\n    HelpPage,\n    askUser,\n    disable_help_button,\n    getText,\n    maybeHideClose,\n    openHelp,\n    restoreGeom,\n    saveGeom,\n    showInfo,\n    tr,\n)\n\n\nclass Models(QDialog):\n    def __init__(\n        self,\n        mw: AnkiQt,\n        parent: QWidget | None = None,\n        fromMain: bool = False,\n        selected_notetype_id: NotetypeId | None = None,\n    ):\n        self.mw = mw\n        parent = parent or mw\n        self.fromMain = fromMain\n        self.selected_notetype_id = selected_notetype_id\n        QDialog.__init__(self, parent or mw)\n        self.col = mw.col.weakref()\n        assert self.col\n        self.mm = self.col.models\n        self.form = aqt.forms.models.Ui_Dialog()\n        self.form.setupUi(self)\n        qconnect(\n            self.form.buttonBox.helpRequested,\n            lambda: openHelp(HelpPage.ADDING_A_NOTE_TYPE),\n        )\n        self.models: Sequence[NotetypeNameIdUseCount] = []\n        self.setupModels()\n\n        self.setWindowFlags(\n            self.windowFlags()\n            | Qt.WindowType.WindowMaximizeButtonHint\n            | Qt.WindowType.WindowMinimizeButtonHint\n        )\n        restoreGeom(self, \"models\")\n\n        self.show()\n\n    # Models\n    ##########################################################################\n\n    def maybe_select_provided_notetype(\n        self, selected_notetype_id: NotetypeId | None = None, row: int = 0\n    ) -> None:\n        \"\"\"Select the provided notetype ID, if any.\n        Otherwise the one at `self.selected_notetype_id`,\n        otherwise the `row`-th element.\"\"\"\n        selected_notetype_id = selected_notetype_id or self.selected_notetype_id\n        if not selected_notetype_id:\n            self.form.modelsList.setCurrentRow(row)\n            return\n        for i, m in enumerate(self.models):\n            if m.id == selected_notetype_id:\n                self.form.modelsList.setCurrentRow(i)\n                break\n\n    def setupModels(self) -> None:\n        self.model = None\n        f = self.form\n        box = f.buttonBox\n\n        default_buttons = [\n            (tr.actions_add(), self.onAdd),\n            (tr.actions_rename(), self.onRename),\n            (tr.actions_delete(), self.onDelete),\n        ]\n\n        if self.fromMain:\n            default_buttons.extend(\n                [\n                    (tr.notetypes_fields(), self.onFields),\n                    (tr.notetypes_cards(), self.onCards),\n                ]\n            )\n\n        default_buttons.append((tr.notetypes_options(), self.onAdvanced))\n\n        for label, func in gui_hooks.models_did_init_buttons(default_buttons, self):\n            button = box.addButton(label, QDialogButtonBox.ButtonRole.ActionRole)\n            qconnect(button.clicked, func)\n\n        qconnect(f.modelsList.itemDoubleClicked, self.onRename)\n\n        def on_done(fut: Future) -> None:\n            self.updateModelsList(fut.result())\n            self.maybe_select_provided_notetype()\n\n        self.mw.taskman.with_progress(self.col.models.all_use_counts, on_done, self)\n        maybeHideClose(box)\n\n    def refresh_list(self, selected_notetype_id: NotetypeId | None = None) -> None:\n        QueryOp(\n            parent=self,\n            op=lambda col: col.models.all_use_counts(),\n            success=lambda notetypes: self.updateModelsList(\n                notetypes, selected_notetype_id\n            ),\n        ).run_in_background()\n\n    def onRename(self) -> None:\n        nt = self.current_notetype()\n        text, ok = getText(tr.actions_new_name(), default=nt[\"name\"])\n        if ok and text.strip():\n            selected_notetype_id = nt[\"id\"]\n            nt[\"name\"] = text\n\n            update_notetype_legacy(parent=self, notetype=nt).success(\n                lambda _: self.refresh_list(selected_notetype_id)\n            ).run_in_background()\n\n    def updateModelsList(\n        self,\n        notetypes: Sequence[NotetypeNameIdUseCount],\n        selected_notetype_id: NotetypeId | None = None,\n    ) -> None:\n        row = self.form.modelsList.currentRow()\n        if row == -1:\n            row = 0\n        self.form.modelsList.clear()\n\n        self.models = notetypes\n        for m in self.models:\n            mUse = tr.browsing_note_count(count=m.use_count)\n            item = QListWidgetItem(f\"{m.name} [{mUse}]\")\n            self.form.modelsList.addItem(item)\n        self.maybe_select_provided_notetype(selected_notetype_id, row)\n\n    def current_notetype(self) -> NotetypeDict:\n        row = self.form.modelsList.currentRow()\n        return self.mm.get(NotetypeId(self.models[row].id))\n\n    def onAdd(self) -> None:\n        def on_success(notetype: NotetypeDict) -> None:\n            # if legacy add-ons already added the notetype, skip adding\n            nid = notetype[\"id\"]\n            if nid:\n                self.refresh_list(nid)\n                return\n\n            # prompt for name\n            text, ok = getText(tr.actions_name(), default=notetype[\"name\"], parent=self)\n            if not ok or not text.strip():\n                return\n            notetype[\"name\"] = text\n\n            def refresh_list(op: OpChangesWithId) -> None:\n                self.refresh_list(NotetypeId(op.id))\n\n            add_notetype_legacy(parent=self, notetype=notetype).success(\n                refresh_list\n            ).run_in_background()\n\n        AddModel(self.mw, on_success, self)\n\n    def onDelete(self) -> None:\n        if len(self.models) < 2:\n            showInfo(tr.notetypes_please_add_another_note_type_first(), parent=self)\n            return\n        idx = self.form.modelsList.currentRow()\n        if self.models[idx].use_count:\n            msg = tr.notetypes_delete_this_note_type_and_all()\n        else:\n            msg = tr.notetypes_delete_this_unused_note_type()\n        if not askUser(msg, parent=self):\n            return\n\n        tracker = ChangeTracker(self.mw)\n        if not tracker.mark_schema():\n            return\n\n        nt = self.current_notetype()\n        remove_notetype(parent=self, notetype_id=nt[\"id\"]).success(\n            lambda _: self.refresh_list(None)\n        ).run_in_background()\n\n    def onAdvanced(self) -> None:\n        nt = self.current_notetype()\n        d = QDialog(self)\n        disable_help_button(d)\n        frm = aqt.forms.modelopts.Ui_Dialog()\n        frm.setupUi(d)\n        frm.latexsvg.setChecked(nt.get(\"latexsvg\", False))\n        frm.latexHeader.setText(nt[\"latexPre\"])\n        frm.latexFooter.setText(nt[\"latexPost\"])\n        d.setWindowTitle(\n            without_unicode_isolation(tr.actions_options_for(val=nt[\"name\"]))\n        )\n        qconnect(frm.buttonBox.helpRequested, lambda: openHelp(HelpPage.LATEX))\n        restoreGeom(d, \"modelopts\")\n        gui_hooks.models_advanced_will_show(d)\n        d.exec()\n        saveGeom(d, \"modelopts\")\n        nt[\"latexsvg\"] = frm.latexsvg.isChecked()\n        nt[\"latexPre\"] = str(frm.latexHeader.toPlainText())\n        nt[\"latexPost\"] = str(frm.latexFooter.toPlainText())\n        update_notetype_legacy(parent=self, notetype=nt).success(\n            lambda _: self.refresh_list(nt[\"id\"])\n        ).run_in_background()\n\n    def _tmpNote(self) -> Note:\n        nt = self.current_notetype()\n        return Note(self.col, nt)\n\n    def onFields(self) -> None:\n        from aqt.fields import FieldDialog\n\n        FieldDialog(self.mw, self.current_notetype(), parent=self)\n\n    def onCards(self) -> None:\n        from aqt.clayout import CardLayout\n\n        n = self._tmpNote()\n        CardLayout(self.mw, n, ord=0, parent=self, fill_empty=True)\n\n    # Cleanup\n    ##########################################################################\n\n    def reject(self) -> None:\n        saveGeom(self, \"models\")\n        QDialog.reject(self)\n\n\nclass AddModel(QDialog):\n    model: NotetypeDict | None\n\n    def __init__(\n        self,\n        mw: AnkiQt,\n        on_success: Callable[[NotetypeDict], None],\n        parent: QWidget | None = None,\n    ) -> None:\n        self.parent_ = parent or mw\n        self.mw = mw\n        self.col = mw.col\n        QDialog.__init__(self, self.parent_, Qt.WindowType.Window)\n        self.model = None\n        self.dialog = aqt.forms.addmodel.Ui_Dialog()\n        self.dialog.setupUi(self)\n        self.setWindowModality(Qt.WindowModality.ApplicationModal)\n        disable_help_button(self)\n        # standard models\n        self.notetypes: list[NotetypeDict | Callable[[Collection], NotetypeDict]] = []\n        for name, func in stdmodels.get_stock_notetypes(self.col):\n            item = QListWidgetItem(tr.notetypes_add(val=name))\n            self.dialog.models.addItem(item)\n            self.notetypes.append(func)\n        # add copies\n        for m in sorted(self.col.models.all(), key=itemgetter(\"name\")):\n            item = QListWidgetItem(tr.notetypes_clone(val=m[\"name\"]))\n            self.dialog.models.addItem(item)\n            self.notetypes.append(m)\n        self.dialog.models.setCurrentRow(0)\n        # the list widget will swallow the enter key\n        s = QShortcut(QKeySequence(\"Return\"), self)\n        qconnect(s.activated, self.accept)\n        # help\n        qconnect(self.dialog.buttonBox.helpRequested, self.onHelp)\n        self.on_success = on_success\n        self.show()\n\n    def reject(self) -> None:\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        model = self.notetypes[self.dialog.models.currentRow()]\n        if isinstance(model, dict):\n            # clone existing\n            self.model = self.mw.col.models.copy(model, add=False)\n        else:\n            self.model = model(self.col)\n        QDialog.accept(self)\n        # On mac, we need to allow time for the existing modal to close or\n        # Qt gets confused.\n        self.mw.progress.single_shot(100, lambda: self.on_success(self.model), True)\n\n    def onHelp(self) -> None:\n        openHelp(HelpPage.ADDING_A_NOTE_TYPE)\n"
  },
  {
    "path": "qt/aqt/mpv.py",
    "content": "# ------------------------------------------------------------------------------\n#\n# mpv.py - Control mpv from Python using JSON IPC\n#\n# Copyright (c) 2015 Lars Gustäbel <lars@gustaebel.de>\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy\n# of this software and associated documentation files (the \"Software\"), to deal\n# in the Software without restriction, including without limitation the rights\n# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n# copies of the Software, and to permit persons to whom the Software is\n# furnished to do so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n#\n# ------------------------------------------------------------------------------\n\n\nfrom __future__ import annotations\n\nimport inspect\nimport json\nimport os\nimport platform\nimport select\nimport socket\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nimport time\nfrom queue import Empty, Full, Queue\nfrom shutil import which\n\nimport aqt\nfrom anki.utils import is_mac, is_win\n\n\nclass MPVError(Exception):\n    pass\n\n\nclass MPVProcessError(MPVError):\n    pass\n\n\nclass MPVCommunicationError(MPVError):\n    pass\n\n\nclass MPVCommandError(MPVError):\n    pass\n\n\nclass MPVTimeoutError(MPVError):\n    pass\n\n\nif is_win:\n    import pywintypes\n    import win32file  # pytype: disable=import-error\n    import win32job\n    import win32pipe\n    import winerror\n\n\nclass MPVBase:\n    \"\"\"Base class for communication with the mpv media player via unix socket\n    based JSON IPC.\n    \"\"\"\n\n    executable = which(\"mpv\")\n    popenEnv: dict[str, str] | None = None\n\n    default_argv = [\n        \"--idle\",\n        \"--no-terminal\",\n        \"--force-window=no\",\n        \"--ontop\",\n        \"--audio-display=no\",\n        \"--keep-open=no\",\n        \"--autoload-files=no\",\n        \"--gapless-audio=no\",\n    ]\n\n    if is_win:\n        default_argv += [\"--af-add=lavfi=[apad=pad_dur=0.150]\"]\n    if not is_mac or platform.machine() != \"arm64\":\n        # our arm64 mpv build doesn't support this option (compiled out)\n        default_argv += [\"--no-ytdl\"]\n\n    def __init__(self, window_id=None, debug=False):\n        self.window_id = window_id\n        self.debug = debug\n\n        self._prepare_socket()\n        self._prepare_process()\n        self._start_process()\n        self._start_socket()\n        self._prepare_thread()\n        self._start_thread()\n\n    def __del__(self):\n        self._stop_thread()\n        self._stop_process()\n        self._stop_socket()\n\n    def _thread_id(self):\n        return threading.get_ident()\n\n    #\n    # Process\n    #\n    def _prepare_process(self):\n        \"\"\"Prepare the argument list for the mpv process.\"\"\"\n        self.argv = [self.executable]\n        self.argv += self.default_argv\n        self.argv += [f\"--input-ipc-server={self._sock_filename}\"]\n        if self.window_id is not None:\n            self.argv += [f\"--wid={str(self.window_id)}\"]\n\n    def _start_process(self):\n        \"\"\"Start the mpv process.\"\"\"\n        self._proc = subprocess.Popen(self.argv, env=self.popenEnv)\n        if is_win:\n            # Ensure mpv gets terminated if Anki closes abruptly.\n            self._job = win32job.CreateJobObject(None, f\"AnkiJob_{os.getpid()}\")\n            extended_info = win32job.QueryInformationJobObject(\n                self._job, win32job.JobObjectExtendedLimitInformation\n            )\n            extended_info[\"BasicLimitInformation\"][\"LimitFlags\"] = (\n                win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE\n            )\n            win32job.SetInformationJobObject(\n                self._job,\n                win32job.JobObjectExtendedLimitInformation,\n                extended_info,\n            )\n            handle = self._proc._handle\n            win32job.AssignProcessToJobObject(self._job, handle)\n\n    def _stop_process(self):\n        \"\"\"Stop the mpv process.\"\"\"\n        if hasattr(self, \"_proc\"):\n            try:\n                self._proc.terminate()\n                self._proc.wait()\n            except ProcessLookupError:\n                pass\n\n    #\n    # Socket communication\n    #\n    def _prepare_socket(self):\n        \"\"\"Create a random socket filename which we pass to mpv with the\n        --input-unix-socket option.\n        \"\"\"\n        if is_win:\n            self._sock_filename = \"ankimpv{}\".format(os.getpid())\n            return\n        fd, self._sock_filename = tempfile.mkstemp(prefix=\"mpv.\")\n        os.close(fd)\n        os.remove(self._sock_filename)\n\n    def _start_socket(self):\n        \"\"\"Wait for the mpv process to create the unix socket and finish\n        startup.\n        \"\"\"\n        start = time.time()\n        timeout = 60 if is_mac else 10\n        while self.is_running() and time.time() < start + timeout:\n            time.sleep(0.1)\n            if is_win:\n                # named pipe\n                try:\n                    self._sock = win32file.CreateFile(\n                        r\"\\\\.\\pipe\\{}\".format(self._sock_filename),\n                        win32file.GENERIC_READ | win32file.GENERIC_WRITE,\n                        0,\n                        None,\n                        win32file.OPEN_EXISTING,\n                        0,\n                        None,\n                    )\n                    win32pipe.SetNamedPipeHandleState(\n                        self._sock,\n                        1,\n                        None,\n                        None,  # PIPE_NOWAIT\n                    )\n                except pywintypes.error as err:\n                    if err.args[0] == winerror.ERROR_FILE_NOT_FOUND:\n                        pass\n                    else:\n                        break\n                else:\n                    break\n            else:\n                # unix socket\n                try:\n                    self._sock = socket.socket(socket.AF_UNIX)\n                    self._sock.connect(self._sock_filename)\n                except (FileNotFoundError, ConnectionRefusedError):\n                    self._sock.close()\n                    continue\n                else:\n                    break\n        else:\n            raise MPVProcessError(\"unable to start process\")\n\n    def _stop_socket(self):\n        \"\"\"Clean up the socket.\"\"\"\n        if hasattr(self, \"_sock\"):\n            self._sock.close()\n        if hasattr(self, \"_sock_filename\"):\n            try:\n                os.remove(self._sock_filename)\n            except OSError:\n                pass\n\n    def _prepare_thread(self):\n        \"\"\"Set up the queues for the communication threads.\"\"\"\n        self._request_queue = Queue(1)\n        self._response_queues = {}\n        self._event_queue = Queue()\n        self._stop_event = threading.Event()\n\n    def _start_thread(self):\n        \"\"\"Start up the communication threads.\"\"\"\n        self._thread = threading.Thread(target=self._reader)\n        self._thread.daemon = True\n        self._thread.start()\n\n    def _stop_thread(self):\n        \"\"\"Stop the communication threads.\"\"\"\n        if hasattr(self, \"_stop_event\"):\n            self._stop_event.set()\n        if hasattr(self, \"_thread\"):\n            self._thread.join()\n\n    def _reader(self):\n        \"\"\"Read the incoming json messages from the unix socket that is\n        connected to the mpv process. Pass them on to the message handler.\n        \"\"\"\n        buf = b\"\"\n        while not self._stop_event.is_set():\n            if is_win:\n                try:\n                    (n, b) = win32file.ReadFile(self._sock, 4096)\n                    buf += b\n                except pywintypes.error as err:\n                    if err.args[0] == winerror.ERROR_NO_DATA:\n                        time.sleep(0.1)\n                        continue\n                    elif err.args[0] == winerror.ERROR_BROKEN_PIPE:\n                        return\n                    else:\n                        raise\n            else:\n                r, w, e = select.select([self._sock], [], [], 1)\n                if r:\n                    try:\n                        b = self._sock.recv(1024)\n                        if not b:\n                            break\n                        buf += b\n                    except ConnectionResetError:\n                        return\n\n            newline = buf.find(b\"\\n\")\n            while newline >= 0:\n                data = buf[: newline + 1]\n                buf = buf[newline + 1 :]\n\n                if self.debug:\n                    sys.stdout.write(f\"<<< {data.decode('utf8', 'replace')}\")\n\n                message = self._parse_message(data)\n                self._handle_message(message)\n\n                newline = buf.find(b\"\\n\")\n\n    #\n    # Message handling\n    #\n    def _compose_message(self, message):\n        \"\"\"Return a json representation from a message dictionary.\"\"\"\n        # XXX may be strict is too strict ;-)\n        data = json.dumps(message)\n        return data.encode(\"utf8\", \"strict\") + b\"\\n\"\n\n    def _parse_message(self, data):\n        \"\"\"Return a message dictionary from a json representation.\"\"\"\n        # XXX may be strict is too strict ;-)\n        data = data.decode(\"utf8\", \"strict\")\n        return json.loads(data)\n\n    def _handle_message(self, message):\n        \"\"\"Handle different types of incoming messages, i.e. responses to\n        commands or asynchronous events.\n        \"\"\"\n        if \"error\" in message:\n            # This message is a reply to a request.\n            try:\n                thread_id = self._request_queue.get(timeout=1)\n            except Empty:\n                raise MPVCommunicationError(\"got a response without a pending request\")\n\n            self._response_queues[thread_id].put(message)\n\n        elif \"event\" in message:\n            # This message is an asynchronous event.\n            self._event_queue.put(message)\n\n        else:\n            raise MPVCommunicationError(f\"invalid message {message!r}\")\n\n    def _send_message(self, message, timeout=None):\n        \"\"\"Send a message/command to the mpv process, message must be a\n        dictionary of the form {\"command\": [\"arg1\", \"arg2\", ...]}. Responses\n        from the mpv process must be collected using _get_response().\n        \"\"\"\n        data = self._compose_message(message)\n\n        if self.debug:\n            sys.stdout.write(f\">>> {data.decode('utf8', 'replace')}\")\n\n        # Request/response cycles are coordinated across different threads, so\n        # that they don't get mixed up. This makes it possible to use commands\n        # (e.g. fetch properties) from event callbacks that run in a different\n        # thread context.\n        thread_id = self._thread_id()\n        if thread_id not in self._response_queues:\n            # Prepare a response queue for the thread to wait on.\n            self._response_queues[thread_id] = Queue()\n\n        # Put the id of the current thread on the request queue. This id is\n        # later used to associate responses from the mpv process with this\n        # request.\n        try:\n            self._request_queue.put(thread_id, block=True, timeout=timeout)\n        except Full:\n            raise MPVTimeoutError(\"unable to put request\")\n\n        # Write the message data to the socket.\n        if is_win:\n            win32file.WriteFile(self._sock, data)\n        else:\n            while data:\n                size = self._sock.send(data)\n                if size == 0:\n                    raise MPVCommunicationError(\"broken sender socket\")\n                data = data[size:]\n\n    def _get_response(self, timeout=None):\n        \"\"\"Collect the response message to a previous request. If there was an\n        error a MPVCommandError exception is raised, otherwise the command\n        specific data is returned.\n        \"\"\"\n        try:\n            message = self._response_queues[self._thread_id()].get(\n                block=True, timeout=timeout\n            )\n        except Empty:\n            raise MPVTimeoutError(\"unable to get response\")\n\n        if message[\"error\"] != \"success\":\n            raise MPVCommandError(message[\"error\"])\n        else:\n            return message.get(\"data\")\n\n    def _get_event(self, timeout=None):\n        \"\"\"Collect a single event message that has been received out-of-band\n        from the mpv process. If a timeout is specified and there have not\n        been any events during that period, None is returned.\n        \"\"\"\n        try:\n            return self._event_queue.get(block=timeout is not None, timeout=timeout)\n        except Empty:\n            return None\n\n    def _send_request(self, message, timeout=None, _retry=1):\n        \"\"\"Send a command to the mpv process and collect the result.\"\"\"\n        self.ensure_running()\n        try:\n            self._send_message(message, timeout)\n            return self._get_response(timeout)\n        except MPVCommandError as e:\n            raise MPVCommandError(f\"{message['command']!r}: {e}\")\n        except Exception:\n            if _retry:\n                print(\"mpv timed out, restarting\")\n                self._stop_process()\n                return self._send_request(message, timeout, _retry - 1)\n            else:\n                raise\n\n    def _register_callbacks(self):\n        \"\"\"Will be called after mpv restart to reinitialize callbacks\n        defined in MPV subclass\n        \"\"\"\n\n    #\n    # Public API\n    #\n    def is_running(self):\n        \"\"\"Return True if the mpv process is still active.\"\"\"\n        return self._proc.poll() is None\n\n    def ensure_running(self):\n        if not self.is_running():\n            self._stop_thread()\n            self._stop_process()\n            self._stop_socket()\n            self._prepare_socket()\n            self._prepare_process()\n            self._start_process()\n            self._start_socket()\n            self._prepare_thread()\n            self._start_thread()\n            self._register_callbacks()\n\n    def close(self):\n        \"\"\"Shutdown the mpv process and our communication setup.\"\"\"\n        if self.is_running():\n            self._send_request({\"command\": [\"quit\"]}, timeout=1)\n            self._stop_process()\n        self._stop_thread()\n        self._stop_socket()\n        self._stop_process()\n\n\nclass MPV(MPVBase):\n    \"\"\"Class for communication with the mpv media player via unix socket\n    based JSON IPC. It adds a few usable methods and a callback API.\n\n    To automatically register methods as event callbacks, subclass this\n    class and define specially named methods as follows:\n\n        def on_file_loaded(self):\n            # This is called for every 'file-loaded' event.\n            ...\n\n        def on_property_time_pos(self, position):\n            # This is called whenever the 'time-pos' property is updated.\n            ...\n\n    Please note that callbacks are executed inside a separate thread. The\n    MPV class itself is completely thread-safe. Requests from different\n    threads to the same MPV instance are synchronized.\n    \"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self._callbacks_queue = Queue()\n        self._callbacks_initialized = False\n\n        super().__init__(*args, **kwargs)\n\n        aqt.mw.taskman.run_in_background(self._register_callbacks, None)\n\n    def _register_callbacks(self):\n        self._callbacks = {}\n        self._property_serials = {}\n        self._new_serial = iter(range(sys.maxsize))\n\n        # Enumerate all methods and auto-register callbacks for\n        # events and property-changes.\n        for method_name, method in inspect.getmembers(self):\n            if not inspect.ismethod(method):\n                continue\n\n            # Bypass MPVError: no such event 'init'\n            if method_name == \"on_init\":\n                continue\n\n            if method_name.startswith(\"on_property_\"):\n                name = method_name[12:]\n                name = name.replace(\"_\", \"-\")\n                self.register_property_callback(name, method)\n\n            elif method_name.startswith(\"on_\"):\n                name = method_name[3:]\n                name = name.replace(\"_\", \"-\")\n                self.register_callback(name, method)\n\n        self._callbacks_initialized = True\n        while True:\n            try:\n                message = self._callbacks_queue.get_nowait()\n            except Empty:\n                break\n            self._handle_event(message)\n\n        # Simulate an init event when the process and all callbacks have been\n        # completely set up.\n        if hasattr(self, \"on_init\"):\n            self.on_init()\n\n    #\n    # Socket communication\n    #\n    def _start_thread(self):\n        \"\"\"Start up the communication threads.\"\"\"\n        super()._start_thread()\n        if not hasattr(self, \"_event_thread\"):\n            self._event_thread = threading.Thread(target=self._event_reader)\n            self._event_thread.daemon = True\n            self._event_thread.start()\n\n    #\n    # Event/callback API\n    #\n    def _event_reader(self):\n        \"\"\"Collect incoming event messages and call the event handler.\"\"\"\n        while True:\n            message = self._get_event(timeout=1)\n            if message is None:\n                continue\n\n            self._handle_event(message)\n\n    def _handle_event(self, message):\n        \"\"\"Lookup and call the callbacks for a particular event message.\"\"\"\n        if not self._callbacks_initialized:\n            self._callbacks_queue.put(message)\n            return\n\n        if message[\"event\"] == \"property-change\":\n            name = f\"property-{message['name']}\"\n        else:\n            name = message[\"event\"]\n\n        for callback in self._callbacks.get(name, []):\n            if \"data\" in message:\n                callback(message[\"data\"])\n            else:\n                callback()\n\n    def register_callback(self, name, callback):\n        \"\"\"Register a function `callback` for the event `name`.\"\"\"\n        try:\n            self.command(\"enable_event\", name)\n        except MPVCommandError:\n            raise MPVError(f\"no such event {name!r}\")\n\n        self._callbacks.setdefault(name, []).append(callback)\n\n    def unregister_callback(self, name, callback):\n        \"\"\"Unregister a previously registered function `callback` for the event\n        `name`.\n        \"\"\"\n        try:\n            callbacks = self._callbacks[name]\n        except KeyError:\n            raise MPVError(f\"no callbacks registered for event {name!r}\")\n\n        try:\n            callbacks.remove(callback)\n        except ValueError:\n            raise MPVError(f\"callback {callback!r} not registered for event {name!r}\")\n\n    def register_property_callback(self, name, callback):\n        \"\"\"Register a function `callback` for the property-change event on\n        property `name`.\n        \"\"\"\n        # Property changes are normally not sent over the connection unless they\n        # are requested using the 'observe_property' command.\n\n        # XXX We manually have to check for the existence of the property name.\n        # Apparently observe_property does not check it :-(\n        proplist = self.command(\"get_property\", \"property-list\", timeout=5)\n        if name not in proplist:\n            raise MPVError(f\"no such property {name!r}\")\n\n        self._callbacks.setdefault(f\"property-{name}\", []).append(callback)\n\n        # 'observe_property' expects some kind of id which can be used later\n        # for unregistering with 'unobserve_property'.\n        serial = next(self._new_serial)\n        self.command(\"observe_property\", serial, name)\n        self._property_serials[(name, callback)] = serial\n        return serial\n\n    def unregister_property_callback(self, name, callback):\n        \"\"\"Unregister a previously registered function `callback` for the\n        property-change event on property `name`.\n        \"\"\"\n        try:\n            callbacks = self._callbacks[f\"property-{name}\"]\n        except KeyError:\n            raise MPVError(f\"no callbacks registered for property {name!r}\")\n\n        try:\n            callbacks.remove(callback)\n        except ValueError:\n            raise MPVError(\n                f\"callback {callback!r} not registered for property {name!r}\"\n            )\n\n        serial = self._property_serials.pop((name, callback))\n        self.command(\"unobserve_property\", serial)\n\n    #\n    # Public API\n    #\n    def command(self, *args, timeout=1):\n        \"\"\"Execute a single command on the mpv process and return the result.\"\"\"\n        return self._send_request({\"command\": list(args)}, timeout=timeout)\n\n    def get_property(self, name):\n        \"\"\"Return the value of property `name`.\"\"\"\n        return self.command(\"get_property\", name)\n\n    def set_property(self, name, value):\n        \"\"\"Set the value of property `name`.\"\"\"\n        return self.command(\"set_property\", name, value)\n\n\n# alias this module for backwards compat\nsys.modules[\"anki.mpv\"] = sys.modules[\"aqt.mpv\"]\n"
  },
  {
    "path": "qt/aqt/notetypechooser.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nfrom anki.collection import OpChanges\nfrom anki.models import NotetypeId\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.qt import *\nfrom aqt.utils import HelpPage, shortcut, tr\n\n\nclass NotetypeChooser(QHBoxLayout):\n    \"\"\"\n    Unlike the older modelchooser, this does not modify the \"current model\",\n    so changes made here do not affect other parts of the UI. To read the\n    currently selected notetype id, use .selected_notetype_id.\n\n    By default, a chooser will pop up when the button is pressed. You can\n    override this by providing `on_button_activated`. Call .choose_notetype()\n    to run the normal behaviour.\n\n    `on_notetype_changed` will be called with the new notetype ID if the user\n    selects a different notetype, or if the currently-selected notetype is\n    deleted.\n    \"\"\"\n\n    _selected_notetype_id: NotetypeId\n\n    def __init__(\n        self,\n        *,\n        mw: AnkiQt,\n        widget: QWidget,\n        starting_notetype_id: NotetypeId,\n        on_button_activated: Callable[[], None] | None = None,\n        on_notetype_changed: Callable[[NotetypeId], None] | None = None,\n        show_prefix_label: bool = True,\n    ) -> None:\n        QHBoxLayout.__init__(self)\n        self._widget = widget  # type: ignore\n        self.mw = mw\n        if on_button_activated:\n            self.on_button_activated = on_button_activated\n        else:\n            self.on_button_activated = self.choose_notetype\n        self._setup_ui(show_label=show_prefix_label)\n        gui_hooks.state_did_reset.append(self.reset_state)\n        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)\n        self._selected_notetype_id = NotetypeId(0)\n        # triggers UI update; avoid firing changed hook on startup\n        self.on_notetype_changed = None\n        self.selected_notetype_id = starting_notetype_id\n        self.on_notetype_changed = on_notetype_changed\n\n    def _setup_ui(self, show_label: bool) -> None:\n        self.setContentsMargins(0, 0, 0, 0)\n        self.setSpacing(8)\n\n        if show_label:\n            self.label = QLabel(tr.notetypes_type())\n            self.addWidget(self.label)\n\n        # button\n        self.button = QPushButton()\n        self.button.setToolTip(shortcut(tr.qt_misc_change_note_type_ctrlandn()))\n        qconnect(\n            QShortcut(QKeySequence(\"Ctrl+N\"), self._widget).activated,\n            self.on_button_activated,\n        )\n        self.button.setAutoDefault(False)\n        self.addWidget(self.button)\n        qconnect(self.button.clicked, self.on_button_activated)\n        sizePolicy = QSizePolicy(QSizePolicy.Policy(7), QSizePolicy.Policy(0))\n        self.button.setSizePolicy(sizePolicy)\n        self._widget.setLayout(self)\n\n    def cleanup(self) -> None:\n        gui_hooks.state_did_reset.remove(self.reset_state)\n        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)\n\n    def reset_state(self) -> None:\n        self._ensure_selected_notetype_valid()\n\n    def show(self) -> None:\n        self._widget.show()  # type: ignore\n\n    def hide(self) -> None:\n        self._widget.hide()  # type: ignore\n\n    def onEdit(self) -> None:\n        import aqt.models\n\n        aqt.models.Models(self.mw, self._widget)\n\n    def choose_notetype(self) -> None:\n        from aqt.studydeck import StudyDeck\n\n        current = self.selected_notetype_name()\n\n        # edit button\n        edit = QPushButton(tr.qt_misc_manage())\n        qconnect(edit.clicked, self.onEdit)\n\n        def nameFunc() -> list[str]:\n            return sorted(n.name for n in self.mw.col.models.all_names_and_ids())\n\n        def callback(ret: StudyDeck) -> None:\n            if not ret.name:\n                return\n            notetype = self.mw.col.models.by_name(ret.name)\n            assert notetype is not None\n            if (id := notetype[\"id\"]) != self._selected_notetype_id:\n                self.selected_notetype_id = id\n\n        StudyDeck(\n            self.mw,\n            names=nameFunc,\n            accept=tr.actions_choose(),\n            title=tr.qt_misc_choose_note_type(),\n            help=HelpPage.NOTE_TYPE,\n            current=current,\n            parent=self._widget,\n            buttons=[edit],\n            cancel=True,\n            geomKey=\"selectModel\",\n            callback=callback,\n        )\n\n    @property\n    def selected_notetype_id(self) -> NotetypeId:\n        # theoretically this should not be necessary, as we're listening to\n        # resets\n        self._ensure_selected_notetype_valid()\n\n        return self._selected_notetype_id\n\n    @selected_notetype_id.setter\n    def selected_notetype_id(self, id: NotetypeId) -> None:\n        if id != self._selected_notetype_id:\n            self._selected_notetype_id = id\n            self._ensure_selected_notetype_valid()\n            self._update_button_label()\n            if func := self.on_notetype_changed:\n                func(self._selected_notetype_id)\n\n    def selected_notetype_name(self) -> str:\n        selected_notetype = self.mw.col.models.get(self.selected_notetype_id)\n        assert selected_notetype is not None\n        return selected_notetype[\"name\"]\n\n    def _ensure_selected_notetype_valid(self) -> None:\n        if not self.mw.col.models.get(self._selected_notetype_id):\n            self.selected_notetype_id = NotetypeId(\n                self.mw.col.models.all_names_and_ids()[0].id\n            )\n\n    def _update_button_label(self) -> None:\n        self.button.setText(self.selected_notetype_name().replace(\"&\", \"&&\"))\n\n    def on_operation_did_execute(\n        self, changes: OpChanges, handler: object | None\n    ) -> None:\n        if changes.notetype:\n            self._update_button_label()\n"
  },
  {
    "path": "qt/aqt/operations/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\nfrom concurrent.futures._base import Future\nfrom typing import Any, Generic, Protocol, TypeVar, Union\n\nimport aqt\nimport aqt.gui_hooks\nimport aqt.main\nfrom anki.collection import (\n    Collection,\n    ImportLogWithChanges,\n    OpChanges,\n    OpChangesAfterUndo,\n    OpChangesOnly,\n    OpChangesWithCount,\n    OpChangesWithId,\n    Progress,\n)\nfrom aqt.errors import show_exception\nfrom aqt.progress import ProgressUpdate\nfrom aqt.qt import QWidget\n\n\nclass HasChangesProperty(Protocol):\n    changes: OpChanges\n\n\n# either an OpChanges object, or an object with .changes on it. This bound\n# doesn't actually work for protobuf objects, so new protobuf objects will\n# either need to be added here, or cast at call time\nResultWithChanges = TypeVar(\n    \"ResultWithChanges\",\n    bound=Union[\n        OpChanges,\n        OpChangesOnly,\n        OpChangesWithCount,\n        OpChangesWithId,\n        OpChangesAfterUndo,\n        ImportLogWithChanges,\n        HasChangesProperty,\n    ],\n)\n\n\nclass CollectionOp(Generic[ResultWithChanges]):\n    \"\"\"Helper to perform a mutating DB operation on a background thread, and update UI.\n\n    `op` should either return OpChanges, or an object with a 'changes'\n    property. The changes will be passed to `operation_did_execute` so that\n    the UI can decide whether it needs to update itself.\n\n    - Shows progress popup for the duration of the op.\n    - Ensures the browser doesn't try to redraw during the operation, which can lead\n    to a frozen UI\n    - Updates undo state at the end of the operation\n    - Commits changes\n    - Fires the `operation_(will|did)_reset` hooks\n    - Fires the legacy `state_did_reset` hook\n\n    Be careful not to call any UI routines in `op`, as that may crash Qt.\n    This includes things select .selectedCards() in the browse screen.\n\n    `success` will be called with the return value of op().\n\n    If op() throws an exception, it will be shown in a popup, or\n    passed to `failure` if it is provided.\n    \"\"\"\n\n    _success: Callable[[ResultWithChanges], Any] | None = None\n    _failure: Callable[[Exception], Any] | None = None\n    _progress_update: Callable[[Progress, ProgressUpdate], None] | None = None\n\n    def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]):\n        self._parent = parent\n        self._op = op\n\n    def success(\n        self, success: Callable[[ResultWithChanges], Any] | None\n    ) -> CollectionOp[ResultWithChanges]:\n        self._success = success\n        return self\n\n    def failure(\n        self, failure: Callable[[Exception], Any] | None\n    ) -> CollectionOp[ResultWithChanges]:\n        self._failure = failure\n        return self\n\n    def with_backend_progress(\n        self, progress_update: Callable[[Progress, ProgressUpdate], None] | None\n    ) -> CollectionOp[ResultWithChanges]:\n        self._progress_update = progress_update\n        return self\n\n    def run_in_background(self, *, initiator: object | None = None) -> None:\n        from aqt import mw\n\n        assert mw\n\n        mw._increase_background_ops()\n\n        def wrapped_op() -> ResultWithChanges:\n            assert mw\n            return self._op(mw.col)\n\n        def wrapped_done(future: Future) -> None:\n            assert mw\n            mw._decrease_background_ops()\n            # did something go wrong?\n            if exception := future.exception():\n                if isinstance(exception, Exception):\n                    if self._failure:\n                        self._failure(exception)\n                    else:\n                        show_exception(parent=self._parent, exception=exception)\n                    return\n                else:\n                    # BaseException like SystemExit; rethrow it\n                    future.result()\n\n            result = future.result()\n            try:\n                if self._success:\n                    self._success(result)\n            finally:\n                on_op_finished(mw, result, initiator)\n\n        self._run(mw, wrapped_op, wrapped_done)\n\n    def _run(\n        self,\n        mw: aqt.main.AnkiQt,\n        op: Callable[[], ResultWithChanges],\n        on_done: Callable[[Future], None],\n    ) -> None:\n        if self._progress_update:\n            mw.taskman.with_backend_progress(\n                op, self._progress_update, on_done=on_done, parent=self._parent\n            )\n        else:\n            mw.taskman.with_progress(op, on_done, parent=self._parent)\n\n\ndef on_op_finished(\n    mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None\n) -> None:\n    mw.update_undo_actions()\n\n    if isinstance(result, OpChanges):\n        changes = result\n    else:\n        changes = result.changes  # type: ignore[union-attr]\n\n    # fire new hook\n    aqt.gui_hooks.operation_did_execute(changes, initiator)\n    # fire legacy hook so old code notices changes\n    if mw.col.op_made_changes(changes):\n        aqt.gui_hooks.state_did_reset()\n\n\nT = TypeVar(\"T\")\n\n\nclass QueryOp(Generic[T]):\n    \"\"\"Helper to perform an operation on a background thread.\n\n    QueryOp is primarily used for read-only requests (reading information\n    from the database, fetching data from the network, etc), but can also\n    be used for mutable requests outside of the collection undo system\n    (eg adding/deleting files, calling a collection method that doesn't support\n    undo, etc). For operations that support undo, use CollectionOp instead.\n\n    - Optionally shows progress popup for the duration of the op.\n    - Ensures the browser doesn't try to redraw during the operation, which can lead\n    to a frozen UI\n\n    Be careful not to call any UI routines in `op`, as that may crash Qt.\n    This includes things like .selectedCards() in the browse screen.\n\n    `success` will be called with the return value of op().\n\n    If op() throws an exception, it will be shown in a popup, or\n    passed to `failure` if it is provided.\n    \"\"\"\n\n    _failure: Callable[[Exception], Any] | None = None\n    _progress: bool | str = False\n    _progress_update: Callable[[Progress, ProgressUpdate], None] | None = None\n\n    def __init__(\n        self,\n        *,\n        parent: QWidget,\n        op: Callable[[Collection], T],\n        success: Callable[[T], Any],\n    ):\n        self._parent = parent\n        self._op = op\n        self._success = success\n        self._uses_collection = True\n\n    def failure(self, failure: Callable[[Exception], Any] | None) -> QueryOp[T]:\n        self._failure = failure\n        return self\n\n    def without_collection(self) -> QueryOp[T]:\n        \"\"\"Flag this QueryOp as not needing the collection.\n\n        Operations that access the collection are serialized. If you're doing\n        something like a series of network queries, and your operation does not\n        access the collection, then you can call this to allow the requests to\n        run in parallel.\"\"\"\n        self._uses_collection = False\n        return self\n\n    def with_progress(\n        self,\n        label: str | None = None,\n    ) -> QueryOp[T]:\n        \"If label not provided, will default to 'Processing...'\"\n        self._progress = label or True\n        return self\n\n    def with_backend_progress(\n        self, progress_update: Callable[[Progress, ProgressUpdate], None] | None\n    ) -> QueryOp[T]:\n        self._progress_update = progress_update\n        return self\n\n    def run_in_background(self) -> None:\n        from aqt import mw\n\n        assert mw\n\n        mw._increase_background_ops()\n\n        def wrapped_op() -> T:\n            assert mw\n            return self._op(mw.col)\n\n        def wrapped_done(future: Future) -> None:\n            assert mw\n\n            mw._decrease_background_ops()\n            # did something go wrong?\n            if exception := future.exception():\n                if isinstance(exception, Exception):\n                    if self._failure:\n                        self._failure(exception)\n                    else:\n                        show_exception(parent=self._parent, exception=exception)\n                    return\n                else:\n                    # BaseException like SystemExit; rethrow it\n                    future.result()\n\n            self._success(future.result())\n\n        self._run(mw, wrapped_op, wrapped_done)\n\n    def _run(\n        self,\n        mw: aqt.main.AnkiQt,\n        op: Callable[[], T],\n        on_done: Callable[[Future], None],\n    ) -> None:\n        label = self._progress if isinstance(self._progress, str) else None\n        if self._progress_update:\n            mw.taskman.with_backend_progress(\n                op,\n                self._progress_update,\n                on_done=on_done,\n                start_label=label,\n                parent=self._parent,\n            )\n        elif self._progress:\n            mw.taskman.with_progress(op, on_done, label=label, parent=self._parent)\n        else:\n            mw.taskman.run_in_background(\n                op, on_done, uses_collection=self._uses_collection\n            )\n"
  },
  {
    "path": "qt/aqt/operations/card.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nfrom anki.cards import CardId\nfrom anki.collection import OpChangesWithCount\nfrom anki.decks import DeckId\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\nfrom aqt.utils import tooltip, tr\n\n\ndef set_card_deck(\n    *, parent: QWidget, card_ids: Sequence[CardId], deck_id: DeckId\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.set_deck(card_ids, deck_id)).success(\n        lambda out: tooltip(tr.browsing_cards_updated(count=out.count), parent=parent)\n    )\n\n\ndef set_card_flag(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n    flag: int,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.set_user_flag_for_cards(flag, card_ids)\n    ).success(\n        lambda out: tooltip(tr.browsing_cards_updated(count=out.count), parent=parent)\n    )\n"
  },
  {
    "path": "qt/aqt/operations/collection.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom anki.collection import OpChanges, OpChangesAfterUndo, Preferences\nfrom anki.errors import UndoEmpty\nfrom aqt import gui_hooks\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\nfrom aqt.utils import showWarning, tooltip, tr\n\n\ndef undo(*, parent: QWidget) -> None:\n    \"Undo the last operation, and refresh the UI.\"\n\n    def on_success(out: OpChangesAfterUndo) -> None:\n        gui_hooks.state_did_undo(out)\n        tooltip(tr.undo_action_undone(action=out.operation), parent=parent)\n\n    def on_failure(exc: Exception) -> None:\n        if not isinstance(exc, UndoEmpty):\n            showWarning(str(exc), parent=parent)\n\n    CollectionOp(parent, lambda col: col.undo()).success(on_success).failure(\n        on_failure\n    ).run_in_background()\n\n\ndef redo(*, parent: QWidget) -> None:\n    \"Redo the last operation, and refresh the UI.\"\n\n    def on_success(out: OpChangesAfterUndo) -> None:\n        tooltip(tr.undo_action_redone(action=out.operation), parent=parent)\n\n    CollectionOp(parent, lambda col: col.redo()).success(on_success).run_in_background()\n\n\ndef set_preferences(\n    *, parent: QWidget, preferences: Preferences\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.set_preferences(preferences))\n"
  },
  {
    "path": "qt/aqt/operations/deck.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport html\nfrom collections.abc import Sequence\n\nfrom anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId\nfrom anki.decks import DeckCollapseScope, DeckDict, DeckId, UpdateDeckConfigs\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\nfrom aqt.utils import getOnlyText, tooltip, tr\n\n\ndef remove_decks(\n    *,\n    parent: QWidget,\n    deck_ids: Sequence[DeckId],\n    deck_name: str,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.decks.remove(deck_ids)).success(\n        lambda out: tooltip(\n            tr.browsing_cards_deleted_with_deckname(\n                count=out.count,\n                deck_name=html.escape(deck_name),\n            ),\n            parent=parent,\n        )\n    )\n\n\ndef reparent_decks(\n    *, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent)\n    ).success(\n        lambda out: tooltip(\n            tr.browsing_reparented_decks(count=out.count), parent=parent\n        )\n    )\n\n\ndef rename_deck(\n    *,\n    parent: QWidget,\n    deck_id: DeckId,\n    new_name: str,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent,\n        lambda col: col.decks.rename(deck_id, new_name),\n    )\n\n\ndef add_deck_dialog(\n    *,\n    parent: QWidget,\n    default_text: str = \"\",\n) -> CollectionOp[OpChangesWithId] | None:\n    if name := getOnlyText(\n        tr.decks_new_deck_name(),\n        default=default_text,\n        parent=parent,\n        title=tr.decks_create_deck(),\n    ).strip():\n        return add_deck(parent=parent, name=name)\n    else:\n        return None\n\n\ndef add_deck(*, parent: QWidget, name: str) -> CollectionOp[OpChangesWithId]:\n    return CollectionOp(parent, lambda col: col.decks.add_normal_deck_with_name(name))\n\n\ndef set_deck_collapsed(\n    *,\n    parent: QWidget,\n    deck_id: DeckId,\n    collapsed: bool,\n    scope: DeckCollapseScope.V,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent,\n        lambda col: col.decks.set_collapsed(\n            deck_id=deck_id, collapsed=collapsed, scope=scope\n        ),\n    )\n\n\ndef set_current_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.decks.set_current(deck_id))\n\n\ndef update_deck_configs(\n    *, parent: QWidget, input: UpdateDeckConfigs\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.decks.update_deck_configs(input))\n\n\ndef update_deck_dict(*, parent: QWidget, deck: DeckDict) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.decks.update_dict(deck))\n"
  },
  {
    "path": "qt/aqt/operations/note.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nfrom anki.collection import OpChanges, OpChangesWithCount\nfrom anki.decks import DeckId\nfrom anki.notes import Note, NoteId\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\nfrom aqt.utils import tooltip, tr\n\n\ndef add_note(\n    *,\n    parent: QWidget,\n    note: Note,\n    target_deck_id: DeckId,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.add_note(note, target_deck_id))\n\n\ndef update_note(*, parent: QWidget, note: Note) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.update_note(note))\n\n\ndef update_notes(*, parent: QWidget, notes: Sequence[Note]) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.update_notes(notes)).success(\n        lambda _: tooltip(tr.browsing_cards_updated(count=len(notes)))\n    )\n\n\ndef remove_notes(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success(\n        lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)),\n    )\n\n\ndef find_and_replace(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n    search: str,\n    replacement: str,\n    regex: bool,\n    field_name: str | None,\n    match_case: bool,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent,\n        lambda col: col.find_and_replace(\n            note_ids=note_ids,\n            search=search,\n            replacement=replacement,\n            regex=regex,\n            field_name=field_name,\n            match_case=match_case,\n        ),\n    ).success(\n        lambda out: tooltip(\n            tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),\n            parent=parent,\n        )\n    )\n"
  },
  {
    "path": "qt/aqt/operations/notetype.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom anki.collection import OpChanges, OpChangesWithId\nfrom anki.models import ChangeNotetypeRequest, NotetypeDict, NotetypeId\nfrom anki.stdmodels import StockNotetypeKind\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\n\n\ndef add_notetype_legacy(\n    *,\n    parent: QWidget,\n    notetype: NotetypeDict,\n) -> CollectionOp[OpChangesWithId]:\n    return CollectionOp(parent, lambda col: col.models.add_dict(notetype))\n\n\ndef update_notetype_legacy(\n    *,\n    parent: QWidget,\n    notetype: NotetypeDict,\n    skip_checks: bool = False,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent, lambda col: col.models.update_dict(notetype, skip_checks)\n    )\n\n\ndef remove_notetype(\n    *,\n    parent: QWidget,\n    notetype_id: NotetypeId,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.models.remove(notetype_id))\n\n\ndef change_notetype_of_notes(\n    *, parent: QWidget, input: ChangeNotetypeRequest\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.models.change_notetype_of_notes(input))\n\n\ndef restore_notetype_to_stock(\n    *, parent: QWidget, notetype_id: NotetypeId, force_kind: StockNotetypeKind.V | None\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent,\n        lambda col: col.models.restore_notetype_to_stock(notetype_id, force_kind),\n    )\n"
  },
  {
    "path": "qt/aqt/operations/scheduling.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nimport aqt\nimport aqt.forms\nfrom anki.cards import CardId\nfrom anki.collection import (\n    CARD_TYPE_NEW,\n    Collection,\n    Config,\n    OpChanges,\n    OpChangesWithCount,\n    OpChangesWithId,\n)\nfrom anki.decks import DeckId\nfrom anki.notes import NoteId\nfrom anki.scheduler import CustomStudyRequest, FilteredDeckForUpdate, UnburyDeck\nfrom anki.scheduler.base import ScheduleCardsAsNew\nfrom anki.scheduler.v3 import CardAnswer\nfrom anki.scheduler.v3 import Scheduler as V3Scheduler\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import *\nfrom aqt.utils import disable_help_button, getText, tooltip, tr\n\n\ndef set_due_date_dialog(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n    config_key: Config.String.V | None,\n) -> CollectionOp[OpChanges] | None:\n    assert aqt.mw\n    if not card_ids:\n        return None\n\n    default_text = (\n        aqt.mw.col.get_config_string(config_key) if config_key is not None else \"\"\n    )\n    prompt = \"\\n\".join(\n        [\n            tr.scheduling_set_due_date_prompt(cards=len(card_ids)),\n            tr.scheduling_set_due_date_prompt_hint(),\n        ]\n    )\n    (days, success) = getText(\n        prompt=prompt,\n        parent=parent,\n        default=default_text,\n        title=tr.actions_set_due_date(),\n    )\n    if not success or not days.strip():\n        return None\n    else:\n        return CollectionOp(\n            parent, lambda col: col.sched.set_due_date(card_ids, days, config_key)\n        ).success(\n            lambda _: tooltip(\n                tr.scheduling_set_due_date_done(cards=len(card_ids)),\n                parent=parent,\n            )\n        )\n\n\ndef grade_now(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n    ease: int,\n) -> CollectionOp[OpChanges]:\n    if ease == 1:\n        rating = CardAnswer.AGAIN\n    elif ease == 2:\n        rating = CardAnswer.HARD\n    elif ease == 3:\n        rating = CardAnswer.GOOD\n    else:\n        rating = CardAnswer.EASY\n    return CollectionOp(\n        parent,\n        lambda col: col._backend.grade_now(\n            card_ids=card_ids,\n            rating=rating,\n        ),\n    ).success(\n        lambda _: tooltip(\n            tr.scheduling_graded_cards_done(cards=len(card_ids)), parent=parent\n        )\n    )\n\n\ndef forget_cards(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n    context: ScheduleCardsAsNew.Context.V | None = None,\n) -> CollectionOp[OpChanges] | None:\n    assert aqt.mw\n\n    dialog = QDialog(parent)\n    disable_help_button(dialog)\n    form = aqt.forms.forget.Ui_Dialog()\n    form.setupUi(dialog)\n\n    if context is not None:\n        defaults = aqt.mw.col.sched.schedule_cards_as_new_defaults(context)\n        form.restore_position.setChecked(defaults.restore_position)\n        form.reset_counts.setChecked(defaults.reset_counts)\n\n    if not dialog.exec():\n        return None\n\n    restore_position = form.restore_position.isChecked()\n    reset_counts = form.reset_counts.isChecked()\n\n    return CollectionOp(\n        parent,\n        lambda col: col.sched.schedule_cards_as_new(\n            card_ids,\n            restore_position=restore_position,\n            reset_counts=reset_counts,\n            context=context,\n        ),\n    ).success(\n        lambda _: tooltip(\n            tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent\n        )\n    )\n\n\ndef reposition_new_cards_dialog(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n) -> CollectionOp[OpChangesWithCount] | None:\n    from aqt import mw\n\n    assert mw\n    assert mw.col.db\n\n    row = mw.col.db.first(\n        f\"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0\"\n    )\n    assert row\n    (min_position, max_position) = row\n    min_position = max(min_position or 0, 0)\n    max_position = max_position or 0\n\n    dialog = QDialog(parent)\n    disable_help_button(dialog)\n    dialog.setWindowModality(Qt.WindowModality.WindowModal)\n    form = aqt.forms.reposition.Ui_Dialog()\n    form.setupUi(dialog)\n\n    txt = tr.browsing_queue_top(val=min_position)\n    txt += \"\\n\" + tr.browsing_queue_bottom(val=max_position)\n    form.label.setText(txt)\n\n    form.start.selectAll()\n\n    defaults = mw.col.sched.reposition_defaults()\n    form.randomize.setChecked(defaults.random)\n    form.shift.setChecked(defaults.shift)\n\n    if not dialog.exec():\n        return None\n\n    start = form.start.value()\n    step = form.step.value()\n    randomize = form.randomize.isChecked()\n    shift = form.shift.isChecked()\n\n    return reposition_new_cards(\n        parent=parent,\n        card_ids=card_ids,\n        starting_from=start,\n        step_size=step,\n        randomize=randomize,\n        shift_existing=shift,\n    )\n\n\ndef reposition_new_cards(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n    starting_from: int,\n    step_size: int,\n    randomize: bool,\n    shift_existing: bool,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent,\n        lambda col: col.sched.reposition_new_cards(\n            card_ids=card_ids,\n            starting_from=starting_from,\n            step_size=step_size,\n            randomize=randomize,\n            shift_existing=shift_existing,\n        ),\n    ).success(\n        lambda out: tooltip(\n            tr.browsing_changed_new_position(count=out.count), parent=parent\n        )\n    )\n\n\ndef suspend_cards(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.sched.suspend_cards(card_ids))\n\n\ndef suspend_note(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.sched.suspend_notes(note_ids))\n\n\ndef unsuspend_cards(\n    *, parent: QWidget, card_ids: Sequence[CardId]\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.sched.unsuspend_cards(card_ids))\n\n\ndef bury_cards(\n    *,\n    parent: QWidget,\n    card_ids: Sequence[CardId],\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.sched.bury_cards(card_ids))\n\n\ndef bury_notes(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.sched.bury_notes(note_ids))\n\n\ndef unbury_cards(\n    *, parent: QWidget, card_ids: Sequence[CardId]\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.sched.unbury_cards(card_ids))\n\n\ndef rebuild_filtered_deck(\n    *, parent: QWidget, deck_id: DeckId\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.sched.rebuild_filtered_deck(deck_id))\n\n\ndef empty_filtered_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.sched.empty_filtered_deck(deck_id))\n\n\ndef add_or_update_filtered_deck(\n    *,\n    parent: QWidget,\n    deck: FilteredDeckForUpdate,\n) -> CollectionOp[OpChangesWithId]:\n    return CollectionOp(parent, lambda col: col.sched.add_or_update_filtered_deck(deck))\n\n\ndef unbury_deck(\n    *,\n    parent: QWidget,\n    deck_id: DeckId,\n    mode: UnburyDeck.Mode.V = UnburyDeck.ALL,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent, lambda col: col.sched.unbury_deck(deck_id=deck_id, mode=mode)\n    )\n\n\ndef answer_card(\n    *,\n    parent: QWidget,\n    answer: CardAnswer,\n) -> CollectionOp[OpChanges]:\n    def answer_v3(col: Collection) -> OpChanges:\n        assert isinstance(col.sched, V3Scheduler)\n        return col.sched.answer_card(answer)\n\n    return CollectionOp(parent, answer_v3)\n\n\ndef custom_study(\n    *,\n    parent: QWidget,\n    request: CustomStudyRequest,\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(parent, lambda col: col.sched.custom_study(request))\n"
  },
  {
    "path": "qt/aqt/operations/tag.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Sequence\n\nfrom anki.collection import OpChanges, OpChangesWithCount\nfrom anki.notes import NoteId\nfrom aqt.operations import CollectionOp\nfrom aqt.qt import QWidget\nfrom aqt.utils import showInfo, tooltip, tr\n\n\ndef add_tags_to_notes(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n    space_separated_tags: str,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags)\n    ).success(\n        lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)\n    )\n\n\ndef remove_tags_from_notes(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[NoteId],\n    space_separated_tags: str,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags)\n    ).success(\n        lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)\n    )\n\n\ndef clear_unused_tags(*, parent: QWidget) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success(\n        lambda out: tooltip(\n            tr.browsing_removed_unused_tags_count(count=out.count), parent=parent\n        )\n    )\n\n\ndef rename_tag(\n    *,\n    parent: QWidget,\n    current_name: str,\n    new_name: str,\n) -> CollectionOp[OpChangesWithCount]:\n    def success(out: OpChangesWithCount) -> None:\n        if out.count:\n            tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)\n        else:\n            showInfo(tr.browsing_tag_rename_warning_empty(), parent=parent)\n\n    return CollectionOp(\n        parent,\n        lambda col: col.tags.rename(old=current_name, new=new_name),\n    ).success(success)\n\n\ndef remove_tags_from_all_notes(\n    *, parent: QWidget, space_separated_tags: str\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags)\n    ).success(\n        lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)\n    )\n\n\ndef reparent_tags(\n    *, parent: QWidget, tags: Sequence[str], new_parent: str\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent)\n    ).success(\n        lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent)\n    )\n\n\ndef set_tag_collapsed(\n    *, parent: QWidget, tag: str, collapsed: bool\n) -> CollectionOp[OpChanges]:\n    return CollectionOp(\n        parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed)\n    )\n\n\ndef find_and_replace_tag(\n    *,\n    parent: QWidget,\n    note_ids: Sequence[int],\n    search: str,\n    replacement: str,\n    regex: bool,\n    match_case: bool,\n) -> CollectionOp[OpChangesWithCount]:\n    return CollectionOp(\n        parent,\n        lambda col: col.tags.find_and_replace(\n            note_ids=note_ids,\n            search=search,\n            replacement=replacement,\n            regex=regex,\n            match_case=match_case,\n        ),\n    ).success(\n        lambda out: tooltip(\n            tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)),\n            parent=parent,\n        ),\n    )\n"
  },
  {
    "path": "qt/aqt/overview.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport html\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport aqt\nimport aqt.operations\nfrom anki.collection import OpChanges\nfrom anki.scheduler import UnburyDeck\nfrom aqt import gui_hooks\nfrom aqt.deckdescription import DeckDescriptionDialog\nfrom aqt.deckoptions import display_options_for_deck\nfrom aqt.operations import QueryOp\nfrom aqt.operations.scheduling import (\n    empty_filtered_deck,\n    rebuild_filtered_deck,\n    unbury_deck,\n)\nfrom aqt.sound import av_player\nfrom aqt.toolbar import BottomBar\nfrom aqt.utils import askUserDialog, openLink, shortcut, tooltip, tr\n\n\nclass OverviewBottomBar:\n    def __init__(self, overview: Overview) -> None:\n        self.overview = overview\n\n\n@dataclass\nclass OverviewContent:\n    \"\"\"Stores sections of HTML content that the overview will be\n    populated with.\n\n    Attributes:\n        deck {str} -- Plain text deck name\n        shareLink {str} -- HTML of the share link section\n        desc {str} -- HTML of the deck description section\n        table {str} -- HTML of the deck stats table section\n    \"\"\"\n\n    deck: str\n    shareLink: str\n    desc: str\n    table: str\n\n\nclass Overview:\n    \"Deck overview.\"\n\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        self.mw = mw\n        self.web = mw.web\n        self.bottom = BottomBar(mw, mw.bottomWeb)\n        self._refresh_needed = False\n\n    def show(self) -> None:\n        av_player.stop_and_clear_queue()\n        self.web.set_bridge_command(self._linkHandler, self)\n        self.mw.setStateShortcuts(self._shortcutKeys())\n        self.refresh()\n\n    def refresh(self) -> None:\n        def success(_counts: tuple) -> None:\n            self._refresh_needed = False\n            self._renderPage()\n            self._renderBottom()\n            self.mw.web.setFocus()\n            gui_hooks.overview_did_refresh(self)\n\n        QueryOp(\n            parent=self.mw, op=lambda col: col.sched.counts(), success=success\n        ).run_in_background()\n\n    def refresh_if_needed(self) -> None:\n        if self._refresh_needed:\n            self.refresh()\n\n    def op_executed(\n        self, changes: OpChanges, handler: object | None, focused: bool\n    ) -> bool:\n        if changes.study_queues:\n            self._refresh_needed = True\n\n        if focused:\n            self.refresh_if_needed()\n\n        return self._refresh_needed\n\n    # Handlers\n    ############################################################\n\n    def _linkHandler(self, url: str) -> bool:\n        if url == \"study\":\n            self.mw.col.startTimebox()\n            self.mw.moveToState(\"review\")\n            if self.mw.state == \"overview\":\n                tooltip(tr.studying_no_cards_are_due_yet())\n        elif url == \"anki\":\n            print(\"anki menu\")\n        elif url == \"opts\":\n            display_options_for_deck(self.mw.col.decks.current())\n        elif url == \"cram\":\n            aqt.dialogs.open(\"FilteredDeckConfigDialog\", self.mw)\n        elif url == \"refresh\":\n            self.rebuild_current_filtered_deck()\n        elif url == \"empty\":\n            self.empty_current_filtered_deck()\n        elif url == \"decks\":\n            self.mw.moveToState(\"deckBrowser\")\n        elif url == \"review\":\n            openLink(f\"{aqt.appShared}info/{self.sid}?v={self.sidVer}\")\n        elif url in {\"studymore\", \"customStudy\"}:\n            self.onStudyMore()\n        elif url == \"unbury\":\n            self.on_unbury()\n        elif url == \"description\":\n            self.edit_description()\n        elif url.lower().startswith(\"http\"):\n            openLink(url)\n        return False\n\n    def _shortcutKeys(self) -> list[tuple[str, Callable]]:\n        return [\n            (\"o\", lambda: display_options_for_deck(self.mw.col.decks.current())),\n            (\"r\", self.rebuild_current_filtered_deck),\n            (\"e\", self.empty_current_filtered_deck),\n            (\"c\", self.onCustomStudyKey),\n            (\"u\", self.on_unbury),\n        ]\n\n    def _current_deck_is_filtered(self) -> int:\n        return self.mw.col.decks.current()[\"dyn\"]\n\n    def rebuild_current_filtered_deck(self) -> None:\n        rebuild_filtered_deck(\n            parent=self.mw, deck_id=self.mw.col.decks.selected()\n        ).run_in_background()\n\n    def empty_current_filtered_deck(self) -> None:\n        empty_filtered_deck(\n            parent=self.mw, deck_id=self.mw.col.decks.selected()\n        ).run_in_background()\n\n    def onCustomStudyKey(self) -> None:\n        if not self._current_deck_is_filtered():\n            self.onStudyMore()\n\n    def on_unbury(self) -> None:\n        mode = UnburyDeck.Mode.ALL\n        info = self.mw.col.sched.congratulations_info()\n        if info.have_sched_buried and info.have_user_buried:\n            opts = [\n                tr.studying_manually_buried_cards(),\n                tr.studying_buried_siblings(),\n                tr.studying_all_buried_cards(),\n                tr.actions_cancel(),\n            ]\n\n            diag = askUserDialog(tr.studying_what_would_you_like_to_unbury(), opts)\n            diag.setDefault(0)\n            ret = diag.run()\n            if ret == opts[0]:\n                mode = UnburyDeck.Mode.USER_ONLY\n            elif ret == opts[1]:\n                mode = UnburyDeck.Mode.SCHED_ONLY\n            elif ret == opts[3]:\n                return\n\n        unbury_deck(\n            parent=self.mw, deck_id=self.mw.col.decks.get_current_id(), mode=mode\n        ).run_in_background()\n\n    onUnbury = on_unbury\n\n    # HTML\n    ############################################################\n\n    def _renderPage(self) -> None:\n        deck = self.mw.col.decks.current()\n        self.sid = deck.get(\"sharedFrom\")\n        if self.sid:\n            self.sidVer = deck.get(\"ver\", None)\n            shareLink = '<a class=smallLink href=\"review\">Reviews and Updates</a>'\n        else:\n            shareLink = \"\"\n        if self.mw.col.sched._is_finished():\n            self._show_finished_screen()\n            return\n        content = OverviewContent(\n            deck=deck[\"name\"],\n            shareLink=shareLink,\n            desc=self._desc(deck),\n            table=self._table(),\n        )\n        gui_hooks.overview_will_render_content(self, content)\n        content.deck = html.escape(content.deck)\n        self.web.stdHtml(\n            self._body % content.__dict__,\n            css=[\"css/overview.css\"],\n            js=[\"js/vendor/jquery.min.js\"],\n            context=self,\n        )\n\n    def _show_finished_screen(self) -> None:\n        self.web.load_sveltekit_page(\"congrats\")\n\n    def _desc(self, deck: dict[str, Any]) -> str:\n        if deck[\"dyn\"]:\n            desc = tr.studying_this_is_a_special_deck_for()\n            desc += f\" {tr.studying_cards_will_be_automatically_returned_to()}\"\n            desc += f\" {tr.studying_deleting_this_deck_from_the_deck()}\"\n        else:\n            desc = deck.get(\"desc\", \"\")\n            if deck.get(\"md\", False):\n                desc = self.mw.col.render_markdown(desc)\n        if not desc:\n            return \"<p>\"\n        if deck[\"dyn\"]:\n            dyn = \"dyn\"\n        else:\n            dyn = \"\"\n        return f'<div class=\"descfont descmid description {dyn}\">{desc}</div>'\n\n    def _table(self) -> str:\n        counts = list(self.mw.col.sched.counts())\n        current_did = self.mw.col.decks.get_current_id()\n        deck_node = self.mw.col.sched.deck_due_tree(current_did)\n\n        but = self.mw.button\n        if self.mw.col.v3_scheduler():\n            assert deck_node is not None\n            buried_new = deck_node.new_count - counts[0]\n            buried_learning = deck_node.learn_count - counts[1]\n            buried_review = deck_node.review_count - counts[2]\n        else:\n            buried_new = buried_learning = buried_review = 0\n        buried_label = tr.studying_counts_differ()\n\n        def number_row(title: str, klass: str, count: int, buried_count: int) -> str:\n            buried = f\"{buried_count:+}\" if buried_count else \"\"\n            return f\"\"\"\n<tr>\n    <td>{title}:</td>\n    <td>\n        <b>\n            <span class={klass}>{count}</span>\n            <span class=bury-count title=\"{buried_label}\">{buried}</span>\n        </b>\n    </td>\n</tr>\n\"\"\"\n\n        return f\"\"\"\n<table width=400 cellpadding=5>\n<tr><td align=center valign=top>\n<table cellspacing=5>\n{number_row(tr.actions_new(), \"new-count\", counts[0], buried_new)}\n{number_row(tr.scheduling_learning(), \"learn-count\", counts[1], buried_learning)}\n{number_row(tr.studying_to_review(), \"review-count\", counts[2], buried_review)}\n</table>\n</td><td align=center>\n{but(\"study\", tr.studying_study_now(), id=\"study\", extra=\" autofocus\")}</td></tr></table>\"\"\"\n\n    _body = \"\"\"\n<center>\n<h3>%(deck)s</h3>\n%(shareLink)s\n%(desc)s\n%(table)s\n</center>\n\"\"\"\n\n    def edit_description(self) -> None:\n        DeckDescriptionDialog(self.mw)\n\n    # Bottom area\n    ######################################################################\n\n    def _renderBottom(self) -> None:\n        links = [\n            [\"O\", \"opts\", tr.actions_options()],\n        ]\n        is_dyn = self.mw.col.decks.current()[\"dyn\"]\n        if is_dyn:\n            links.append([\"R\", \"refresh\", tr.actions_rebuild()])\n            links.append([\"E\", \"empty\", tr.studying_empty()])\n        else:\n            links.append([\"C\", \"studymore\", tr.actions_custom_study()])\n            # links.append([\"F\", \"cram\", _(\"Filter/Cram\")])\n        if self.mw.col.sched.have_buried():\n            links.append([\"U\", \"unbury\", tr.studying_unbury()])\n        if not is_dyn:\n            links.append([\"\", \"description\", tr.scheduling_description()])\n        link_handler = gui_hooks.overview_will_render_bottom(\n            self._linkHandler,\n            links,\n        )\n        if not callable(link_handler):\n            link_handler = self._linkHandler\n        buf = \"\"\n        for b in links:\n            if b[0]:\n                b[0] = tr.actions_shortcut_key(val=shortcut(b[0]))\n            buf += \"\"\"\n<button title=\"%s\" onclick='pycmd(\"%s\")'>%s</button>\"\"\" % tuple(b)\n        self.bottom.draw(\n            buf=buf,\n            link_handler=link_handler,\n            web_context=OverviewBottomBar(self),\n        )\n\n    # Studying more\n    ######################################################################\n\n    def onStudyMore(self) -> None:\n        import aqt.customstudy\n\n        aqt.customstudy.CustomStudy.fetch_data_and_show(self.mw)\n"
  },
  {
    "path": "qt/aqt/package.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"Helpers for the packaged version of Anki.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nfrom anki.utils import is_mac, is_win\n\n\n# ruff: noqa: F401\ndef first_run_setup() -> None:\n    \"\"\"Code run the first time after install/upgrade.\n\n    Currently, we just import our main libraries and invoke\n    mpv/lame on macOS, which is slow on the first run, and doing\n    it this way shows progress being made.\n    \"\"\"\n\n    if not is_mac:\n        return\n\n    # Import anki_audio first and spawn commands\n    import anki_audio\n\n    audio_pkg_path = Path(anki_audio.__file__).parent\n\n    # Start mpv and lame commands concurrently\n    processes = []\n    for cmd_name in [\"mpv\", \"lame\"]:\n        cmd_path = audio_pkg_path / cmd_name\n        proc = subprocess.Popen(\n            [str(cmd_path), \"--version\"],\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        processes.append(proc)\n\n    # Continue with other imports while commands run\n    import concurrent.futures\n\n    import bs4\n    import flask\n    import flask_cors\n    import markdown\n    import PyQt6.QtCore\n    import PyQt6.QtGui\n    import PyQt6.QtNetwork\n    import PyQt6.QtQuick\n    import PyQt6.QtWebChannel\n    import PyQt6.QtWebEngineCore\n    import PyQt6.QtWebEngineWidgets\n    import PyQt6.QtWidgets\n    import PyQt6.sip\n    import requests\n    import waitress\n\n    import anki.collection\n\n    from . import _macos_helper\n\n    # Wait for both commands to complete\n    for proc in processes:\n        proc.wait()\n\n\ndef uv_binary() -> str | None:\n    \"\"\"Return the path to the uv binary.\"\"\"\n    return os.environ.get(\"ANKI_LAUNCHER_UV\")\n\n\ndef launcher_root() -> str | None:\n    \"\"\"Return the path to the launcher root directory (AnkiProgramFiles).\"\"\"\n    return os.environ.get(\"UV_PROJECT\")\n\n\ndef venv_binary(cmd: str) -> str | None:\n    \"\"\"Return the path to a binary in the launcher's venv.\"\"\"\n    root = launcher_root()\n    if not root:\n        return None\n\n    root_path = Path(root)\n    if is_win:\n        binary_path = root_path / \".venv\" / \"Scripts\" / cmd\n    else:\n        binary_path = root_path / \".venv\" / \"bin\" / cmd\n\n    return str(binary_path)\n\n\ndef add_python_requirements(reqs: list[str]) -> tuple[bool, str]:\n    \"\"\"Add Python requirements to the launcher venv using uv add.\n\n    Returns (success, output)\"\"\"\n\n    binary = uv_binary()\n    if not binary:\n        return (False, \"Not in packaged build.\")\n\n    uv_cmd = [binary, \"add\"] + reqs\n    result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False)\n\n    if result.returncode == 0:\n        root = launcher_root()\n        if root:\n            sync_marker = Path(root) / \".sync_complete\"\n            sync_marker.touch()\n\n        return (True, result.stdout)\n    else:\n        return (False, result.stderr)\n\n\ndef launcher_executable() -> str | None:\n    \"\"\"Return the path to the Anki launcher executable.\"\"\"\n    return os.getenv(\"ANKI_LAUNCHER\")\n\n\ndef trigger_launcher_run() -> None:\n    \"\"\"Create a trigger file to request launcher UI on next run.\"\"\"\n    try:\n        root = launcher_root()\n        if not root:\n            return\n\n        trigger_path = Path(root) / \".want-launcher\"\n        trigger_path.touch()\n    except Exception as e:\n        print(e)\n\n\ndef update_and_restart() -> None:\n    \"\"\"Update and restart Anki using the launcher.\"\"\"\n    from aqt import mw\n\n    launcher = launcher_executable()\n    assert launcher\n\n    trigger_launcher_run()\n\n    with contextlib.suppress(ResourceWarning):\n        env = os.environ.copy()\n        env[\"ANKI_LAUNCHER_WANT_TERMINAL\"] = \"1\"\n        # fixes a bug where launcher fails to appear if opening it\n        # straight after updating\n        if \"GNOME_TERMINAL_SCREEN\" in env:\n            del env[\"GNOME_TERMINAL_SCREEN\"]\n        creationflags = 0\n        if sys.platform == \"win32\":\n            creationflags = (\n                subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS\n            )\n        # On Windows 10, changing the handles breaks ANSI display\n        io = None if sys.platform == \"win32\" else subprocess.DEVNULL\n\n        subprocess.Popen(\n            [launcher],\n            start_new_session=True,\n            stdin=io,\n            stdout=io,\n            stderr=io,\n            env=env,\n            creationflags=creationflags,\n        )\n\n    mw.app.quit()\n"
  },
  {
    "path": "qt/aqt/preferences.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport functools\nimport re\nfrom collections.abc import Callable\n\nimport anki.lang\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.collection import OpChanges\nfrom anki.utils import is_mac\nfrom aqt import AnkiQt\nfrom aqt.ankihub import ankihub_login, ankihub_logout\nfrom aqt.operations.collection import set_preferences\nfrom aqt.profiles import VideoDriver\nfrom aqt.qt import *\nfrom aqt.sync import sync_login\nfrom aqt.theme import Theme\nfrom aqt.url_schemes import show_url_schemes_dialog\nfrom aqt.utils import (\n    HelpPage,\n    add_ellipsis_to_action_label,\n    askUser,\n    disable_help_button,\n    is_win,\n    openHelp,\n    showInfo,\n    showWarning,\n    tr,\n)\n\n\nclass Preferences(QDialog):\n    def __init__(self, mw: AnkiQt) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        self.mw = mw\n        self.prof = self.mw.pm.profile\n        self.form = aqt.forms.preferences.Ui_Preferences()\n        self.form.setupUi(self)\n        for spinbox in (\n            self.form.lrnCutoff,\n            self.form.dayOffset,\n            self.form.timeLimit,\n            self.form.network_timeout,\n        ):\n            spinbox.setSuffix(f\" {spinbox.suffix()}\")\n\n        disable_help_button(self)\n        help_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help)\n        assert help_button is not None\n        help_button.setAutoDefault(False)\n\n        close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)\n        assert close_button is not None\n        close_button.setAutoDefault(False)\n\n        qconnect(\n            self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)\n        )\n        self.silentlyClose = True\n        self.setup_collection()\n        self.setup_profile()\n        self.setup_global()\n        self.setup_configurable_answer_keys()\n        self.show()\n\n    def setup_configurable_answer_keys(self):\n        \"\"\"\n        Create a group box in Preferences with widgets that let the user edit answer keys.\n        \"\"\"\n        ease_labels = (\n            (1, tr.studying_again()),\n            (2, tr.studying_hard()),\n            (3, tr.studying_good()),\n            (4, tr.studying_easy()),\n        )\n        group = self.form.preferences_answer_keys\n        group.setLayout(layout := QFormLayout())\n        tab_widget: QWidget = self.form.url_schemes\n        for ease, label in ease_labels:\n            layout.addRow(\n                label,\n                line_edit := QLineEdit(self.mw.pm.get_answer_key(ease) or \"\"),\n            )\n            QWidget.setTabOrder(tab_widget, line_edit)\n            tab_widget = line_edit\n            qconnect(\n                line_edit.textChanged,\n                functools.partial(self.mw.pm.set_answer_key, ease),\n            )\n            line_edit.setPlaceholderText(tr.preferences_shortcut_placeholder())\n\n    def accept(self) -> None:\n        self.accept_with_callback()\n\n    def accept_with_callback(self, callback: Callable[[], None] | None = None) -> None:\n        # avoid exception if main window is already closed\n        if not self.mw.col:\n            return\n\n        def after_collection_update() -> None:\n            self.update_profile()\n            self.update_global()\n            self.mw.pm.save()\n            self.done(0)\n            aqt.dialogs.markClosed(\"Preferences\")\n\n            if callback:\n                callback()\n\n        self.update_collection(after_collection_update)\n\n    def reject(self) -> None:\n        self.accept()\n\n    # Preferences stored in the collection\n    ######################################################################\n\n    def setup_collection(self) -> None:\n        self.prefs = self.mw.col.get_preferences()\n\n        form = self.form\n\n        scheduling = self.prefs.scheduling\n\n        form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0))\n        form.dayOffset.setValue(scheduling.rollover)\n\n        reviewing = self.prefs.reviewing\n        form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0))\n        form.showEstimates.setChecked(reviewing.show_intervals_on_buttons)\n        form.showProgress.setChecked(reviewing.show_remaining_due_counts)\n        form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)\n        form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)\n\n        editing = self.prefs.editing\n        form.useCurrent.setCurrentIndex(\n            0 if editing.adding_defaults_to_current_deck else 1\n        )\n        form.paste_strips_formatting.setChecked(editing.paste_strips_formatting)\n        form.ignore_accents_in_search.setChecked(editing.ignore_accents_in_search)\n        form.pastePNG.setChecked(editing.paste_images_as_png)\n        form.render_latex.setChecked(editing.render_latex)\n        form.default_search_text.setText(editing.default_search_text)\n\n        form.backup_explanation.setText(\n            anki.lang.with_collapsed_whitespace(tr.preferences_backup_explanation())\n        )\n        form.daily_backups.setValue(self.prefs.backups.daily)\n        form.weekly_backups.setValue(self.prefs.backups.weekly)\n        form.monthly_backups.setValue(self.prefs.backups.monthly)\n        form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins)\n\n        add_ellipsis_to_action_label(self.form.url_schemes)\n        qconnect(self.form.url_schemes.clicked, show_url_schemes_dialog)\n\n    def update_collection(self, on_done: Callable[[], None]) -> None:\n        form = self.form\n\n        scheduling = self.prefs.scheduling\n        scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60\n        scheduling.rollover = form.dayOffset.value()\n\n        reviewing = self.prefs.reviewing\n        reviewing.show_remaining_due_counts = form.showProgress.isChecked()\n        reviewing.show_intervals_on_buttons = form.showEstimates.isChecked()\n        reviewing.time_limit_secs = form.timeLimit.value() * 60\n        reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()\n        reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()\n\n        editing = self.prefs.editing\n        editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()\n        editing.paste_images_as_png = self.form.pastePNG.isChecked()\n        editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked()\n        editing.render_latex = self.form.render_latex.isChecked()\n        editing.default_search_text = self.form.default_search_text.text()\n        editing.ignore_accents_in_search = (\n            self.form.ignore_accents_in_search.isChecked()\n        )\n\n        self.prefs.backups.daily = form.daily_backups.value()\n        self.prefs.backups.weekly = form.weekly_backups.value()\n        self.prefs.backups.monthly = form.monthly_backups.value()\n        self.prefs.backups.minimum_interval_mins = form.minutes_between_backups.value()\n\n        def after_prefs_update(changes: OpChanges) -> None:\n            self.mw.apply_collection_options()\n            on_done()\n\n        set_preferences(parent=self, preferences=self.prefs).success(\n            after_prefs_update\n        ).run_in_background()\n\n    # Preferences stored in the profile\n    ######################################################################\n\n    def setup_profile(self) -> None:\n        \"Setup options stored in the user profile.\"\n        self.setup_network()\n\n    def update_profile(self) -> None:\n        self.update_network()\n\n    # Profile: network\n    ######################################################################\n\n    def setup_network(self) -> None:\n        self.form.media_log.setText(tr.sync_media_log_button())\n        qconnect(self.form.media_log.clicked, self.on_media_log)\n        self.form.syncOnProgramOpen.setChecked(self.mw.pm.auto_syncing_enabled())\n        self.form.syncMedia.setChecked(self.mw.pm.media_syncing_enabled())\n        self.form.autoSyncMedia.setChecked(\n            self.mw.pm.periodic_sync_media_minutes() != 0\n        )\n        self.form.custom_sync_url.setText(self.mw.pm.custom_sync_url())\n        self.form.network_timeout.setValue(self.mw.pm.network_timeout())\n\n        self.form.check_for_updates.setChecked(self.mw.pm.check_for_updates())\n        qconnect(self.form.check_for_updates.stateChanged, self.mw.pm.set_update_check)\n\n        self.form.check_for_addon_updates.setChecked(\n            self.mw.pm.check_for_addon_updates()\n        )\n        qconnect(\n            self.form.check_for_addon_updates.stateChanged,\n            self.mw.pm.set_check_for_addon_updates,\n        )\n\n        self.update_login_status()\n        qconnect(self.form.syncLogout.clicked, self.sync_logout)\n        qconnect(self.form.syncLogin.clicked, self.sync_login)\n        qconnect(self.form.syncAnkiHubLogout.clicked, self.ankihub_sync_logout)\n        qconnect(self.form.syncAnkiHubLogin.clicked, self.ankihub_sync_login)\n\n    def update_login_status(self) -> None:\n        assert self.prof is not None\n        if not self.prof.get(\"syncKey\"):\n            self.form.syncUser.setText(tr.preferences_ankiweb_intro())\n            self.form.syncLogin.setVisible(True)\n            self.form.syncLogout.setVisible(False)\n        else:\n            self.form.syncUser.setText(self.prof.get(\"syncUser\", \"\"))\n            self.form.syncLogin.setVisible(False)\n            self.form.syncLogout.setVisible(True)\n\n        if not self.mw.pm.ankihub_token():\n            self.form.syncAnkiHubUser.setText(tr.preferences_ankihub_intro())\n            self.form.syncAnkiHubLogin.setVisible(True)\n            self.form.syncAnkiHubLogout.setVisible(False)\n        else:\n            self.form.syncAnkiHubUser.setText(self.mw.pm.ankihub_username())\n            self.form.syncAnkiHubLogin.setVisible(False)\n            self.form.syncAnkiHubLogout.setVisible(True)\n\n    def on_media_log(self) -> None:\n        self.mw.media_syncer.show_sync_log()\n\n    def sync_login(self) -> None:\n        def on_success():\n            assert self.prof is not None\n            if self.prof.get(\"syncKey\"):\n                self.update_login_status()\n                self.confirm_sync_after_login()\n\n        self.update_network()\n        sync_login(self.mw, on_success)\n\n    def sync_logout(self) -> None:\n        if self.mw.media_syncer.is_syncing():\n            showWarning(\"Can't log out while sync in progress.\")\n            return\n        assert self.prof is not None\n        self.prof[\"syncKey\"] = None\n        self.mw.col.media.force_resync()\n        self.update_login_status()\n\n    def ankihub_sync_login(self) -> None:\n        def on_success():\n            if self.mw.pm.ankihub_token():\n                self.update_login_status()\n\n        ankihub_login(self.mw, on_success)\n\n    def ankihub_sync_logout(self) -> None:\n        ankihub_token = self.mw.pm.ankihub_token()\n        if ankihub_token is None:\n            return\n        ankihub_logout(self.mw, self.update_login_status, ankihub_token)\n\n    def confirm_sync_after_login(self) -> None:\n        from aqt import mw\n\n        if askUser(tr.preferences_login_successful_sync_now(), parent=mw):\n            self.accept_with_callback(self.mw.on_sync_button_clicked)\n\n    def update_network(self) -> None:\n        assert self.prof is not None\n        self.prof[\"autoSync\"] = self.form.syncOnProgramOpen.isChecked()\n        self.prof[\"syncMedia\"] = self.form.syncMedia.isChecked()\n        self.mw.pm.set_periodic_sync_media_minutes(\n            self.form.autoSyncMedia.isChecked() and 15 or 0\n        )\n        if self.form.fullSync.isChecked():\n            self.mw.col.mod_schema(check=False)\n        self.mw.pm.set_custom_sync_url(self.form.custom_sync_url.text())\n        self.mw.pm.set_network_timeout(self.form.network_timeout.value())\n\n    # Global preferences\n    ######################################################################\n\n    def setup_global(self) -> None:\n        \"Setup options global to all profiles.\"\n        self.form.reduce_motion.setChecked(self.mw.pm.reduce_motion())\n        qconnect(self.form.reduce_motion.stateChanged, self.mw.pm.set_reduce_motion)\n\n        self.form.minimalist_mode.setChecked(self.mw.pm.minimalist_mode())\n        qconnect(self.form.minimalist_mode.stateChanged, self.mw.pm.set_minimalist_mode)\n\n        self.form.spacebar_rates_card.setChecked(self.mw.pm.spacebar_rates_card())\n        qconnect(\n            self.form.spacebar_rates_card.stateChanged,\n            self.mw.pm.set_spacebar_rates_card,\n        )\n\n        hide_choices = [tr.preferences_full_screen_only(), tr.preferences_always()]\n\n        self.form.hide_top_bar.setChecked(self.mw.pm.hide_top_bar())\n        qconnect(self.form.hide_top_bar.stateChanged, self.mw.pm.set_hide_top_bar)\n        qconnect(\n            self.form.hide_top_bar.stateChanged,\n            self.form.topBarComboBox.setVisible,\n        )\n        self.form.topBarComboBox.addItems(hide_choices)\n        self.form.topBarComboBox.setCurrentIndex(self.mw.pm.top_bar_hide_mode())\n        self.form.topBarComboBox.setVisible(self.form.hide_top_bar.isChecked())\n\n        qconnect(\n            self.form.topBarComboBox.currentIndexChanged,\n            self.mw.pm.set_top_bar_hide_mode,\n        )\n\n        self.form.hide_bottom_bar.setChecked(self.mw.pm.hide_bottom_bar())\n        qconnect(self.form.hide_bottom_bar.stateChanged, self.mw.pm.set_hide_bottom_bar)\n        qconnect(\n            self.form.hide_bottom_bar.stateChanged,\n            self.form.bottomBarComboBox.setVisible,\n        )\n        self.form.bottomBarComboBox.addItems(hide_choices)\n        self.form.bottomBarComboBox.setCurrentIndex(self.mw.pm.bottom_bar_hide_mode())\n        self.form.bottomBarComboBox.setVisible(self.form.hide_bottom_bar.isChecked())\n\n        qconnect(\n            self.form.bottomBarComboBox.currentIndexChanged,\n            self.mw.pm.set_bottom_bar_hide_mode,\n        )\n\n        self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))\n        themes = [\n            tr.preferences_theme_follow_system(),\n            tr.preferences_theme_light(),\n            tr.preferences_theme_dark(),\n        ]\n        self.form.theme.addItems(themes)\n        self.form.theme.setCurrentIndex(self.mw.pm.theme().value)\n        qconnect(self.form.theme.currentIndexChanged, self.on_theme_changed)\n\n        self.form.styleComboBox.addItems([\"Anki\"] + ([\"Native\"] if not is_win else []))\n        self.form.styleComboBox.setCurrentIndex(self.mw.pm.get_widget_style())\n        qconnect(\n            self.form.styleComboBox.currentIndexChanged,\n            self.mw.pm.set_widget_style,\n        )\n        self.form.styleLabel.setVisible(not is_win)\n        self.form.styleComboBox.setVisible(not is_win)\n        qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes)\n\n        self.setup_language()\n        self.setup_video_driver()\n\n        self.setupOptions()\n\n    def update_global(self) -> None:\n        restart_required = False\n\n        self.update_video_driver()\n\n        newScale = self.form.uiScale.value() / 100\n        if newScale != self.mw.pm.uiScale():\n            self.mw.pm.setUiScale(newScale)\n            restart_required = True\n\n        if restart_required:\n            showInfo(tr.preferences_changes_will_take_effect_when_you())\n\n        self.updateOptions()\n\n    def on_theme_changed(self, index: int) -> None:\n        self.mw.set_theme(Theme(index))\n\n    def on_reset_window_sizes(self) -> None:\n        assert self.prof is not None\n        regexp = re.compile(r\"(Geom(etry)?|State|Splitter|Header)(\\d+.\\d+)?$\")\n        for key in list(self.prof.keys()):\n            if regexp.search(key):\n                del self.prof[key]\n        showInfo(tr.preferences_reset_window_sizes_complete())\n\n    # legacy - one of Henrik's add-ons is currently wrapping them\n\n    def setupOptions(self) -> None:\n        pass\n\n    def updateOptions(self) -> None:\n        pass\n\n    # Global: language\n    ######################################################################\n\n    def setup_language(self) -> None:\n        f = self.form\n        f.lang.addItems([x[0] for x in anki.lang.langs])\n        f.lang.setCurrentIndex(self.current_lang_index())\n        qconnect(f.lang.currentIndexChanged, self.on_language_index_changed)\n\n    def current_lang_index(self) -> int:\n        codes = [x[1] for x in anki.lang.langs]\n        lang = anki.lang.current_lang\n        if lang in anki.lang.compatMap:\n            lang = anki.lang.compatMap[lang]\n        else:\n            lang = lang.replace(\"-\", \"_\")\n        try:\n            return codes.index(lang)\n        except Exception:\n            return codes.index(\"en_US\")\n\n    def on_language_index_changed(self, idx: int) -> None:\n        code = anki.lang.langs[idx][1]\n        self.mw.pm.setLang(code)\n        showInfo(tr.preferences_please_restart_anki_to_complete_language(), parent=self)\n\n    # Global: video driver\n    ######################################################################\n\n    def setup_video_driver(self) -> None:\n        self.video_drivers = VideoDriver.all_for_platform()\n        names = [video_driver_name_for_platform(d) for d in self.video_drivers]\n        self.form.video_driver.addItems(names)\n        self.form.video_driver.setCurrentIndex(\n            self.video_drivers.index(self.mw.pm.video_driver())\n        )\n\n    def update_video_driver(self) -> None:\n        new_driver = self.video_drivers[self.form.video_driver.currentIndex()]\n        if new_driver != self.mw.pm.video_driver():\n            self.mw.pm.set_video_driver(new_driver)\n            showInfo(tr.preferences_changes_will_take_effect_when_you())\n\n\ndef video_driver_name_for_platform(driver: VideoDriver) -> str:\n    if qtmajor < 6:\n        if driver == VideoDriver.ANGLE:\n            return tr.preferences_video_driver_angle()\n        elif driver == VideoDriver.Software:\n            if is_mac:\n                return tr.preferences_video_driver_software_mac()\n            else:\n                return tr.preferences_video_driver_software_other()\n        elif driver == VideoDriver.OpenGL:\n            if is_mac:\n                return tr.preferences_video_driver_opengl_mac()\n            else:\n                return tr.preferences_video_driver_opengl_other()\n\n    label = driver.name\n    if driver == VideoDriver.default_for_platform():\n        label += f\" ({tr.preferences_video_driver_default()})\"\n\n    return label\n"
  },
  {
    "path": "qt/aqt/profiles.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport errno\nimport io\nimport os\nimport pickle\nimport random\nimport shutil\nimport traceback\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nimport anki.lang\nimport aqt.forms\nimport aqt.sound\nfrom anki._legacy import deprecated\nfrom anki.collection import Collection\nfrom anki.db import DB\nfrom anki.lang import without_unicode_isolation\nfrom anki.sync import SyncAuth\nfrom anki.utils import int_time, int_version, is_mac, is_win\nfrom aqt import appHelpSite, gui_hooks\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.theme import Theme, WidgetStyle, theme_manager\nfrom aqt.toolbar import HideMode\nfrom aqt.utils import disable_help_button, send_to_trash, showWarning, tr\n\nif TYPE_CHECKING:\n    from aqt.browser.layout import BrowserLayout\n    from aqt.editor import EditorMode\n\n\n# Profile handling\n##########################################################################\n# - Saves in pickles rather than json to easily store Qt window state.\n# - Saves in sqlite rather than a flat file so the config can't be corrupted\n\n\nclass VideoDriver(Enum):\n    OpenGL = \"auto\"\n    ANGLE = \"angle\"\n    Software = \"software\"\n    Metal = \"metal\"\n    Vulkan = \"vulkan\"\n    Direct3D = \"d3d11\"\n\n    @staticmethod\n    def default_for_platform() -> VideoDriver:\n        return VideoDriver.all_for_platform()[0]\n\n    def constrained_to_platform(self) -> VideoDriver:\n        if self not in VideoDriver.all_for_platform():\n            return VideoDriver.default_for_platform()\n        return self\n\n    def next(self) -> VideoDriver:\n        all = VideoDriver.all_for_platform()\n        try:\n            idx = (all.index(self) + 1) % len(all)\n        except ValueError:\n            idx = 0\n        return all[idx]\n\n    @staticmethod\n    def all_for_platform() -> list[VideoDriver]:\n        all = []\n        if qtmajor > 5:\n            if is_win:\n                all.append(VideoDriver.Direct3D)\n            if is_mac:\n                all.append(VideoDriver.Metal)\n        all.append(VideoDriver.OpenGL)\n        if qtmajor > 5 and not is_mac:\n            all.append(VideoDriver.Vulkan)\n        if is_win and qtmajor < 6:\n            all.append(VideoDriver.ANGLE)\n        all.append(VideoDriver.Software)\n\n        return all\n\n\nmetaConf = dict(\n    ver=0,\n    updates=True,\n    created=int_time(),\n    id=random.randrange(0, 2**63),\n    lastMsg=0,\n    suppressUpdate=False,\n    firstRun=True,\n    defaultLang=None,\n)\n\n# Old Anki versions expected these keys to exist. Don't add new ones here - it's better practice\n# to always use profile.get(..., defaultValue) instead, as keys may be missing.\nprofileConf: dict[str, Any] = dict(\n    # profile\n    mainWindowGeom=None,\n    mainWindowState=None,\n    numBackups=50,\n    lastOptimize=int_time(),\n    # editing\n    searchHistory=[],\n    # syncing\n    syncKey=None,\n    syncMedia=True,\n    autoSync=True,\n    # importing\n    allowHTML=False,\n    importMode=1,\n    # these are not used, but Anki 2.1.42 and below\n    # expect these keys to exist\n    lastColour=\"#00f\",\n    stripHTML=True,\n    deleteMedia=False,\n)\n\n\nclass LoadMetaResult:\n    firstTime: bool\n    loadError: bool\n\n\nclass ProfileManager:\n    default_answer_keys = {ease_num: str(ease_num) for ease_num in range(1, 5)}\n    last_run_version: int = 0\n\n    def __init__(self, base: Path) -> None:\n        \"base should be retrieved via ProfileMangager.get_created_base_folder\"\n        ## Settings which should be forgotten each Anki restart\n        self.session: dict[str, Any] = {}\n        self.name: str | None = None\n        self.db: DB | None = None\n        self.profile: dict | None = None\n        self.invalid_profile_provided_on_commandline = False\n        self.base = str(base)\n\n    def setupMeta(self) -> LoadMetaResult:\n        # load metadata\n        res = self._loadMeta()\n        self.firstRun = res.firstTime\n        self.last_run_version = self.meta.get(\"last_run_version\", self.last_run_version)\n        self.meta[\"last_run_version\"] = int_version()\n        return res\n\n    # -p profile provided on command line.\n    def openProfile(self, profile: str) -> None:\n        if profile not in self.profiles():\n            self.invalid_profile_provided_on_commandline = True\n        else:\n            try:\n                self.load(profile)\n            except Exception:\n                self.invalid_profile_provided_on_commandline = True\n\n    # Profile load/save\n    ######################################################################\n\n    def profiles(self) -> list[str]:\n        def names() -> list[str]:\n            return self.db.list(\"select name from profiles where name != '_global'\")\n\n        n = names()\n        if not n:\n            self._ensureProfile()\n            n = names()\n\n        return n\n\n    def _unpickle(self, data: bytes) -> Any:\n        class Unpickler(pickle.Unpickler):\n            def find_class(self, class_module: str, name: str) -> Any:\n                # handle sip lookup ourselves, mapping to current Qt version\n                if class_module == \"sip\" or class_module.endswith(\".sip\"):\n\n                    def unpickle_type(module: str, klass: str, args: Any) -> Any:\n                        if qtmajor > 5:\n                            module = module.replace(\"Qt5\", \"Qt6\")\n                        else:\n                            module = module.replace(\"Qt6\", \"Qt5\")\n                        if klass == \"QByteArray\":\n                            if module.startswith(\"PyQt4\"):\n                                # can't trust str objects from python 2\n                                return QByteArray()\n                            else:\n                                # return the bytes directly\n                                return args[0]\n                        elif name == \"_unpickle_enum\":\n                            # old style enums can't be unpickled\n                            return None\n                        else:\n                            return sip._unpickle_type(module, klass, args)  # type: ignore\n\n                    return unpickle_type\n                else:\n                    return super().find_class(class_module, name)\n\n        up = Unpickler(io.BytesIO(data), errors=\"ignore\")\n        return up.load()\n\n    def _pickle(self, obj: Any) -> bytes:\n        for key, val in obj.items():\n            if isinstance(val, QByteArray):\n                obj[key] = bytes(val)  # type: ignore\n\n        return pickle.dumps(obj, protocol=4)\n\n    def load(self, name: str) -> bool:\n        if name == \"_global\":\n            raise Exception(\"_global is not a valid name\")\n        data = self.db.scalar(\n            \"select cast(data as blob) from profiles where name = ? collate nocase\",\n            name,\n        )\n        self.name = name\n        try:\n            self.profile = self._unpickle(data)\n        except Exception:\n            print(traceback.format_exc())\n            QMessageBox.warning(\n                None,\n                tr.profiles_profile_corrupt(),\n                tr.profiles_anki_could_not_read_your_profile(),\n            )\n            print(\"resetting corrupt profile\")\n            self.profile = profileConf.copy()\n            self.save()\n        self.set_last_loaded_profile_name(name)\n        return True\n\n    def save(self) -> None:\n        sql = \"update profiles set data = ? where name = ? collate nocase\"\n        self.db.execute(sql, self._pickle(self.profile), self.name)\n        self.db.execute(sql, self._pickle(self.meta), \"_global\")\n        self.db.commit()\n\n    def create(self, name: str) -> None:\n        prof = profileConf.copy()\n        if self.db.scalar(\"select 1 from profiles where name = ? collate nocase\", name):\n            return\n        self.db.execute(\n            \"insert or ignore into profiles values (?, ?)\",\n            name,\n            self._pickle(prof),\n        )\n        self.db.commit()\n\n    def remove(self, name: str) -> None:\n        path = self.profileFolder(create=False)\n        send_to_trash(Path(path))\n        self.db.execute(\"delete from profiles where name = ? collate nocase\", name)\n        self.db.commit()\n\n    def trashCollection(self) -> None:\n        path = self.collectionPath()\n        send_to_trash(Path(path))\n\n    def rename(self, name: str) -> None:\n        oldName = self.name\n        oldFolder = self.profileFolder()\n        self.name = name\n        newFolder = self.profileFolder(create=False)\n        if os.path.exists(newFolder):\n            if (oldFolder != newFolder) and (oldFolder.lower() == newFolder.lower()):\n                # OS is telling us the folder exists because it does not take\n                # case into account; use a temporary folder location\n                midFolder = \"\".join([oldFolder, \"-temp\"])\n                if not os.path.exists(midFolder):\n                    os.rename(oldFolder, midFolder)\n                    oldFolder = midFolder\n                else:\n                    showWarning(tr.profiles_please_remove_the_folder_and(val=midFolder))\n                    self.name = oldName\n                    return\n            else:\n                showWarning(tr.profiles_folder_already_exists())\n                self.name = oldName\n                return\n\n        # update name\n        self.db.execute(\n            \"update profiles set name = ? where name = ? collate nocase\", name, oldName\n        )\n        # rename folder\n        try:\n            os.rename(oldFolder, newFolder)\n        except Exception as e:\n            self.db.rollback()\n            if \"WinError 5\" in str(e):\n                showWarning(tr.profiles_anki_could_not_rename_your_profile())\n            elif isinstance(e, OSError) and e.errno == errno.ENAMETOOLONG:\n                showWarning(tr.profiles_anki_could_not_rename_your_profile())\n            else:\n                raise\n        except BaseException:\n            self.db.rollback()\n            raise\n        else:\n            self.db.commit()\n\n    # Folder handling\n    ######################################################################\n\n    def profileFolder(self, create: bool = True) -> str:\n        path = os.path.join(self.base, self.name)\n        if create:\n            self._ensureExists(path)\n        return path\n\n    def addonFolder(self) -> str:\n        return self._ensureExists(os.path.join(self.base, \"addons21\"))\n\n    def backupFolder(self) -> str:\n        return self._ensureExists(os.path.join(self.profileFolder(), \"backups\"))\n\n    def collectionPath(self) -> str:\n        return os.path.join(self.profileFolder(), \"collection.anki2\")\n\n    def addon_logs(self) -> str:\n        return self._ensureExists(os.path.join(self.base, \"logs\"))\n\n    # Downgrade\n    ######################################################################\n\n    def downgrade(self, profiles: list[str]) -> list[str]:\n        \"Downgrade all profiles. Return a list of profiles that couldn't be opened.\"\n        problem_profiles = []\n        for name in profiles:\n            path = os.path.join(self.base, name, \"collection.anki2\")\n            if not os.path.exists(path):\n                continue\n            with DB(path) as db:\n                if db.scalar(\"select ver from col\") == 11:\n                    # nothing to do\n                    continue\n            try:\n                c = Collection(path)\n                c.close(downgrade=True)\n            except Exception as e:\n                print(e)\n                problem_profiles.append(name)\n        return problem_profiles\n\n    # Helpers\n    ######################################################################\n\n    def _ensureExists(self, path: str) -> str:\n        if not os.path.exists(path):\n            os.makedirs(path)\n        return path\n\n    @staticmethod\n    def get_created_base_folder(path_override: str | None) -> Path:\n        \"Create the base folder and return it, using provided path or default.\"\n        path = Path(\n            path_override\n            or os.environ.get(\"ANKI_BASE\")\n            or ProfileManager._default_base()\n        )\n        path.mkdir(parents=True, exist_ok=True)\n        return path.resolve()\n\n    @staticmethod\n    def _default_base() -> str:\n        if is_win:\n            from aqt.winpaths import get_appdata\n\n            return os.path.join(get_appdata(), \"Anki2\")\n        elif is_mac:\n            return os.path.expanduser(\"~/Library/Application Support/Anki2\")\n        else:\n            dataDir = os.environ.get(\n                \"XDG_DATA_HOME\", os.path.expanduser(\"~/.local/share\")\n            )\n            if not os.path.exists(dataDir):\n                os.makedirs(dataDir)\n            return os.path.join(dataDir, \"Anki2\")\n\n    def _loadMeta(self, retrying: bool = False) -> LoadMetaResult:\n        result = LoadMetaResult()\n        result.firstTime = False\n        result.loadError = retrying\n\n        opath = os.path.join(self.base, \"prefs.db\")\n        path = os.path.join(self.base, \"prefs21.db\")\n        if not retrying and os.path.exists(opath) and not os.path.exists(path):\n            shutil.copy(opath, path)\n\n        result.firstTime = not os.path.exists(path)\n\n        def recover() -> None:\n            # if we can't load profile, start with a new one\n            if self.db:\n                try:\n                    self.db.close()\n                except Exception:\n                    pass\n            for suffix in (\"\", \"-journal\"):\n                fpath = path + suffix\n                if os.path.exists(fpath):\n                    os.unlink(fpath)\n\n        # open DB file and read data\n        try:\n            self.db = DB(path)\n            if not self.db.scalar(\"pragma integrity_check\") == \"ok\":\n                raise Exception(\"corrupt db\")\n            self.db.execute(\n                \"\"\"\ncreate table if not exists profiles\n(name text primary key collate nocase, data blob not null);\"\"\"\n            )\n            data = self.db.scalar(\n                \"select cast(data as blob) from profiles where name = '_global'\"\n            )\n        except Exception:\n            traceback.print_stack()\n            if result.loadError:\n                # already failed, prevent infinite loop\n                raise\n            # delete files and try again\n            recover()\n            return self._loadMeta(retrying=True)\n\n        # try to read data\n        if not result.firstTime:\n            try:\n                self.meta = self._unpickle(data)\n                return result\n            except Exception:\n                traceback.print_stack()\n                print(\"resetting corrupt _global\")\n                result.loadError = True\n                result.firstTime = True\n\n        # if new or read failed, create a default global profile\n        self.meta = metaConf.copy()\n        self.db.execute(\n            \"insert or replace into profiles values ('_global', ?)\",\n            self._pickle(metaConf),\n        )\n        return result\n\n    def _ensureProfile(self) -> None:\n        \"Create a new profile if none exists.\"\n        self.create(tr.profiles_user_1())\n        p = os.path.join(self.base, \"README.txt\")\n        with open(p, \"w\", encoding=\"utf8\") as file:\n            file.write(\n                without_unicode_isolation(\n                    tr.profiles_folder_readme(\n                        link=f\"{appHelpSite}files#startup-options\",\n                    )\n                )\n                + \"\\n\"\n            )\n\n    # Default language\n    ######################################################################\n    # On first run, allow the user to choose the default language\n\n    def setDefaultLang(self, idx: int) -> None:\n        # create dialog\n        class NoCloseDiag(QDialog):\n            def reject(self) -> None:\n                pass\n\n        d = self.langDiag = NoCloseDiag()\n        f = self.langForm = aqt.forms.setlang.Ui_Dialog()\n        f.setupUi(d)\n        disable_help_button(d)\n        qconnect(d.accepted, self._onLangSelected)\n        qconnect(d.rejected, lambda: True)\n        # update list\n        f.lang.addItems([x[0] for x in anki.lang.langs])\n        f.lang.setCurrentRow(idx)\n        d.exec()\n\n    def _onLangSelected(self) -> None:\n        f = self.langForm\n        obj = anki.lang.langs[f.lang.currentRow()]\n        code = obj[1]\n        name = obj[0]\n        r = QMessageBox.question(\n            None,\n            \"Anki\",\n            tr.profiles_confirm_lang_choice(lang=name),\n            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n            QMessageBox.StandardButton.No,  # type: ignore\n        )\n        if r != QMessageBox.StandardButton.Yes:\n            return self.setDefaultLang(f.lang.currentRow())\n        self.setLang(code)\n\n    def setLang(self, code: str) -> None:\n        self.meta[\"defaultLang\"] = code\n        sql = \"update profiles set data = ? where name = ? collate nocase\"\n        self.db.execute(sql, self._pickle(self.meta), \"_global\")\n        self.db.commit()\n        anki.lang.set_lang(code)\n\n    # OpenGL\n    ######################################################################\n\n    def _gldriver_path(self) -> str:\n        if qtmajor < 6:\n            fname = \"gldriver\"\n        else:\n            fname = \"gldriver6\"\n        return os.path.join(self.base, fname)\n\n    def video_driver(self) -> VideoDriver:\n        path = self._gldriver_path()\n        try:\n            with open(path, encoding=\"utf8\") as file:\n                text = file.read().strip()\n                return VideoDriver(text).constrained_to_platform()\n        except (ValueError, OSError):\n            return VideoDriver.default_for_platform()\n\n    def set_video_driver(self, driver: VideoDriver) -> None:\n        with open(self._gldriver_path(), \"w\", encoding=\"utf8\") as file:\n            file.write(driver.value)\n\n    def set_next_video_driver(self) -> None:\n        self.set_video_driver(self.video_driver().next())\n\n    # Shared options\n    ######################################################################\n\n    def uiScale(self) -> float:\n        scale = self.meta.get(\"uiScale\", 1.0)\n        return max(scale, 1)\n\n    def setUiScale(self, scale: float) -> None:\n        self.meta[\"uiScale\"] = scale\n\n    def reduce_motion(self) -> bool:\n        return self.meta.get(\"reduce_motion\", True)\n\n    def set_reduce_motion(self, on: bool) -> None:\n        self.meta[\"reduce_motion\"] = on\n        gui_hooks.body_classes_need_update()\n\n    def minimalist_mode(self) -> bool:\n        return self.meta.get(\"minimalist_mode\", False)\n\n    def set_minimalist_mode(self, on: bool) -> None:\n        self.meta[\"minimalist_mode\"] = on\n        gui_hooks.body_classes_need_update()\n\n    def spacebar_rates_card(self) -> bool:\n        return self.meta.get(\"spacebar_rates_card\", True)\n\n    def set_spacebar_rates_card(self, on: bool) -> None:\n        self.meta[\"spacebar_rates_card\"] = on\n\n    def get_answer_key(self, ease: int) -> str | None:\n        return self.meta.setdefault(\"answer_keys\", self.default_answer_keys).get(ease)\n\n    def set_answer_key(self, ease: int, key: str):\n        self.meta.setdefault(\"answer_keys\", self.default_answer_keys)[ease] = key\n\n    def hide_top_bar(self) -> bool:\n        return self.meta.get(\"hide_top_bar\", False)\n\n    def set_hide_top_bar(self, on: bool) -> None:\n        self.meta[\"hide_top_bar\"] = on\n        gui_hooks.body_classes_need_update()\n\n    def top_bar_hide_mode(self) -> HideMode:\n        return self.meta.get(\"top_bar_hide_mode\", HideMode.FULLSCREEN)\n\n    def set_top_bar_hide_mode(self, mode: HideMode) -> None:\n        self.meta[\"top_bar_hide_mode\"] = mode\n        gui_hooks.body_classes_need_update()\n\n    def hide_bottom_bar(self) -> bool:\n        return self.meta.get(\"hide_bottom_bar\", False)\n\n    def set_hide_bottom_bar(self, on: bool) -> None:\n        self.meta[\"hide_bottom_bar\"] = on\n        gui_hooks.body_classes_need_update()\n\n    def bottom_bar_hide_mode(self) -> HideMode:\n        return self.meta.get(\"bottom_bar_hide_mode\", HideMode.FULLSCREEN)\n\n    def set_bottom_bar_hide_mode(self, mode: HideMode) -> None:\n        self.meta[\"bottom_bar_hide_mode\"] = mode\n        gui_hooks.body_classes_need_update()\n\n    def last_addon_update_check(self) -> int:\n        return self.meta.get(\"last_addon_update_check\", 0)\n\n    def set_last_addon_update_check(self, secs: int) -> None:\n        self.meta[\"last_addon_update_check\"] = secs\n\n    def check_for_addon_updates(self) -> bool:\n        return self.meta.get(\"check_for_addon_updates\", True)\n\n    def set_check_for_addon_updates(self, on: bool) -> None:\n        self.meta[\"check_for_addon_updates\"] = on\n\n    @deprecated(info=\"use theme_manager.night_mode\")\n    def night_mode(self) -> bool:\n        return theme_manager.night_mode\n\n    def theme(self) -> Theme:\n        return Theme(self.meta.get(\"theme\", 0))\n\n    def set_theme(self, theme: Theme) -> None:\n        self.meta[\"theme\"] = theme.value\n\n    def set_widget_style(self, style: WidgetStyle) -> None:\n        self.meta[\"widget_style\"] = style\n        theme_manager.apply_style()\n\n    def get_widget_style(self) -> WidgetStyle:\n        return self.meta.get(\n            \"widget_style\", WidgetStyle.NATIVE if is_mac else WidgetStyle.ANKI\n        )\n\n    def browser_layout(self) -> BrowserLayout:\n        from aqt.browser.layout import BrowserLayout\n\n        return BrowserLayout(self.meta.get(\"browser_layout\", \"auto\"))\n\n    def set_browser_layout(self, layout: BrowserLayout) -> None:\n        self.meta[\"browser_layout\"] = layout.value\n\n    def editor_key(self, mode: EditorMode) -> str:\n        from aqt.editor import EditorMode\n\n        return {\n            EditorMode.ADD_CARDS: \"add\",\n            EditorMode.BROWSER: \"browser\",\n            EditorMode.EDIT_CURRENT: \"current\",\n        }[mode]\n\n    def tags_collapsed(self, mode: EditorMode) -> bool:\n        return self.meta.get(f\"{self.editor_key(mode)}TagsCollapsed\", False)\n\n    def set_tags_collapsed(self, mode: EditorMode, collapsed: bool) -> None:\n        self.meta[f\"{self.editor_key(mode)}TagsCollapsed\"] = collapsed\n\n    def legacy_import_export(self) -> bool:\n        \"Always returns False so users with this option enabled are not stuck on the legacy importer after the UI option is removed.\"\n        return False\n\n    def set_legacy_import_export(self, enabled: bool) -> None:\n        self.meta[\"legacy_import\"] = enabled\n\n    def last_loaded_profile_name(self) -> str | None:\n        return self.meta.get(\"last_loaded_profile_name\")\n\n    def set_last_loaded_profile_name(self, name: str) -> None:\n        self.meta[\"last_loaded_profile_name\"] = name\n\n    # Profile-specific\n    ######################################################################\n\n    def set_sync_key(self, val: str | None) -> None:\n        self.profile[\"syncKey\"] = val\n\n    def set_sync_username(self, val: str | None) -> None:\n        self.profile[\"syncUser\"] = val\n\n    def set_host_number(self, val: int | None) -> None:\n        self.profile[\"hostNum\"] = val or 0\n\n    def check_for_updates(self) -> bool:\n        return self.meta.get(\"check_for_updates\", True)\n\n    def set_update_check(self, on: bool) -> None:\n        self.meta[\"check_for_updates\"] = on\n\n    def media_syncing_enabled(self) -> bool:\n        return self.profile.get(\"syncMedia\", True)\n\n    def auto_syncing_enabled(self) -> bool:\n        \"True if syncing on startup/shutdown enabled.\"\n        return self.profile.get(\"autoSync\", True)\n\n    def sync_auth(self) -> SyncAuth | None:\n        if not (hkey := self.profile.get(\"syncKey\")):\n            return None\n        return SyncAuth(\n            hkey=hkey,\n            endpoint=self.sync_endpoint(),\n            io_timeout_secs=self.network_timeout(),\n        )\n\n    def clear_sync_auth(self) -> None:\n        self.set_sync_key(None)\n        self.set_sync_username(None)\n        self.set_host_number(None)\n        self.set_current_sync_url(None)\n\n    def sync_endpoint(self) -> str | None:\n        return self._current_sync_url() or self.custom_sync_url() or None\n\n    def _current_sync_url(self) -> str | None:\n        \"\"\"The last endpoint the server redirected us to.\"\"\"\n        return self.profile.get(\"currentSyncUrl\")\n\n    def set_current_sync_url(self, url: str | None) -> None:\n        self.profile[\"currentSyncUrl\"] = url\n\n    def middle_click_paste_enabled(self) -> bool:\n        return self.profile.get(\"middleClickPasteEnabled\", True)\n\n    def set_middle_click_paste_enabled(self, val: bool) -> None:\n        self.profile[\"middleClickPasteEnabled\"] = val\n\n    def custom_sync_url(self) -> str | None:\n        \"\"\"A custom server provided by the user.\"\"\"\n        return self.profile.get(\"customSyncUrl\")\n\n    def set_custom_sync_url(self, url: str | None) -> None:\n        if url != self.custom_sync_url():\n            self.set_current_sync_url(None)\n            self.profile[\"customSyncUrl\"] = url\n\n    def periodic_sync_media_minutes(self) -> int:\n        return self.profile.get(\"autoSyncMediaMinutes\", 15)\n\n    def set_periodic_sync_media_minutes(self, val: int) -> None:\n        self.profile[\"autoSyncMediaMinutes\"] = val\n\n    def show_browser_table_tooltips(self) -> bool:\n        return self.profile.get(\"browserTableTooltips\", True)\n\n    def set_show_browser_table_tooltips(self, val: bool) -> None:\n        self.profile[\"browserTableTooltips\"] = val\n\n    def set_network_timeout(self, timeout_secs: int) -> None:\n        self.profile[\"networkTimeout\"] = timeout_secs\n\n    def network_timeout(self) -> int:\n        return self.profile.get(\"networkTimeout\") or 60\n\n    def set_ankihub_token(self, val: str | None) -> None:\n        self.profile[\"thirdPartyAnkiHubToken\"] = val\n\n    def ankihub_token(self) -> str | None:\n        return self.profile.get(\"thirdPartyAnkiHubToken\")\n\n    def set_ankihub_username(self, val: str | None) -> None:\n        self.profile[\"thirdPartyAnkiHubUsername\"] = val\n\n    def ankihub_username(self) -> str | None:\n        return self.profile.get(\"thirdPartyAnkiHubUsername\")\n\n    def allowed_url_schemes(self) -> list[str]:\n        return self.profile.get(\"allowedUrlSchemes\", [])\n\n    def set_allowed_url_schemes(self, schemes: list[str]) -> None:\n        self.profile[\"allowedUrlSchemes\"] = schemes\n\n    def always_allow_scheme(self, scheme: str) -> None:\n        schemes = self.allowed_url_schemes()\n\n        if scheme not in schemes:\n            schemes.append(scheme)\n\n        self.set_allowed_url_schemes(schemes)\n"
  },
  {
    "path": "qt/aqt/progress.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom dataclasses import dataclass\n\nimport aqt.forms\nfrom anki._legacy import print_deprecation_warning\nfrom anki.collection import Progress\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.utils import disable_help_button, tr\n\n# Progress info\n##########################################################################\n\n\nclass ProgressManager:\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        self.mw = mw\n        self.app = mw.app\n        self.inDB = False\n        self.blockUpdates = False\n        self._show_timer: QTimer | None = None\n        self._busy_cursor_timer: QTimer | None = None\n        self._win: ProgressDialog | None = None\n        self._levels = 0\n        self._backend_timer: QTimer | None = None\n\n    # Safer timers\n    ##########################################################################\n    # Custom timers which avoid firing while a progress dialog is active\n    # (likely due to some long-running DB operation)\n\n    def timer(\n        self,\n        ms: int,\n        func: Callable,\n        repeat: bool,\n        requiresCollection: bool = True,\n        *,\n        parent: QObject | None = None,\n    ) -> QTimer:\n        \"\"\"Create and start a standard Anki timer. For an alternative see `single_shot()`.\n\n        If the timer fires while a progress window is shown:\n        - if it is a repeating timer, it will wait the same delay again\n        - if it is non-repeating, it will try again in 100ms\n\n        If requiresCollection is True, the timer will not fire if the\n        collection has been unloaded. Setting it to False will allow the\n        timer to fire even when there is no collection, but will still\n        only fire when there is no current progress dialog.\n\n\n        Issues and alternative\n        ---\n        The created timer will only be destroyed when `parent` is destroyed.\n        This can cause memory leaks, because anything captured by `func` isn't freed either.\n        If there is no QObject that will get destroyed reasonably soon, and you have to\n        pass `mw`, you should call `deleteLater()` on the returned QTimer as soon as\n        it's served its purpose, or use `single_shot()`.\n\n        Also note that you may not be able to pass an adequate parent, if you want to\n        make a callback after a widget closes. If you passed that widget, the timer\n        would get destroyed before it could fire.\n        \"\"\"\n\n        if parent is None:\n            print_deprecation_warning(\n                \"to avoid memory leaks, pass an appropriate parent to progress.timer()\"\n                \" or use progress.single_shot()\"\n            )\n            parent = self.mw\n\n        qtimer = QTimer(parent)\n        if not repeat:\n            qtimer.setSingleShot(True)\n        qconnect(qtimer.timeout, self._get_handler(func, repeat, requiresCollection))\n        qtimer.start(ms)\n        return qtimer\n\n    def single_shot(\n        self,\n        ms: int,\n        func: Callable[[], None],\n        requires_collection: bool = True,\n    ) -> None:\n        \"\"\"Create and start a one-off Anki timer. For an alternative and more\n        documentation, see `timer()`.\n\n\n        Issues and alternative\n        ---\n        `single_shot()` cleans itself up, so a passed closure won't leak any memory.\n        However, if `func` references a QObject other than `mw`, which gets deleted before the\n        timer fires, an Exception is raised. To avoid this, either use `timer()` passing\n        that object as the parent, or check in `func` with `sip.isdeleted(object)` if\n        it still exists.\n\n        On the other hand, if a widget is supposed to make an external callback after it closes,\n        you likely want to use `single_shot()`, which will fire even if the calling\n        widget is already destroyed.\n        \"\"\"\n        QTimer.singleShot(ms, self._get_handler(func, False, requires_collection))\n\n    def _get_handler(\n        self, func: Callable[[], None], repeat: bool, requires_collection: bool\n    ) -> Callable[[], None]:\n        def handler() -> None:\n            if requires_collection and not self.mw.col:\n                # no current collection; timer is no longer valid\n                print(f\"Ignored progress func as collection unloaded: {repr(func)}\")\n                return\n\n            if not self._levels:\n                # no current progress; safe to fire\n                func()\n            elif repeat:\n                # skip this time; we'll fire again\n                pass\n            else:\n                # retry in 100ms\n                self.single_shot(100, func, requires_collection)\n\n        return handler\n\n    # Creating progress dialogs\n    ##########################################################################\n\n    def start(\n        self,\n        max: int = 0,\n        min: int = 0,\n        label: str | None = None,\n        parent: QWidget | None = None,\n        immediate: bool = False,\n        title: str = \"Anki\",\n    ) -> ProgressDialog | None:\n        self._levels += 1\n        if self._levels > 1:\n            return None\n        # setup window\n        parent = parent or self.app.activeWindow()\n        if not parent and self.mw.isVisible():\n            parent = self.mw\n\n        label = label or tr.qt_misc_processing()\n        self._win = ProgressDialog(parent)\n        self._win.form.progressBar.setMinimum(min)\n        self._win.form.progressBar.setMaximum(max)\n        self._win.form.progressBar.setTextVisible(False)\n        self._win.form.label.setText(label)\n        self._win.setWindowTitle(title)\n        self._win.setWindowModality(Qt.WindowModality.ApplicationModal)\n        self._win.setMinimumWidth(300)\n        self._busy_cursor_timer = QTimer(self.mw)\n        self._busy_cursor_timer.setSingleShot(True)\n        self._busy_cursor_timer.start(300)\n        qconnect(self._busy_cursor_timer.timeout, self._set_busy_cursor)\n        self._shown: float = 0\n        self._counter = min\n        self._min = min\n        self._max = max\n        self._firstTime = time.monotonic()\n        self._show_timer = QTimer(self.mw)\n        self._show_timer.setSingleShot(True)\n        self._show_timer.start(immediate and 100 or 600)\n        qconnect(self._show_timer.timeout, self._on_show_timer)\n        return self._win\n\n    def start_with_backend_updates(\n        self,\n        progress_update: Callable[[Progress, ProgressUpdate], None],\n        start_label: str | None = None,\n        parent: QWidget | None = None,\n    ) -> None:\n        self._backend_timer = QTimer()\n        self._backend_timer.setSingleShot(False)\n        self._backend_timer.setInterval(100)\n\n        if not (dialog := self.start(immediate=True, label=start_label, parent=parent)):\n            print(\"Progress dialog already running; aborting will not work\")\n\n        def on_progress() -> None:\n            assert self.mw\n\n            user_wants_abort = dialog and dialog.wantCancel or False\n            update = ProgressUpdate(user_wants_abort=user_wants_abort)\n            progress = self.mw.backend.latest_progress()\n            progress_update(progress, update)\n            if update.abort:\n                self.mw.backend.set_wants_abort()\n            if update.has_update():\n                self.update(label=update.label, value=update.value, max=update.max)\n\n        qconnect(self._backend_timer.timeout, on_progress)\n        self._backend_timer.start()\n\n    def update(\n        self,\n        label: str | None = None,\n        value: int | None = None,\n        process: bool = True,\n        maybeShow: bool = True,\n        max: int | None = None,\n    ) -> None:\n        # print(\"update\", label, self._levels, self._min, self._counter, self._max, label, time.monotonic() - self._shown)\n        if not self.mw.inMainThread():\n            print(\"progress.update() called on wrong thread\")\n            return\n        if maybeShow:\n            self._maybeShow()\n        if not self._shown:\n            return\n\n        assert self._win is not None\n        if label:\n            self._win.form.label.setText(label)\n\n        self._max = max or 0\n        self._win.form.progressBar.setMaximum(self._max)\n        if self._max:\n            self._counter = value if value is not None else (self._counter + 1)\n            self._win.form.progressBar.setValue(self._counter)\n\n    def finish(self) -> None:\n        def do_window_cleanup(future: Future | None = None):\n            # this method can be called in an async fashion from taskman where a future\n            # is passed or in synchronous manner from the main thread\n            if future is not None:\n                future.result()\n\n            next_levels = self._levels - 1\n            next_levels = max(0, next_levels)\n            try:\n                if next_levels == 0:\n                    if self._win:\n                        self._closeWin()\n                    if self._busy_cursor_timer:\n                        self._busy_cursor_timer.stop()\n                        self._busy_cursor_timer = None\n                    self._restore_cursor()\n                    if self._show_timer:\n                        self._show_timer.stop()\n                        self._show_timer = None\n                if self._backend_timer:\n                    self._backend_timer.stop()\n                    self._backend_timer.deleteLater()\n                    self._backend_timer = None\n            except RuntimeError as exc:\n                # during shutdown, the timers may have already been deleted by Qt\n                print(f\"do_window_cleanup error ignored: {exc}\")\n            self._levels = next_levels\n\n        # if the window is not currently shown, we can do cleanup immediately, if it is\n        # currently shown then we need to give the window system a half-second to\n        # present the window before we close it again - fixes progress window getting\n        # stuck, especially on ubuntu 16.10+\n        elapsed_time = time.monotonic() - self._shown\n        time_to_wait = 0.5 - elapsed_time\n        # NOTE: we must not yield control if the window is not shown since we don't want\n        # to expose ourselves to the possibility of something showing the window in the\n        # meantime\n        if (not self._shown) or (time_to_wait <= 0):\n            do_window_cleanup()\n        else:\n            # NOTE: we can't use self.single_shot here since it won't call the callback\n            # until _levels = 0, but if we are in this branch then _levels > 0\n            self.mw.taskman.run_in_background(\n                lambda time_to_wait=time_to_wait: time.sleep(time_to_wait),\n                do_window_cleanup,\n                uses_collection=False,\n            )\n\n    def clear(self) -> None:\n        \"Restore the interface after an error.\"\n        if self._levels:\n            self._levels = 1\n            self.finish()\n\n    def _maybeShow(self) -> None:\n        if not self._levels:\n            return\n        if self._shown:\n            return\n        delta = time.monotonic() - self._firstTime\n        if delta > 0.5:\n            self._showWin()\n\n    def _showWin(self) -> None:\n        assert self._win is not None\n        self._shown = time.monotonic()\n        self._win.show()\n\n    def _closeWin(self) -> None:\n        # if the parent window has been deleted, the progress dialog may have\n        # already been dropped; delete it if it hasn't been\n        if self._win and not sip.isdeleted(self._win):\n            self._win.cancel()\n        self._win = None\n        self._shown = 0\n\n    def _set_busy_cursor(self) -> None:\n        self.mw.app.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))\n\n    def _restore_cursor(self) -> None:\n        self.app.restoreOverrideCursor()\n\n    def busy(self) -> int:\n        \"True if processing.\"\n        return self._levels\n\n    def _on_show_timer(self) -> None:\n        if self.mw.app.focusWindow() is None:\n            # if no window is focused (eg app is minimized), defer display\n            assert self._show_timer is not None\n            self._show_timer.start(10)\n            return\n\n        self._show_timer = None\n        self._showWin()\n\n    def want_cancel(self) -> bool:\n        win = self._win\n        if win:\n            return win.wantCancel\n        else:\n            return False\n\n    def set_title(self, title: str) -> None:\n        win = self._win\n        if win:\n            win.setWindowTitle(title)\n\n\nclass ProgressDialog(QDialog):\n    def __init__(self, parent: QWidget | None) -> None:\n        QDialog.__init__(self, parent)\n        disable_help_button(self)\n        self.form = aqt.forms.progress.Ui_Dialog()\n        self.form.setupUi(self)\n        self._closingDown = False\n        self.wantCancel = False\n        # required for smooth progress bars\n        self.form.progressBar.setStyleSheet(\"QProgressBar::chunk { width: 1px; }\")\n\n    def cancel(self) -> None:\n        self._closingDown = True\n        self.hide()\n        self.deleteLater()\n\n    def closeEvent(self, evt: QCloseEvent | None) -> None:\n        assert evt is not None\n        if self._closingDown:\n            evt.accept()\n        else:\n            self.wantCancel = True\n            evt.ignore()\n\n    def keyPressEvent(self, evt: QKeyEvent | None) -> None:\n        assert evt is not None\n        if evt.key() == Qt.Key.Key_Escape:\n            evt.ignore()\n            self.wantCancel = True\n\n\n@dataclass\nclass ProgressUpdate:\n    label: str | None = None\n    value: int | None = None\n    max: int | None = None\n    user_wants_abort: bool = False\n    abort: bool = False\n\n    def has_update(self) -> bool:\n        return self.label is not None or self.value is not None or self.max is not None\n"
  },
  {
    "path": "qt/aqt/props.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nSee pylib/anki/hooks.py\n\"\"\"\n\nfrom __future__ import annotations\n\n# You can find the definitions in ../tools/genhooks_gui.py\nfrom _aqt.props import *\n"
  },
  {
    "path": "qt/aqt/py.typed",
    "content": ""
  },
  {
    "path": "qt/aqt/qt/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# make sure not to optimize imports on this file\n# ruff: noqa: F401\nfrom __future__ import annotations\n\nimport os\nimport sys\nimport traceback\nfrom collections.abc import Callable\nfrom typing import TypeVar, Union\n\nfrom anki._legacy import deprecated\n\n# legacy code depends on these re-exports\nfrom anki.utils import is_mac, is_win\n\nfrom .qt6 import *\n\n\ndef debug() -> None:\n    from pdb import set_trace\n\n    pyqtRemoveInputHook()\n    set_trace()\n\n\nif os.environ.get(\"DEBUG\"):\n\n    def info(type, value, tb) -> None:  # type: ignore\n        for line in traceback.format_exception(type, value, tb):\n            sys.stdout.write(line)\n        pyqtRemoveInputHook()\n        from pdb import pm\n\n        pm()\n\n    sys.excepthook = info\n\n_version = QLibraryInfo.version()\nqtmajor = _version.majorVersion()\nqtminor = _version.minorVersion()\nqtpoint = _version.microVersion()\nqtfullversion = _version.segments()\n\nif qtmajor == 6 and qtminor < 2:\n    raise Exception(\"Anki does not support your Qt version.\")\n\n\ndef qconnect(signal: Callable | pyqtSignal | pyqtBoundSignal, func: Callable) -> None:\n    \"\"\"Helper to work around type checking not working with signal.connect(func).\"\"\"\n    signal.connect(func)  # type: ignore\n\n\n_T = TypeVar(\"_T\")\n\n\n@deprecated(info=\"no longer required, and now a no-op\")\ndef without_qt5_compat_wrapper(cls: _T) -> _T:\n    return cls\n"
  },
  {
    "path": "qt/aqt/qt/qt6.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# make sure not to optimize imports on this file\n# ruff: noqa: F401\n\"\"\"\nPyQt6 imports\n\"\"\"\n\nfrom PyQt6 import sip\nfrom PyQt6.QtCore import *\n\n# conflicting Qt and qFuzzyCompare definitions require an ignore\nfrom PyQt6.QtGui import *  # type: ignore[no-redef,assignment]\nfrom PyQt6.QtNetwork import QLocalServer, QLocalSocket, QNetworkProxy\nfrom PyQt6.QtQuick import *\nfrom PyQt6.QtWebChannel import QWebChannel\nfrom PyQt6.QtWebEngineCore import *\nfrom PyQt6.QtWebEngineWidgets import *\nfrom PyQt6.QtWidgets import *\n"
  },
  {
    "path": "qt/aqt/reviewer.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport json\nimport random\nimport re\nfrom collections.abc import Callable, Generator, Sequence\nfrom dataclasses import dataclass\nfrom enum import Enum, auto\nfrom functools import partial\nfrom typing import Any, Literal, Match, Union, cast\n\nimport aqt\nimport aqt.browser\nimport aqt.operations\nfrom anki.cards import Card, CardId\nfrom anki.collection import Config, OpChanges, OpChangesWithCount\nfrom anki.lang import with_collapsed_whitespace\nfrom anki.scheduler.base import ScheduleCardsAsNew\nfrom anki.scheduler.v3 import (\n    CardAnswer,\n    QueuedCards,\n    SchedulingContext,\n    SchedulingStates,\n    SetSchedulingStatesRequest,\n)\nfrom anki.scheduler.v3 import Scheduler as V3Scheduler\nfrom anki.tags import MARKED_TAG\nfrom anki.types import assert_exhaustive\nfrom anki.utils import is_mac\nfrom aqt import AnkiQt, gui_hooks\nfrom aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo\nfrom aqt.deckoptions import confirm_deck_then_display_options\nfrom aqt.operations.card import set_card_flag\nfrom aqt.operations.note import remove_notes\nfrom aqt.operations.scheduling import (\n    answer_card,\n    bury_cards,\n    bury_notes,\n    forget_cards,\n    set_due_date_dialog,\n    suspend_cards,\n    suspend_note,\n)\nfrom aqt.operations.tag import add_tags_to_notes, remove_tags_from_notes\nfrom aqt.profiles import VideoDriver\nfrom aqt.qt import *\nfrom aqt.sound import av_player, play_clicked_audio, record_audio\nfrom aqt.theme import theme_manager\nfrom aqt.toolbar import BottomBar\nfrom aqt.utils import (\n    askUserDialog,\n    downArrow,\n    qtMenuShortcutWorkaround,\n    show_warning,\n    tooltip,\n    tr,\n)\n\n\nclass RefreshNeeded(Enum):\n    NOTE_TEXT = auto()\n    QUEUES = auto()\n    FLAG = auto()\n\n\nclass ReviewerBottomBar:\n    def __init__(self, reviewer: Reviewer) -> None:\n        self.reviewer = reviewer\n\n\ndef replay_audio(card: Card, question_side: bool) -> None:\n    if question_side:\n        av_player.play_tags(card.question_av_tags())\n    else:\n        tags = card.answer_av_tags()\n        if card.replay_question_audio_on_answer_side():\n            tags = card.question_av_tags() + tags\n        av_player.play_tags(tags)\n\n\n@dataclass\nclass V3CardInfo:\n    \"\"\"Stores the top of the card queue for the v3 scheduler.\n\n    This includes current and potential next states of the displayed card,\n    which may be mutated by a user's custom scheduling.\n    \"\"\"\n\n    queued_cards: QueuedCards\n    states: SchedulingStates\n    context: SchedulingContext\n\n    @staticmethod\n    def from_queue(queued_cards: QueuedCards) -> V3CardInfo:\n        top_card = queued_cards.cards[0]\n        states = top_card.states\n        states.current.custom_data = top_card.card.custom_data\n        return V3CardInfo(\n            queued_cards=queued_cards, states=states, context=top_card.context\n        )\n\n    def top_card(self) -> QueuedCards.QueuedCard:\n        return self.queued_cards.cards[0]\n\n    def counts(self) -> tuple[int, list[int]]:\n        \"Returns (idx, counts).\"\n        counts = [\n            self.queued_cards.new_count,\n            self.queued_cards.learning_count,\n            self.queued_cards.review_count,\n        ]\n        card = self.top_card()\n        if card.queue == QueuedCards.NEW:\n            idx = 0\n        elif card.queue == QueuedCards.LEARNING:\n            idx = 1\n        else:\n            idx = 2\n        return idx, counts\n\n    @staticmethod\n    def rating_from_ease(ease: int) -> CardAnswer.Rating.V:\n        if ease == 1:\n            return CardAnswer.AGAIN\n        elif ease == 2:\n            return CardAnswer.HARD\n        elif ease == 3:\n            return CardAnswer.GOOD\n        else:\n            return CardAnswer.EASY\n\n\nclass AnswerAction(Enum):\n    BURY_CARD = 0\n    ANSWER_AGAIN = 1\n    ANSWER_GOOD = 2\n    ANSWER_HARD = 3\n    SHOW_REMINDER = 4\n\n\nclass QuestionAction(Enum):\n    SHOW_ANSWER = 0\n    SHOW_REMINDER = 1\n\n\nclass Reviewer:\n    def __init__(self, mw: AnkiQt) -> None:\n        self.mw = mw\n        self.web = mw.web\n        self.card: Card | None = None\n        self.previous_card: Card | None = None\n        self._answeredIds: list[CardId] = []\n        self._recordedAudio: str | None = None\n        self._combining: bool = True\n        self.typeCorrect: str | None = None  # web init happens before this is set\n        self.state: Literal[\"question\", \"answer\", \"transition\"] | None = None\n        self._refresh_needed: RefreshNeeded | None = None\n        self._v3: V3CardInfo | None = None\n        self._state_mutation_key = str(random.randint(0, 2**64 - 1))\n        self.bottom = BottomBar(mw, mw.bottomWeb)\n        self._card_info = ReviewerCardInfo(self.mw)\n        self._previous_card_info = PreviousReviewerCardInfo(self.mw)\n        self._states_mutated = True\n        self._state_mutation_js = None\n        self._reps: int | None = None\n        self._show_question_timer: QTimer | None = None\n        self._show_answer_timer: QTimer | None = None\n        self.auto_advance_enabled = False\n        gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)\n\n    def show(self) -> None:\n        if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():\n            self.mw.moveToState(\"deckBrowser\")\n            show_warning(tr.scheduling_update_required().replace(\"V2\", \"v3\"))\n            return\n        self.mw.setStateShortcuts(self._shortcutKeys())  # type: ignore\n        self.web.set_bridge_command(self._linkHandler, self)\n        self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self))\n        self._state_mutation_js = self.mw.col.get_config(\"cardStateCustomizer\")\n        self._reps = None\n        self._refresh_needed = RefreshNeeded.QUEUES\n        self.refresh_if_needed()\n\n    # this is only used by add-ons\n    def lastCard(self) -> Card | None:\n        if self._answeredIds:\n            if not self.card or self._answeredIds[-1] != self.card.id:\n                try:\n                    return self.mw.col.get_card(self._answeredIds[-1])\n                except TypeError:\n                    # id was deleted\n                    return None\n        return None\n\n    def cleanup(self) -> None:\n        gui_hooks.reviewer_will_end()\n        self.card = None\n        self.auto_advance_enabled = False\n\n    def refresh_if_needed(self) -> None:\n        if self._refresh_needed is RefreshNeeded.QUEUES:\n            self.nextCard()\n            self.mw.fade_in_webview()\n            self._refresh_needed = None\n        elif self._refresh_needed is RefreshNeeded.NOTE_TEXT:\n            self._redraw_current_card()\n            self.mw.fade_in_webview()\n            self._refresh_needed = None\n        elif self._refresh_needed is RefreshNeeded.FLAG:\n            self.card.load()\n            self._update_flag_icon()\n            # for when modified in browser\n            self.mw.fade_in_webview()\n            self._refresh_needed = None\n        elif self._refresh_needed:\n            assert_exhaustive(self._refresh_needed)\n\n    def op_executed(\n        self, changes: OpChanges, handler: object | None, focused: bool\n    ) -> bool:\n        if handler is not self:\n            if changes.study_queues:\n                self._refresh_needed = RefreshNeeded.QUEUES\n            elif changes.note_text:\n                self._refresh_needed = RefreshNeeded.NOTE_TEXT\n            elif changes.card:\n                self._refresh_needed = RefreshNeeded.FLAG\n\n        if focused and self._refresh_needed:\n            self.refresh_if_needed()\n\n        return bool(self._refresh_needed)\n\n    def _redraw_current_card(self) -> None:\n        self.card.load()\n        if self.state == \"answer\":\n            self._showAnswer()\n        else:\n            self._showQuestion()\n\n    # Fetching a card\n    ##########################################################################\n\n    def nextCard(self) -> None:\n        self.previous_card = self.card\n        self.card = None\n        self._v3 = None\n        self._get_next_v3_card()\n\n        self._previous_card_info.set_card(self.previous_card)\n        self._card_info.set_card(self.card)\n\n        if not self.card:\n            self.mw.moveToState(\"overview\")\n            return\n\n        if self._reps is None:\n            self._initWeb()\n\n        self._showQuestion()\n\n    def _get_next_v3_card(self) -> None:\n        assert isinstance(self.mw.col.sched, V3Scheduler)\n        output = self.mw.col.sched.get_queued_cards()\n        if not output.cards:\n            return\n        self._v3 = V3CardInfo.from_queue(output)\n        self.card = Card(self.mw.col, backend_card=self._v3.top_card().card)\n        self.card.start_timer()\n\n    def get_scheduling_states(self) -> SchedulingStates:\n        return self._v3.states\n\n    def get_scheduling_context(self) -> SchedulingContext:\n        return self._v3.context\n\n    def set_scheduling_states(self, request: SetSchedulingStatesRequest) -> None:\n        if request.key != self._state_mutation_key:\n            return\n\n        self._v3.states = request.states\n\n    def _run_state_mutation_hook(self) -> None:\n        def on_eval(result: Any) -> None:\n            if result is None:\n                # eval failed, usually a syntax error\n                self._states_mutated = True\n\n        if js := self._state_mutation_js:\n            self._states_mutated = False\n            self.web.evalWithCallback(\n                RUN_STATE_MUTATION.format(key=self._state_mutation_key, js=js),\n                on_eval,\n            )\n\n    # Audio\n    ##########################################################################\n\n    def replayAudio(self) -> None:\n        if self.state == \"question\":\n            replay_audio(self.card, True)\n        elif self.state == \"answer\":\n            replay_audio(self.card, False)\n        gui_hooks.audio_will_replay(self.web, self.card, self.state == \"question\")\n\n    def _on_av_player_did_end_playing(self, *args) -> None:\n        def task() -> None:\n            if av_player.queue_is_empty():\n                if (\n                    self._show_question_timer\n                    and self._show_question_timer.remainingTime() <= 0\n                ):\n                    self._on_show_question_timeout()\n                elif (\n                    self._show_answer_timer\n                    and self._show_answer_timer.remainingTime() <= 0\n                ):\n                    self._on_show_answer_timeout()\n\n        # Allow time for audio queue to update\n        self.mw.taskman.run_on_main(lambda: self.mw.progress.single_shot(100, task))\n\n    # Initializing the webview\n    ##########################################################################\n\n    def revHtml(self) -> str:\n        extra = self.mw.col.conf.get(\"reviewExtra\", \"\")\n        fade = \"\"\n        if self.mw.pm.video_driver() == VideoDriver.Software:\n            fade = \"<script>qFade=0;</script>\"\n        return f\"\"\"\n<div id=\"_mark\" hidden>&#x2605;</div>\n<div id=\"_flag\" hidden>&#x2691;</div>\n{fade}\n<div id=\"qa\"></div>\n{extra}\n\"\"\"\n\n    def _initWeb(self) -> None:\n        self._reps = 0\n        # main window\n        self.web.stdHtml(\n            self.revHtml(),\n            css=[\"css/reviewer.css\"],\n            js=[\n                \"js/mathjax.js\",\n                \"js/vendor/mathjax/tex-chtml-full.js\",\n                \"js/reviewer.js\",\n            ],\n            context=self,\n        )\n        # block default drag & drop behavior while allowing drop events to be received by JS handlers\n        self.web.allow_drops = True\n        self.web.eval(\"_blockDefaultDragDropBehavior();\")\n        # show answer / ease buttons\n        self.bottom.web.stdHtml(\n            self._bottomHTML(),\n            css=[\"css/toolbar-bottom.css\", \"css/reviewer-bottom.css\"],\n            js=[\"js/vendor/jquery.min.js\", \"js/reviewer-bottom.js\"],\n            context=ReviewerBottomBar(self),\n        )\n\n    # Showing the question\n    ##########################################################################\n\n    def _mungeQA(self, buf: str) -> str:\n        return self.typeAnsFilter(self.mw.prepare_card_text_for_display(buf))\n\n    def _showQuestion(self) -> None:\n        self._reps += 1\n        self.state = \"question\"\n        self.typedAnswer: str | None = None\n        c = self.card\n        # grab the question and play audio\n        q = c.question()\n        # play audio?\n        if c.autoplay():\n            self.web.setPlaybackRequiresGesture(False)\n            sounds = c.question_av_tags()\n            gui_hooks.reviewer_will_play_question_sounds(c, sounds)\n        else:\n            self.web.setPlaybackRequiresGesture(True)\n            sounds = []\n            gui_hooks.reviewer_will_play_question_sounds(c, sounds)\n        gui_hooks.av_player_will_play_tags(sounds, self.state, self)\n        av_player.play_tags(sounds)\n        # render & update bottom\n        q = self._mungeQA(q)\n        q = gui_hooks.card_will_show(q, c, \"reviewQuestion\")\n        self._run_state_mutation_hook()\n\n        bodyclass = theme_manager.body_classes_for_card_ord(c.ord)\n        a = self.mw.col.media.escape_media_filenames(c.answer())\n\n        self.web.eval(\n            f\"_showQuestion({json.dumps(q)}, {json.dumps(a)}, '{bodyclass}');\"\n        )\n        self._update_flag_icon()\n        self._update_mark_icon()\n        self._showAnswerButton()\n        self.mw.web.setFocus()\n        # user hook\n        gui_hooks.reviewer_did_show_question(c)\n        self._auto_advance_to_answer_if_enabled()\n\n    def _auto_advance_to_answer_if_enabled(self) -> None:\n        self._clear_auto_advance_timers()\n        if self.auto_advance_enabled:\n            conf = self.mw.col.decks.config_dict_for_deck_id(\n                self.card.current_deck_id()\n            )\n            if conf[\"secondsToShowQuestion\"]:\n                self._show_answer_timer = self.mw.progress.timer(\n                    int(conf[\"secondsToShowQuestion\"] * 1000),\n                    self._on_show_answer_timeout,\n                    repeat=False,\n                    parent=self.mw,\n                )\n\n    def _on_show_answer_timeout(self) -> None:\n        if self.card is None:\n            return\n        conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id())\n        if conf[\"waitForAudio\"] and av_player.current_player:\n            return\n        if (\n            not self.auto_advance_enabled\n            or not self.mw.app.focusWidget()\n            or self.mw.app.focusWidget().window() != self.mw\n        ):\n            self.auto_advance_enabled = False\n            return\n        try:\n            question_action = list(QuestionAction)[conf[\"questionAction\"]]\n        except IndexError:\n            question_action = QuestionAction.SHOW_ANSWER\n\n        if question_action == QuestionAction.SHOW_ANSWER:\n            self._showAnswer()\n        else:\n            tooltip(tr.studying_question_time_elapsed())\n\n    def autoplay(self, card: Card) -> bool:\n        print(\"use card.autoplay() instead of reviewer.autoplay(card)\")\n        return card.autoplay()\n\n    def _update_flag_icon(self) -> None:\n        self.web.eval(f\"_drawFlag({self.card.user_flag()});\")\n\n    def _update_mark_icon(self) -> None:\n        self.web.eval(f\"_drawMark({json.dumps(self.card.note().has_tag(MARKED_TAG))});\")\n\n    _drawMark = _update_mark_icon\n    _drawFlag = _update_flag_icon\n\n    # Showing the answer\n    ##########################################################################\n\n    def _showAnswer(self) -> None:\n        if self.mw.state != \"review\":\n            # showing resetRequired screen; ignore space\n            return\n        self.state = \"answer\"\n        c = self.card\n        a = c.answer()\n        # play audio?\n        if c.autoplay():\n            sounds = c.answer_av_tags()\n            gui_hooks.reviewer_will_play_answer_sounds(c, sounds)\n        else:\n            sounds = []\n            gui_hooks.reviewer_will_play_answer_sounds(c, sounds)\n        gui_hooks.av_player_will_play_tags(sounds, self.state, self)\n        av_player.play_tags(sounds)\n        a = self._mungeQA(a)\n        a = gui_hooks.card_will_show(a, c, \"reviewAnswer\")\n        # render and update bottom\n        self.web.eval(f\"_showAnswer({json.dumps(a)});\")\n        self._showEaseButtons()\n        self.mw.web.setFocus()\n        # user hook\n        gui_hooks.reviewer_did_show_answer(c)\n        self._auto_advance_to_question_if_enabled()\n\n    def _auto_advance_to_question_if_enabled(self) -> None:\n        self._clear_auto_advance_timers()\n        if self.auto_advance_enabled:\n            conf = self.mw.col.decks.config_dict_for_deck_id(\n                self.card.current_deck_id()\n            )\n            if conf[\"secondsToShowAnswer\"]:\n                self._show_question_timer = self.mw.progress.timer(\n                    int(conf[\"secondsToShowAnswer\"] * 1000),\n                    self._on_show_question_timeout,\n                    repeat=False,\n                    parent=self.mw,\n                )\n\n    def _on_show_question_timeout(self) -> None:\n        if self.card is None:\n            return\n        conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id())\n        if conf[\"waitForAudio\"] and av_player.current_player:\n            return\n        if (\n            not self.auto_advance_enabled\n            or not self.mw.app.focusWidget()\n            or self.mw.app.focusWidget().window() != self.mw\n        ):\n            self.auto_advance_enabled = False\n            return\n        try:\n            answer_action = list(AnswerAction)[conf[\"answerAction\"]]\n        except IndexError:\n            answer_action = AnswerAction.BURY_CARD\n        if answer_action == AnswerAction.ANSWER_AGAIN:\n            self._answerCard(1)\n        elif answer_action == AnswerAction.ANSWER_HARD:\n            self._answerCard(2)\n        elif answer_action == AnswerAction.ANSWER_GOOD:\n            self._answerCard(3)\n        elif answer_action == AnswerAction.SHOW_REMINDER:\n            tooltip(tr.studying_answer_time_elapsed())\n        else:\n            self.bury_current_card()\n\n    # Answering a card\n    ############################################################\n\n    def _answerCard(self, ease: Literal[1, 2, 3, 4]) -> None:\n        \"Reschedule card and show next.\"\n        if self.mw.state != \"review\":\n            # showing resetRequired screen; ignore key\n            return\n        if self.state != \"answer\":\n            return\n        proceed, ease = gui_hooks.reviewer_will_answer_card(\n            (True, ease), self, self.card\n        )\n        if not proceed:\n            return\n\n        sched = cast(V3Scheduler, self.mw.col.sched)\n        answer = sched.build_answer(\n            card=self.card,\n            states=self._v3.states,\n            rating=self._v3.rating_from_ease(ease),\n        )\n\n        def after_answer(changes: OpChanges) -> None:\n            if gui_hooks.reviewer_did_answer_card.count() > 0:\n                self.card.load()\n            # v3 scheduler doesn't report this\n            suspended = self.card is not None and self.card.queue < 0\n            self._after_answering(ease)\n            if sched.state_is_leech(answer.new_state):\n                self.onLeech(suspended)\n\n        self.state = \"transition\"\n        answer_card(parent=self.mw, answer=answer).success(\n            after_answer\n        ).run_in_background(initiator=self)\n\n    def _after_answering(self, ease: Literal[1, 2, 3, 4]) -> None:\n        gui_hooks.reviewer_did_answer_card(self, self.card, ease)\n        self._answeredIds.append(self.card.id)\n        if not self.check_timebox():\n            self.nextCard()\n\n    # Handlers\n    ############################################################\n\n    def korean_shortcuts(\n        self,\n    ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]:\n        return [\n            (\"ㄷ\", self.mw.onEditCurrent),\n            (\"ㅡ\", self.showContextMenu),\n            (\"ㄱ\", self.replayAudio),\n            (\"Ctrl+Alt+ㅜ\", self.forget_current_card),\n            # does not work\n            # (\"Ctrl+Alt+ㄷ\", self.on_create_copy),\n            # does not work\n            # (\"Ctrl+Shift+ㅇ\", self.on_set_due),\n            (\"ㅍ\", self.onReplayRecorded),\n            (\"Shift+ㅍ\", self.onRecordVoice),\n            (\"ㅐ\", self.onOptions),\n            (\"ㅑ\", self.on_card_info),\n            (\"Ctrl+Alt+ㅑ\", self.on_previous_card_info),\n            (\"ㅕ\", self.mw.undo),\n        ]\n\n    def _shortcutKeys(\n        self,\n    ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]:\n        def generate_default_answer_keys() -> Generator[\n            tuple[str, partial], None, None\n        ]:\n            for ease in aqt.mw.pm.default_answer_keys:\n                key = aqt.mw.pm.get_answer_key(ease)\n                if not key:\n                    continue\n                ease = cast(Literal[1, 2, 3, 4], ease)\n                answer_card_according_to_pressed_key = partial(self._answerCard, ease)\n                yield (key, answer_card_according_to_pressed_key)\n\n        return [\n            (\"e\", self.mw.onEditCurrent),\n            (\" \", self.onEnterKey),\n            (Qt.Key.Key_Return, self.onEnterKey),\n            (Qt.Key.Key_Enter, self.onEnterKey),\n            (\"m\", self.showContextMenu),\n            (\"r\", self.replayAudio),\n            (Qt.Key.Key_F5, self.replayAudio),\n            *(\n                (f\"Ctrl+{flag.index}\", self.set_flag_func(flag.index))\n                for flag in self.mw.flags.all()\n            ),\n            (\"*\", self.toggle_mark_on_current_note),\n            (\"=\", self.bury_current_note),\n            (\"-\", self.bury_current_card),\n            (\"!\", self.suspend_current_note),\n            (\"@\", self.suspend_current_card),\n            (\"Ctrl+Alt+N\", self.forget_current_card),\n            (\"Ctrl+Alt+E\", self.on_create_copy),\n            (\"Ctrl+Backspace\" if is_mac else \"Ctrl+Delete\", self.delete_current_note),\n            (\"Ctrl+Shift+D\", self.on_set_due),\n            (\"v\", self.onReplayRecorded),\n            (\"Shift+v\", self.onRecordVoice),\n            (\"o\", self.onOptions),\n            (\"i\", self.on_card_info),\n            (\"Ctrl+Alt+i\", self.on_previous_card_info),\n            *generate_default_answer_keys(),\n            (\"u\", self.mw.undo),\n            (\"5\", self.on_pause_audio),\n            (\"6\", self.on_seek_backward),\n            (\"7\", self.on_seek_forward),\n            (\"Shift+A\", self.toggle_auto_advance),\n            *self.korean_shortcuts(),\n        ]\n\n    def on_pause_audio(self) -> None:\n        av_player.toggle_pause()\n        gui_hooks.audio_did_pause_or_unpause(self.web)\n\n    seek_secs = 5\n\n    def on_seek_backward(self) -> None:\n        av_player.seek_relative(-self.seek_secs)\n        gui_hooks.audio_did_seek_relative(self.web, -self.seek_secs)\n\n    def on_seek_forward(self) -> None:\n        av_player.seek_relative(self.seek_secs)\n        gui_hooks.audio_did_seek_relative(self.web, self.seek_secs)\n\n    def onEnterKey(self) -> None:\n        if self.state == \"question\":\n            self._getTypedAnswer()\n        elif self.state == \"answer\" and aqt.mw.pm.spacebar_rates_card():\n            self.bottom.web.evalWithCallback(\n                \"selectedAnswerButton()\", self._onAnswerButton\n            )\n\n    def _onAnswerButton(self, val: str) -> None:\n        # button selected?\n        if val and val in \"1234\":\n            val2: Literal[1, 2, 3, 4] = int(val)  # type: ignore\n            self._answerCard(val2)\n        else:\n            self._answerCard(self._defaultEase())\n\n    def _linkHandler(self, url: str) -> None:\n        if url == \"ans\":\n            self._getTypedAnswer()\n        elif url.startswith(\"ease\"):\n            val: Literal[1, 2, 3, 4] = int(url[4:])  # type: ignore\n            self._answerCard(val)\n        elif url == \"edit\":\n            self.mw.onEditCurrent()\n        elif url == \"more\":\n            self.showContextMenu()\n        elif url.startswith(\"play:\"):\n            play_clicked_audio(url, self.card)\n        elif url.startswith(\"updateToolbar\"):\n            self.mw.toolbarWeb.update_background_image()\n        elif url == \"statesMutated\":\n            self._states_mutated = True\n        else:\n            print(\"unrecognized anki link:\", url)\n\n    # Type in the answer\n    ##########################################################################\n\n    typeAnsPat = r\"\\[\\[type:(.+?)\\]\\]\"\n\n    def typeAnsFilter(self, buf: str) -> str:\n        if self.state == \"question\":\n            return self.typeAnsQuestionFilter(buf)\n        else:\n            return self.typeAnsAnswerFilter(buf)\n\n    def typeAnsQuestionFilter(self, buf: str) -> str:\n        self._combining = True\n        self.typeCorrect = None\n        clozeIdx = None\n        m = re.search(self.typeAnsPat, buf)\n        if not m:\n            return buf\n        fld = m.group(1)\n        # if it's a cloze, extract data\n        if fld.startswith(\"cloze:\"):\n            # get field and cloze position\n            clozeIdx = self.card.ord + 1\n            fld = fld.split(\":\")[1]\n        if fld.startswith(\"nc:\"):\n            self._combining = False\n            fld = fld.split(\":\")[1]\n        # loop through fields for a match\n        for f in self.card.note_type()[\"flds\"]:\n            if f[\"name\"] == fld:\n                self.typeCorrect = self.card.note()[f[\"name\"]]\n                if clozeIdx:\n                    # narrow to cloze\n                    self.typeCorrect = self._contentForCloze(self.typeCorrect, clozeIdx)\n                self.typeFont = f[\"font\"]\n                self.typeSize = f[\"size\"]\n                break\n        if not self.typeCorrect:\n            if self.typeCorrect is None:\n                if clozeIdx:\n                    warn = tr.studying_please_run_toolsempty_cards()\n                else:\n                    warn = tr.studying_type_answer_unknown_field(val=fld)\n                return re.sub(self.typeAnsPat, warn, buf)\n            else:\n                # empty field, remove type answer pattern\n                return re.sub(self.typeAnsPat, \"\", buf)\n        return re.sub(\n            self.typeAnsPat,\n            f\"\"\"\n<center>\n<input type=text id=typeans onkeypress=\"_typeAnsPress();\"\n   style=\"font-family: '{self.typeFont}'; font-size: {self.typeSize}px;\">\n</center>\n\"\"\",\n            buf,\n        )\n\n    def typeAnsAnswerFilter(self, buf: str) -> str:\n        if not self.typeCorrect:\n            return re.sub(self.typeAnsPat, \"\", buf)\n        m = re.search(self.typeAnsPat, buf)\n        type_pattern = m.group(1) if m else \"\"\n        orig = buf\n        origSize = len(buf)\n        buf = buf.replace(\"<hr id=answer>\", \"\")\n        hadHR = len(buf) != origSize\n        initial_expected = self.typeCorrect\n        initial_provided = self.typedAnswer\n        expected, provided = gui_hooks.reviewer_will_compare_answer(\n            (initial_expected, initial_provided), type_pattern\n        )\n\n        output = self.mw.col.compare_answer(expected, provided, self._combining)\n        output = gui_hooks.reviewer_will_render_compared_answer(\n            output,\n            initial_expected,\n            initial_provided,\n            type_pattern,\n        )\n\n        # and update the type answer area\n        def repl(match: Match) -> str:\n            # can't pass a string in directly, and can't use re.escape as it\n            # escapes too much\n            s = \"\"\"\n<div style=\"font-family: '{}'; font-size: {}px\">{}</div>\"\"\".format(\n                self.typeFont,\n                self.typeSize,\n                output,\n            )\n            if hadHR:\n                # a hack to ensure the q/a separator falls before the answer\n                # comparison when user is using {{FrontSide}}\n                s = f\"<hr id=answer>{s}\"\n            return s\n\n        if hadHR and not re.search(self.typeAnsPat, buf):\n            return orig\n\n        return re.sub(self.typeAnsPat, repl, buf)\n\n    def _contentForCloze(self, txt: str, idx: int) -> str | None:\n        return self.mw.col.extract_cloze_for_typing(txt, idx) or None\n\n    def _getTypedAnswer(self) -> None:\n        self.web.evalWithCallback(\"getTypedAnswer();\", self._onTypedAnswer)\n\n    def _onTypedAnswer(self, val: None) -> None:\n        self.typedAnswer = val or \"\"\n        self._showAnswer()\n\n    # Bottom bar\n    ##########################################################################\n\n    def _bottomHTML(self) -> str:\n        return \"\"\"\n<center id=outer>\n<table id=innertable width=100%% cellspacing=0 cellpadding=0>\n<tr>\n<td align=start valign=top class=stat>\n<button title=\"%(editkey)s\" onclick=\"pycmd('edit');\">%(edit)s</button></td>\n<td align=center valign=top id=middle>\n</td>\n<td align=end valign=top class=stat>\n<button title=\"%(morekey)s\" onclick=\"pycmd('more');\">\n%(more)s %(downArrow)s\n<span id=time class=stattxt></span>\n</button>\n</td>\n</tr>\n</table>\n</center>\n<script>\ntime = %(time)d;\ntimerStopped = false;\n</script>\n\"\"\" % dict(\n            edit=tr.studying_edit(),\n            editkey=tr.actions_shortcut_key(val=\"E\"),\n            more=tr.studying_more(),\n            morekey=tr.actions_shortcut_key(val=\"M\"),\n            downArrow=downArrow(),\n            time=self.card.time_taken() // 1000,\n        )\n\n    def _showAnswerButton(self) -> None:\n        middle = \"\"\"\n<button title=\"{}\" id=\"ansbut\" onclick='pycmd(\"ans\");'>{}<span class=stattxt>{}</span></button>\"\"\".format(\n            tr.actions_shortcut_key(val=tr.studying_space()),\n            tr.studying_show_answer(),\n            self._remaining(),\n        )\n        # wrap it in a table so it has the same top margin as the ease buttons\n        middle = (\n            \"<table cellpadding=0><tr><td class=stat2 align=center>%s</td></tr></table>\"\n            % middle\n        )\n        if self.card.should_show_timer():\n            maxTime = self.card.time_limit() / 1000\n        else:\n            maxTime = 0\n        self.bottom.web.eval(\"showQuestion(%s,%d);\" % (json.dumps(middle), maxTime))\n\n    def _showEaseButtons(self) -> None:\n        if not self._states_mutated:\n            self.mw.progress.single_shot(50, self._showEaseButtons)\n            return\n        middle = self._answerButtons()\n        conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id())\n        self.bottom.web.eval(\n            f\"showAnswer({json.dumps(middle)}, {json.dumps(conf['stopTimerOnAnswer'])});\"\n        )\n\n    def _remaining(self) -> str:\n        if not self.mw.col.conf[\"dueCounts\"]:\n            return \"\"\n\n        counts: list[int | str]\n        idx, counts_ = self._v3.counts()\n        counts = cast(list[Union[int, str]], counts_)\n        counts[idx] = f\"<u>{counts[idx]}</u>\"\n\n        return f\"\"\"\n<span class=new-count>{counts[0]}</span> +\n<span class=learn-count>{counts[1]}</span> +\n<span class=review-count>{counts[2]}</span>\n\"\"\"\n\n    def _defaultEase(self) -> Literal[2, 3]:\n        return 3\n\n    def _answerButtonList(self) -> tuple[tuple[int, str], ...]:\n        button_count = self.mw.col.sched.answerButtons(self.card)\n        if button_count == 2:\n            buttons_tuple: tuple[tuple[int, str], ...] = (\n                (1, tr.studying_again()),\n                (2, tr.studying_good()),\n            )\n        elif button_count == 3:\n            buttons_tuple = (\n                (1, tr.studying_again()),\n                (2, tr.studying_good()),\n                (3, tr.studying_easy()),\n            )\n        else:\n            buttons_tuple = (\n                (1, tr.studying_again()),\n                (2, tr.studying_hard()),\n                (3, tr.studying_good()),\n                (4, tr.studying_easy()),\n            )\n        buttons_tuple = gui_hooks.reviewer_will_init_answer_buttons(\n            buttons_tuple, self, self.card\n        )\n        return buttons_tuple\n\n    def _answerButtons(self) -> str:\n        default = self._defaultEase()\n\n        assert isinstance(self.mw.col.sched, V3Scheduler)\n        labels = self.mw.col.sched.describe_next_states(self._v3.states)\n\n        def but(i: int, label: str) -> str:\n            if i == default:\n                extra = \"\"\"id=\"defease\" \"\"\"\n            else:\n                extra = \"\"\n            due = self._buttonTime(i, v3_labels=labels)\n            key = (\n                tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(i))\n                if aqt.mw.pm.get_answer_key(i)\n                else \"\"\n            )\n            return \"\"\"\n<td align=center><button %s title=\"%s\" data-ease=\"%s\" onclick='pycmd(\"ease%d\");'>\\\n%s%s</button></td>\"\"\" % (\n                extra,\n                key,\n                i,\n                i,\n                label,\n                due,\n            )\n\n        buf = \"<center><table cellpadding=0 cellspacing=0><tr>\"\n        for ease, label in self._answerButtonList():\n            buf += but(ease, label)\n        buf += \"</tr></table>\"\n        return buf\n\n    def _buttonTime(self, i: int, v3_labels: Sequence[str]) -> str:\n        if self.mw.col.conf[\"estTimes\"]:\n            txt = v3_labels[i - 1]\n            return f\"\"\"<span class=\"nobold\">{txt}</span>\"\"\"\n        else:\n            return \"\"\n\n    # Leeches\n    ##########################################################################\n\n    def onLeech(self, suspended: bool = False) -> None:\n        # for now\n        s = tr.studying_card_was_a_leech()\n        if suspended:\n            s += f\" {tr.studying_it_has_been_suspended()}\"\n        tooltip(s)\n\n    # Timebox\n    ##########################################################################\n\n    def check_timebox(self) -> bool:\n        \"True if answering should be aborted.\"\n        elapsed = self.mw.col.timeboxReached()\n        if elapsed:\n            assert not isinstance(elapsed, bool)\n            cards_val = elapsed[1]\n            minutes_val = int(round(elapsed[0] / 60))\n            message = with_collapsed_whitespace(\n                tr.studying_card_studied_in_minute(\n                    cards=cards_val, minutes=str(minutes_val)\n                )\n            )\n            fin = tr.studying_finish()\n            diag = askUserDialog(message, [tr.studying_continue(), fin])\n            diag.setIcon(QMessageBox.Icon.Information)\n            if diag.run() == fin:\n                self.mw.moveToState(\"deckBrowser\")\n                return True\n            self.mw.col.startTimebox()\n        return False\n\n    # Context menu\n    ##########################################################################\n\n    # note the shortcuts listed here also need to be defined above\n    def _contextMenu(self) -> list[Any]:\n        currentFlag = self.card and self.card.user_flag()\n        opts = [\n            [\n                tr.studying_flag_card(),\n                [\n                    [\n                        flag.label,\n                        f\"Ctrl+{flag.index}\",\n                        self.set_flag_func(flag.index),\n                        dict(checked=currentFlag == flag.index),\n                    ]\n                    for flag in self.mw.flags.all()\n                ],\n            ],\n            [tr.studying_bury_card(), \"-\", self.bury_current_card],\n            [\n                tr.actions_with_ellipsis(action=tr.actions_forget_card()),\n                \"Ctrl+Alt+N\",\n                self.forget_current_card,\n            ],\n            [\n                tr.actions_with_ellipsis(action=tr.actions_set_due_date()),\n                \"Ctrl+Shift+D\",\n                self.on_set_due,\n            ],\n            [tr.actions_suspend_card(), \"@\", self.suspend_current_card],\n            [tr.actions_options(), \"O\", self.onOptions],\n            [tr.actions_card_info(), \"I\", self.on_card_info],\n            [tr.actions_previous_card_info(), \"Ctrl+Alt+I\", self.on_previous_card_info],\n            None,\n            [tr.studying_mark_note(), \"*\", self.toggle_mark_on_current_note],\n            [tr.studying_bury_note(), \"=\", self.bury_current_note],\n            [tr.studying_suspend_note(), \"!\", self.suspend_current_note],\n            [\n                tr.actions_with_ellipsis(action=tr.actions_create_copy()),\n                \"Ctrl+Alt+E\",\n                self.on_create_copy,\n            ],\n            [\n                tr.studying_delete_note(),\n                \"Ctrl+Backspace\" if is_mac else \"Ctrl+Delete\",\n                self.delete_current_note,\n            ],\n            None,\n            [tr.actions_replay_audio(), \"R\", self.replayAudio],\n            [tr.studying_pause_audio(), \"5\", self.on_pause_audio],\n            [tr.studying_audio_5s(), \"6\", self.on_seek_backward],\n            [tr.studying_audio_and5s(), \"7\", self.on_seek_forward],\n            [tr.studying_record_own_voice(), \"Shift+V\", self.onRecordVoice],\n            [tr.studying_replay_own_voice(), \"V\", self.onReplayRecorded],\n            [\n                tr.actions_auto_advance(),\n                \"Shift+A\",\n                self.toggle_auto_advance,\n                dict(checked=self.auto_advance_enabled),\n            ],\n        ]\n        return opts\n\n    def showContextMenu(self) -> None:\n        opts = self._contextMenu()\n        m = QMenu(self.mw)\n        self._addMenuItems(m, opts)\n\n        gui_hooks.reviewer_will_show_context_menu(self, m)\n        qtMenuShortcutWorkaround(m)\n        m.popup(QCursor.pos())\n\n    def _addMenuItems(self, m: QMenu, rows: Sequence) -> None:\n        for row in rows:\n            if not row:\n                m.addSeparator()\n                continue\n            if len(row) == 2:\n                subm = m.addMenu(row[0])\n                self._addMenuItems(subm, row[1])\n                qtMenuShortcutWorkaround(subm)\n                continue\n            if len(row) == 4:\n                label, scut, func, opts = row\n            else:\n                label, scut, func = row\n                opts = {}\n            a = m.addAction(label)\n            if scut:\n                a.setShortcut(QKeySequence(scut))\n            if opts.get(\"checked\"):\n                a.setCheckable(True)\n                a.setChecked(True)\n            qconnect(a.triggered, func)\n\n    def onOptions(self) -> None:\n        confirm_deck_then_display_options(self.card)\n\n    def on_previous_card_info(self) -> None:\n        self._previous_card_info.show()\n\n    def on_card_info(self) -> None:\n        self._card_info.show()\n\n    def set_flag_on_current_card(self, desired_flag: int) -> None:\n        # need to toggle off?\n        if self.card.user_flag() == desired_flag:\n            flag = 0\n        else:\n            flag = desired_flag\n\n        set_card_flag(parent=self.mw, card_ids=[self.card.id], flag=flag).success(\n            lambda _: None\n        ).run_in_background()\n\n    def set_flag_func(self, desired_flag: int) -> Callable:\n        return lambda: self.set_flag_on_current_card(desired_flag)\n\n    def toggle_mark_on_current_note(self) -> None:\n        def redraw_mark(out: OpChangesWithCount) -> None:\n            self.card.load()\n            self._update_mark_icon()\n\n        note = self.card.note()\n        if note.has_tag(MARKED_TAG):\n            remove_tags_from_notes(\n                parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG\n            ).success(redraw_mark).run_in_background(initiator=self)\n        else:\n            add_tags_to_notes(\n                parent=self.mw,\n                note_ids=[note.id],\n                space_separated_tags=MARKED_TAG,\n            ).success(redraw_mark).run_in_background(initiator=self)\n\n    def on_set_due(self) -> None:\n        if self.mw.state != \"review\" or not self.card:\n            return\n\n        if op := set_due_date_dialog(\n            parent=self.mw,\n            card_ids=[self.card.id],\n            config_key=Config.String.SET_DUE_REVIEWER,\n        ):\n            op.run_in_background()\n\n    def suspend_current_note(self) -> None:\n        gui_hooks.reviewer_will_suspend_note(self.card.nid)\n        suspend_note(\n            parent=self.mw,\n            note_ids=[self.card.nid],\n        ).success(lambda _: tooltip(tr.studying_note_suspended())).run_in_background()\n\n    def suspend_current_card(self) -> None:\n        gui_hooks.reviewer_will_suspend_card(self.card.id)\n        suspend_cards(\n            parent=self.mw,\n            card_ids=[self.card.id],\n        ).success(lambda _: tooltip(tr.studying_card_suspended())).run_in_background()\n\n    def bury_current_note(self) -> None:\n        gui_hooks.reviewer_will_bury_note(self.card.nid)\n        bury_notes(\n            parent=self.mw,\n            note_ids=[self.card.nid],\n        ).success(\n            lambda res: tooltip(tr.studying_cards_buried(count=res.count))\n        ).run_in_background()\n\n    def bury_current_card(self) -> None:\n        gui_hooks.reviewer_will_bury_card(self.card.id)\n        bury_cards(\n            parent=self.mw,\n            card_ids=[self.card.id],\n        ).success(\n            lambda res: tooltip(tr.studying_cards_buried(count=res.count))\n        ).run_in_background()\n\n    def forget_current_card(self) -> None:\n        if op := forget_cards(\n            parent=self.mw,\n            card_ids=[self.card.id],\n            context=ScheduleCardsAsNew.Context.REVIEWER,\n        ):\n            op.run_in_background()\n\n    def on_create_copy(self) -> None:\n        if self.card:\n            aqt.dialogs.open(\"AddCards\", self.mw).set_note(\n                self.card.note(), self.card.current_deck_id()\n            )\n\n    def delete_current_note(self) -> None:\n        # need to check state because the shortcut is global to the main\n        # window\n        if self.mw.state != \"review\" or not self.card:\n            return\n\n        remove_notes(parent=self.mw, note_ids=[self.card.nid]).run_in_background()\n\n    def onRecordVoice(self) -> None:\n        def after_record(path: str) -> None:\n            self._recordedAudio = path\n            self.onReplayRecorded()\n\n        record_audio(self.mw, self.mw, False, after_record)\n\n    def onReplayRecorded(self) -> None:\n        self._recordedAudio = gui_hooks.reviewer_will_replay_recording(\n            self._recordedAudio\n        )\n        if not self._recordedAudio:\n            tooltip(tr.studying_you_havent_recorded_your_voice_yet())\n            return\n        av_player.play_file(self._recordedAudio)\n\n    def _clear_auto_advance_timers(self) -> None:\n        if self._show_answer_timer:\n            self._show_answer_timer.deleteLater()\n            self._show_answer_timer = None\n        if self._show_question_timer:\n            self._show_question_timer.deleteLater()\n            self._show_question_timer = None\n\n    def toggle_auto_advance(self) -> None:\n        self.auto_advance_enabled = not self.auto_advance_enabled\n        if self.auto_advance_enabled:\n            tooltip(tr.actions_auto_advance_activated())\n        else:\n            tooltip(tr.actions_auto_advance_deactivated())\n        self.auto_advance_if_enabled()\n\n    def auto_advance_if_enabled(self) -> None:\n        if self.state == \"question\":\n            self._auto_advance_to_answer_if_enabled()\n        elif self.state == \"answer\":\n            self._auto_advance_to_question_if_enabled()\n\n    # legacy\n\n    onBuryCard = bury_current_card\n    onBuryNote = bury_current_note\n    onSuspend = suspend_current_note\n    onSuspendCard = suspend_current_card\n    onDelete = delete_current_note\n    onMark = toggle_mark_on_current_note\n    setFlag = set_flag_on_current_card\n\n\n# if the last element is a comment, then the RUN_STATE_MUTATION code\n# breaks due to the comment wrongly commenting out python code.\n# To prevent this we put the js code on a separate line\nRUN_STATE_MUTATION = \"\"\"\nanki.mutateNextCardStates('{key}', async (states, customData, ctx) => {{\n    {js}\n    }}).finally(() => bridgeCommand('statesMutated'));\n\"\"\"\n"
  },
  {
    "path": "qt/aqt/schema_change_tracker.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport enum\n\nfrom aqt import AnkiQt\n\n\nclass Change(enum.Enum):\n    NO_CHANGE = 0\n    BASIC_CHANGE = 1\n    SCHEMA_CHANGE = 2\n\n\nclass ChangeTracker:\n    _changed = Change.NO_CHANGE\n\n    def __init__(self, mw: AnkiQt) -> None:\n        self.mw = mw\n\n    def mark_basic(self) -> None:\n        if self._changed == Change.NO_CHANGE:\n            self._changed = Change.BASIC_CHANGE\n\n    def mark_schema(self) -> bool:\n        \"If false, processing should be aborted.\"\n        if self._changed != Change.SCHEMA_CHANGE:\n            if not self.mw.confirm_schema_modification():\n                return False\n            self._changed = Change.SCHEMA_CHANGE\n        return True\n\n    def changed(self) -> bool:\n        return self._changed != Change.NO_CHANGE\n\n    def set_unchanged(self) -> None:\n        self._changed = Change.NO_CHANGE\n"
  },
  {
    "path": "qt/aqt/sound.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport os\nimport os.path\nimport platform\nimport re\nimport subprocess\nimport sys\nimport time\nimport traceback\nimport wave\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom operator import itemgetter\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom markdown import markdown\n\nimport aqt\nimport aqt.mpv\nimport aqt.qt\nfrom anki.cards import Card\nfrom anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag\nfrom anki.utils import is_lin, is_mac, is_win, namedtmp\nfrom aqt import gui_hooks\nfrom aqt._macos_helper import macos_helper\nfrom aqt.mpv import MPV, MPVBase, MPVCommandError\nfrom aqt.qt import *\nfrom aqt.taskman import TaskManager\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    disable_help_button,\n    restoreGeom,\n    saveGeom,\n    showWarning,\n    startup_info,\n    tooltip,\n    tr,\n)\n\n# AV player protocol\n##########################################################################\n\nOnDoneCallback = Callable[[], None]\n\n\nclass Player(ABC):\n    @abstractmethod\n    def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:\n        \"\"\"Play a file.\n\n        When reimplementing, make sure to call\n        gui_hooks.av_player_did_begin_playing(self, tag)\n        on the main thread after playback begins.\n        \"\"\"\n\n    @abstractmethod\n    def rank_for_tag(self, tag: AVTag) -> int | None:\n        \"\"\"How suited this player is to playing tag.\n\n        AVPlayer will choose the player that returns the highest rank\n        for a given tag.\n\n        If None, this player can not play the tag.\n        \"\"\"\n\n    def stop(self) -> None:\n        \"\"\"Optional.\n\n        If implemented, the player must not call on_done() when the audio is stopped.\"\"\"\n\n    def seek_relative(self, secs: int) -> None:\n        \"Jump forward or back by secs. Optional.\"\n\n    def toggle_pause(self) -> None:\n        \"Optional.\"\n\n    def shutdown(self) -> None:\n        \"Do any cleanup required at program termination. Optional.\"\n\n\nAUDIO_EXTENSIONS = {\n    \"3gp\",\n    \"flac\",\n    \"m4a\",\n    \"mp3\",\n    \"oga\",\n    \"ogg\",\n    \"opus\",\n    \"spx\",\n    \"wav\",\n}\n\n\ndef is_audio_file(fname: str) -> bool:\n    ext = fname.split(\".\")[-1].lower()\n    return ext in AUDIO_EXTENSIONS\n\n\nclass SoundOrVideoPlayer(Player):\n    default_rank = 0\n\n    def rank_for_tag(self, tag: AVTag) -> int | None:\n        if isinstance(tag, SoundOrVideoTag):\n            return self.default_rank\n        else:\n            return None\n\n\nclass SoundPlayer(Player):\n    default_rank = 0\n\n    def rank_for_tag(self, tag: AVTag) -> int | None:\n        if isinstance(tag, SoundOrVideoTag) and is_audio_file(tag.filename):\n            return self.default_rank\n        else:\n            return None\n\n\nclass VideoPlayer(Player):\n    default_rank = 0\n\n    def rank_for_tag(self, tag: AVTag) -> int | None:\n        if isinstance(tag, SoundOrVideoTag) and not is_audio_file(tag.filename):\n            return self.default_rank\n        else:\n            return None\n\n\n# Main playing interface\n##########################################################################\n\n\nclass AVPlayer:\n    players: list[Player] = []\n    # when a new batch of audio is played, should the currently playing\n    # audio be stopped?\n    interrupt_current_audio = True\n    # caller key for the current playback (optional)\n    current_caller: Any = None\n    # whether the last call to play_file_with_caller interrupted another\n    current_caller_interrupted = False\n\n    def __init__(self) -> None:\n        self._enqueued: list[AVTag] = []\n        self.current_player: Player | None = None\n\n    def play_tags(self, tags: list[AVTag]) -> None:\n        \"\"\"Clear the existing queue, then start playing provided tags.\"\"\"\n        self.clear_queue_and_maybe_interrupt()\n        self._enqueued = tags[:]\n        self._play_next_if_idle()\n\n    def append_tags(self, tags: list[AVTag]) -> None:\n        \"\"\"Append provided tags to the queue, then start playing them if the current player is idle.\"\"\"\n        self._enqueued.extend(tags)\n        self._play_next_if_idle()\n\n    def queue_is_empty(self) -> bool:\n        return not bool(self._enqueued)\n\n    def stop_and_clear_queue(self) -> None:\n        self._enqueued = []\n        self._stop_if_playing()\n\n    def stop_and_clear_queue_if_caller(self, caller: Any) -> None:\n        if caller == self.current_caller:\n            self.stop_and_clear_queue()\n\n    def clear_queue_and_maybe_interrupt(self) -> None:\n        self._enqueued = []\n        if self.interrupt_current_audio:\n            self._stop_if_playing()\n\n    def play_file(self, filename: str) -> None:\n        \"\"\"Play the provided path.\n\n        SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,\n        you should only ever use the os.path.basename(filename) as the filename.\"\"\"\n        self.play_tags([SoundOrVideoTag(filename=filename)])\n\n    def play_file_with_caller(self, filename: str, caller: Any) -> None:\n        \"\"\"Play the provided path, noting down the caller.\n\n        SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,\n        you should only ever use the os.path.basename(filename) as the filename.\"\"\"\n        if self.current_caller:\n            self.current_caller_interrupted = True\n        self.current_caller = caller\n        self.play_file(filename)\n\n    def insert_file(self, filename: str) -> None:\n        \"\"\"Place the provided path at the top of the playlist.\n\n        SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,\n        you should only ever use the os.path.basename(filename) as the filename.\"\"\"\n        self._enqueued.insert(0, SoundOrVideoTag(filename=filename))\n        self._play_next_if_idle()\n\n    def toggle_pause(self) -> None:\n        if self.current_player:\n            self.current_player.toggle_pause()\n\n    def seek_relative(self, secs: int) -> None:\n        if self.current_player:\n            self.current_player.seek_relative(secs)\n\n    def shutdown(self) -> None:\n        self.stop_and_clear_queue()\n        for player in self.players:\n            player.shutdown()\n        self.players.clear()\n\n    def _stop_if_playing(self) -> None:\n        if self.current_player:\n            self.current_player.stop()\n\n    def _pop_next(self) -> AVTag | None:\n        if not self._enqueued:\n            return None\n        return self._enqueued.pop(0)\n\n    def _on_play_finished(self) -> None:\n        if not self.current_caller_interrupted:\n            self.current_caller = None\n        self.current_caller_interrupted = False\n        gui_hooks.av_player_did_end_playing(self.current_player)\n        self.current_player = None\n        self._play_next_if_idle()\n\n    def _play_next_if_idle(self) -> None:\n        if self.current_player:\n            return\n\n        next = self._pop_next()\n        if next is not None:\n            self._play(next)\n\n    def _play(self, tag: AVTag) -> None:\n        best_player = self._best_player_for_tag(tag)\n        if best_player:\n            self.current_player = best_player\n            gui_hooks.av_player_will_play(tag)\n            self.current_player.play(tag, self._on_play_finished)\n        else:\n            tooltip(f\"no players found for {tag}\")\n\n    def _best_player_for_tag(self, tag: AVTag) -> Player | None:\n        ranked = []\n        for p in self.players:\n            rank = p.rank_for_tag(tag)\n            if rank is not None:\n                ranked.append((rank, p))\n\n        ranked.sort(key=itemgetter(0))\n\n        if ranked:\n            return ranked[-1][1]\n        else:\n            return None\n\n\nav_player = AVPlayer()\n\n# Packaged commands\n##########################################################################\n\n\n# return modified command array that points to bundled command, and return\n# required environment\ndef _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:\n    cmd = cmd[:]\n    env = os.environ.copy()\n    # keep LD_LIBRARY_PATH when in snap environment\n    if \"LD_LIBRARY_PATH\" in env and \"SNAP\" not in env:\n        del env[\"LD_LIBRARY_PATH\"]\n\n    # Try to find binary in anki-audio package for Windows/Mac\n    if is_win or is_mac:\n        try:\n            import anki_audio\n\n            audio_pkg_path = Path(anki_audio.__file__).parent\n            if is_win:\n                packaged_path = audio_pkg_path / (cmd[0] + \".exe\")\n            else:  # is_mac\n                packaged_path = audio_pkg_path / cmd[0]\n\n            if packaged_path.exists():\n                cmd[0] = str(packaged_path)\n                return cmd, env\n        except ImportError:\n            # anki-audio not available, fall back to old behavior\n            pass\n\n    packaged_path = Path(sys.prefix) / cmd[0]\n    if packaged_path.exists():\n        cmd[0] = str(packaged_path)\n\n    return cmd, env\n\n\n# Platform hacks\n##########################################################################\n\n# legacy global for add-ons\nsi = startup_info()\n\n\n# osx throws interrupted system call errors frequently\ndef retryWait(proc: subprocess.Popen) -> int:\n    while 1:\n        try:\n            return proc.wait()\n        except OSError:\n            continue\n\n\n# Simple player implementations\n##########################################################################\n\n\nclass SimpleProcessPlayer(Player):\n    \"A player that invokes a new process for each tag to play.\"\n\n    args: list[str] = []\n    env: dict[str, str] | None = None\n\n    def __init__(self, taskman: TaskManager, media_folder: str | None = None) -> None:\n        self._taskman = taskman\n        self._media_folder = media_folder\n        self._terminate_flag = False\n        self._process: subprocess.Popen | None = None\n        self._warned_about_missing_player = False\n\n    def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:\n        self._terminate_flag = False\n        self._taskman.run_in_background(\n            lambda: self._play(tag),\n            lambda res: self._on_done(res, on_done),\n            uses_collection=False,\n        )\n\n    def stop(self) -> None:\n        self._terminate_flag = True\n\n    # note: mplayer implementation overrides this\n    def _play(self, tag: AVTag) -> None:\n        assert isinstance(tag, SoundOrVideoTag)\n        self._process = subprocess.Popen(\n            self.args + [\"--\", tag.path(self._media_folder)],\n            env=self.env,\n            cwd=self._media_folder,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        self._wait_for_termination(tag)\n\n    def _wait_for_termination(self, tag: AVTag) -> None:\n        self._taskman.run_on_main(\n            lambda: gui_hooks.av_player_did_begin_playing(self, tag)\n        )\n\n        while True:\n            # should we abort playing?\n            if self._terminate_flag:\n                self._process.terminate()\n                self._process.wait(1)\n                try:\n                    if self._process.stdin:\n                        self._process.stdin.close()\n                except Exception as e:\n                    print(\"unable to close stdin:\", e)\n                self._process = None\n                return\n\n            # wait for completion\n            try:\n                self._process.wait(0.1)\n                if self._process.returncode != 0:\n                    print(f\"player got return code: {self._process.returncode}\")\n                try:\n                    if self._process.stdin:\n                        self._process.stdin.close()\n                except Exception as e:\n                    print(\"unable to close stdin:\", e)\n                self._process = None\n                return\n            except subprocess.TimeoutExpired:\n                # process still running, repeat loop\n                pass\n\n    def _on_done(self, ret: Future, cb: OnDoneCallback) -> None:\n        try:\n            ret.result()\n        except FileNotFoundError:\n            if not self._warned_about_missing_player:\n                showWarning(tr.media_sound_and_video_on_cards_will())\n                self._warned_about_missing_player = True\n            # must call cb() here, as we don't currently have another way\n            # to flag to av_player that we've stopped\n        cb()\n\n\nclass SimpleMpvPlayer(SimpleProcessPlayer, VideoPlayer):\n    default_rank = 1\n\n    args, env = _packagedCmd(\n        [\n            \"mpv\",\n            \"--no-terminal\",\n            \"--force-window=no\",\n            \"--ontop\",\n            \"--audio-display=no\",\n            \"--keep-open=no\",\n            \"--input-media-keys=no\",\n            \"--autoload-files=no\",\n            \"--no-ytdl\",\n        ]\n    )\n\n    def __init__(\n        self, taskman: TaskManager, base_folder: str, media_folder: str\n    ) -> None:\n        super().__init__(taskman, media_folder)\n        self.args += [f\"--config-dir={base_folder}\"]\n\n\nclass SimpleMplayerPlayer(SimpleProcessPlayer, SoundOrVideoPlayer):\n    args, env = _packagedCmd([\"mplayer\", \"-really-quiet\", \"-noautosub\"])\n    if is_win:\n        args += [\"-ao\", \"win32\"]\n\n\n# MPV\n##########################################################################\n\n\nclass MpvManager(MPV, SoundOrVideoPlayer):\n    if not is_lin:\n        default_argv = MPVBase.default_argv + [\n            \"--input-media-keys=no\",\n        ]\n\n    def __init__(self, base_path: str, media_folder: str) -> None:\n        self.media_folder = media_folder\n        mpvPath, self.popenEnv = _packagedCmd([\"mpv\"])\n        self.executable = mpvPath[0]\n        self._on_done: OnDoneCallback | None = None\n        self.default_argv += [f\"--config-dir={base_path}\"]\n        super().__init__(window_id=None, debug=False)\n\n    def on_init(self) -> None:\n        # if mpv dies and is restarted, tell Anki the\n        # current file is done\n        if self._on_done:\n            self._on_done()\n\n        m = re.search(r\"(\\d+)\\.(\\d+)\\.(\\d+)\", self.get_property(\"mpv-version\"))\n        if m:\n            self.mpv_version = (int(m[1]), int(m[2]), int(m[3]))\n        else:\n            self.mpv_version = None\n\n        try:\n            self.command(\"keybind\", \"q\", \"stop\")\n            self.command(\"keybind\", \"Q\", \"stop\")\n            self.command(\"keybind\", \"CLOSE_WIN\", \"stop\")\n            self.command(\"keybind\", \"ctrl+w\", \"stop\")\n            self.command(\"keybind\", \"ctrl+c\", \"stop\")\n        except MPVCommandError:\n            print(\"mpv too old for key rebinding\")\n\n    def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:\n        assert isinstance(tag, SoundOrVideoTag)\n        self._on_done = on_done\n        path = tag.path(self.media_folder)\n\n        if self.mpv_version is None or self.mpv_version >= (0, 38, 0):\n            self.command(\"loadfile\", path, \"replace\", -1, \"pause=no\")\n        else:\n            self.command(\"loadfile\", path, \"replace\", \"pause=no\")\n        gui_hooks.av_player_did_begin_playing(self, tag)\n\n    def stop(self) -> None:\n        self.command(\"stop\")\n\n    def toggle_pause(self) -> None:\n        self.command(\"cycle\", \"pause\")\n\n    def seek_relative(self, secs: int) -> None:\n        self.command(\"seek\", secs, \"relative\")\n\n    def on_property_idle_active(self, value: bool) -> None:\n        if value and self._on_done:\n            from aqt import mw\n\n            mw.taskman.run_on_main(self._on_done)\n\n    def shutdown(self) -> None:\n        self.close()\n\n    # Legacy, not used\n    ##################################################\n\n    togglePause = toggle_pause\n    seekRelative = seek_relative\n\n    def queueFile(self, file: str) -> None:\n        return\n\n    def clearQueue(self) -> None:\n        return\n\n\n# Mplayer in slave mode\n##########################################################################\n\n\nclass SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer):\n    def __init__(self, taskman: TaskManager, media_folder: str) -> None:\n        self.media_folder = media_folder\n        super().__init__(taskman, media_folder)\n        self.args.append(\"-slave\")\n\n    def _play(self, tag: AVTag) -> None:\n        assert isinstance(tag, SoundOrVideoTag)\n\n        self._process = subprocess.Popen(\n            self.args + [\"--\", tag.path(self.media_folder)],\n            env=self.env,\n            cwd=self.media_folder,\n            stdin=subprocess.PIPE,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n            startupinfo=startup_info(),\n        )\n        self._wait_for_termination(tag)\n\n    def command(self, *args: Any) -> None:\n        \"\"\"Send a command over the slave interface.\n\n        The trailing newline is automatically added.\"\"\"\n        str_args = [str(x) for x in args]\n        if self._process:\n            self._process.stdin.write(\" \".join(str_args).encode(\"utf8\") + b\"\\n\")\n            self._process.stdin.flush()\n\n    def seek_relative(self, secs: int) -> None:\n        self.command(\"seek\", secs, 0)\n\n    def toggle_pause(self) -> None:\n        self.command(\"pause\")\n\n\n# MP3 transcoding\n##########################################################################\n\n\ndef _encode_mp3(src_wav: str, dst_mp3: str) -> None:\n    cmd = [\"lame\", src_wav, dst_mp3, \"--noreplaygain\", \"--quiet\"]\n    cmd, env = _packagedCmd(cmd)\n    try:\n        retcode = retryWait(subprocess.Popen(cmd, startupinfo=startup_info(), env=env))\n    except Exception as e:\n        raise Exception(tr.media_error_running(val=\" \".join(cmd))) from e\n    if retcode != 0:\n        raise Exception(tr.media_error_running(val=\" \".join(cmd)))\n\n    os.unlink(src_wav)\n\n\ndef encode_mp3(mw: aqt.AnkiQt, src_wav: str, on_done: Callable[[str], None]) -> None:\n    \"Encode the provided wav file to .mp3, and call on_done() with the path.\"\n    dst_mp3 = src_wav.replace(\".wav\", \"%d.mp3\" % time.time())\n\n    def _on_done(fut: Future) -> None:\n        if exc := fut.exception():\n            print(exc)\n            showWarning(tr.editing_couldnt_record_audio_have_you_installed())\n            return\n\n        on_done(dst_mp3)\n\n    mw.taskman.run_in_background(\n        lambda: _encode_mp3(src_wav, dst_mp3), _on_done, uses_collection=False\n    )\n\n\n# Recording interface\n##########################################################################\n\n\nclass Recorder(ABC):\n    # seconds to wait before recording\n    STARTUP_DELAY = 0.3\n\n    def __init__(self, output_path: str) -> None:\n        self.output_path = output_path\n\n    def start(self, on_done: Callable[[], None]) -> None:\n        \"Start recording, then call on_done() when started.\"\n        self._started_at = time.time()\n        on_done()\n\n    def stop(self, on_done: Callable[[str], None]) -> None:\n        \"Stop recording, then call on_done() when finished.\"\n        on_done(self.output_path)\n\n    def duration(self) -> float:\n        \"Seconds since recording started.\"\n        return time.time() - self._started_at\n\n    def on_timer(self) -> None:\n        \"Will be called periodically.\"\n\n\n# QAudioInput recording\n##########################################################################\n\n\nclass QtAudioInputRecorder(Recorder):\n    def __init__(self, output_path: str, mw: aqt.AnkiQt, parent: QWidget) -> None:\n        super().__init__(output_path)\n\n        self.mw = mw\n        self._parent = parent\n\n        from PyQt6.QtMultimedia import QAudioSource, QMediaDevices  # type: ignore\n\n        # Get the default audio input device\n        device = QMediaDevices.defaultAudioInput()\n\n        # Try to use Int16 format first (avoids conversion)\n        preferred_format = device.preferredFormat()\n        int16_format = preferred_format\n        int16_format.setSampleFormat(preferred_format.SampleFormat.Int16)\n\n        if device.isFormatSupported(int16_format):\n            # Use Int16 if supported\n            format = int16_format\n        else:\n            # Fall back to device's preferred format\n            format = preferred_format\n\n        # Create the audio source with the chosen format\n        source = QAudioSource(device, format, parent)\n\n        # Store the actual format being used\n        self._format = source.format()\n        self._audio_input = source\n\n    def _convert_float_to_int16(self, float_buffer: bytearray) -> bytes:\n        \"\"\"Convert float32 audio samples to int16 format for WAV output.\"\"\"\n        import struct\n\n        float_count = len(float_buffer) // 4  # 4 bytes per float32\n        floats = struct.unpack(f\"{float_count}f\", float_buffer)\n\n        # Convert to int16 range, clipping and scaling in one step\n        int16_samples = [\n            max(-32768, min(32767, int(max(-1.0, min(1.0, f)) * 32767))) for f in floats\n        ]\n\n        return struct.pack(f\"{len(int16_samples)}h\", *int16_samples)\n\n    def start(self, on_done: Callable[[], None]) -> None:\n        self._iodevice = self._audio_input.start()\n        self._buffer = bytearray()\n        qconnect(self._iodevice.readyRead, self._on_read_ready)\n        super().start(on_done)\n\n    def _on_read_ready(self) -> None:\n        self._buffer.extend(cast(bytes, self._iodevice.readAll()))\n\n    def stop(self, on_done: Callable[[str], None]) -> None:\n        from PyQt6.QtMultimedia import QAudio\n\n        def on_stop_timer() -> None:\n            # read anything remaining in buffer & stop\n            self._on_read_ready()\n            self._audio_input.stop()\n\n            if (err := self._audio_input.error()) != QAudio.Error.NoError:\n                showWarning(f\"recording failed: {err}\")\n                return\n\n            def write_file() -> None:\n                from PyQt6.QtMultimedia import QAudioFormat\n\n                # swallow the first 300ms to allow audio device to quiesce\n                bytes_per_frame = self._format.bytesPerFrame()\n                frames_to_skip = int(self._format.sampleRate() * self.STARTUP_DELAY)\n                bytes_to_skip = frames_to_skip * bytes_per_frame\n\n                if len(self._buffer) <= bytes_to_skip:\n                    return\n                self._buffer = self._buffer[bytes_to_skip:]\n\n                # Check if we need to convert float samples to int16\n                if self._format.sampleFormat() == QAudioFormat.SampleFormat.Float:\n                    audio_data = self._convert_float_to_int16(self._buffer)\n                    sample_width = 2  # int16 is 2 bytes\n                else:\n                    # For integer formats, use the data as-is\n                    audio_data = bytes(self._buffer)\n                    sample_width = self._format.bytesPerSample()\n\n                # write out the wave file with the correct format parameters\n                wf = wave.open(self.output_path, \"wb\")\n                wf.setnchannels(self._format.channelCount())\n                wf.setsampwidth(sample_width)\n                wf.setframerate(self._format.sampleRate())\n                wf.writeframes(audio_data)\n                wf.close()\n\n            def and_then(fut: Future) -> None:\n                fut.result()\n                Recorder.stop(self, on_done)\n\n            self.mw.taskman.run_in_background(\n                write_file, and_then, uses_collection=False\n            )\n\n        # schedule the stop for half a second in the future,\n        # to avoid truncating the end of the recording\n        self._stop_timer = t = QTimer(self._parent)\n        t.timeout.connect(on_stop_timer)  # type: ignore\n        t.setSingleShot(True)\n        t.start(500)\n\n\n# Native macOS recording\n##########################################################################\n\n\nclass NativeMacRecorder(Recorder):\n    def __init__(self, output_path: str) -> None:\n        super().__init__(output_path)\n        self._error: str | None = None\n\n    def _on_error(self, msg: str) -> None:\n        self._error = msg\n\n    def start(self, on_done: Callable[[], None]) -> None:\n        self._error = None\n        assert macos_helper\n        macos_helper.start_wav_record(self.output_path, self._on_error)\n        super().start(on_done)\n\n    def stop(self, on_done: Callable[[str], None]) -> None:\n        assert macos_helper\n        macos_helper.end_wav_record()\n        Recorder.stop(self, on_done)\n\n\n# Recording dialog\n##########################################################################\n\n\nclass RecordDialog(QDialog):\n    _recorder: Recorder\n\n    def __init__(\n        self,\n        parent: QWidget,\n        mw: aqt.AnkiQt,\n        on_success: Callable[[str], None],\n    ):\n        QDialog.__init__(self, parent)\n        self._parent = parent\n        self.mw = mw\n        self._on_success = on_success\n        disable_help_button(self)\n\n        self._start_recording()\n        self._setup_dialog()\n\n    def _setup_dialog(self) -> None:\n        self.setWindowTitle(\"Anki\")\n        icon = QLabel()\n        qicon = theme_manager.icon_from_resources(\"icons:media-record.svg\")\n        icon.setPixmap(qicon.pixmap(60, 60))\n        self.label = QLabel(\"...\")\n        hbox = QHBoxLayout()\n        hbox.addWidget(icon)\n        hbox.addWidget(self.label)\n        v = QVBoxLayout()\n        v.addLayout(hbox)\n        buts = (\n            QDialogButtonBox.StandardButton.Save\n            | QDialogButtonBox.StandardButton.Cancel\n        )\n        b = QDialogButtonBox(buts)  # type: ignore\n        v.addWidget(b)\n        self.setLayout(v)\n        save_button = b.button(QDialogButtonBox.StandardButton.Save)\n        save_button.setDefault(True)\n        save_button.setAutoDefault(True)\n        qconnect(save_button.clicked, self.accept)\n        cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel)\n        cancel_button.setDefault(False)\n        cancel_button.setAutoDefault(False)\n        qconnect(cancel_button.clicked, self.reject)\n        restoreGeom(self, \"audioRecorder2\")\n        self.show()\n\n    def _save_diag(self) -> None:\n        saveGeom(self, \"audioRecorder2\")\n\n    def _start_recording(self) -> None:\n        if macos_helper and platform.machine() == \"arm64\":\n            self._recorder = NativeMacRecorder(\n                namedtmp(\"rec.wav\"),\n            )\n        else:\n            self._recorder = QtAudioInputRecorder(\n                namedtmp(\"rec.wav\"), self.mw, self._parent\n            )\n        self._recorder.start(self._start_timer)\n\n    def _start_timer(self) -> None:\n        self._timer = t = QTimer(self._parent)\n        t.timeout.connect(self._on_timer)  # type: ignore\n        t.setSingleShot(False)\n        t.start(100)\n\n    def _on_timer(self) -> None:\n        self._recorder.on_timer()\n        duration = self._recorder.duration()\n        self.label.setText(tr.media_recordingtime(secs=f\"{duration:0.1f}\"))\n\n    def accept(self) -> None:\n        self._timer.stop()\n\n        try:\n            self._save_diag()\n            self._recorder.stop(self._on_success)\n        finally:\n            QDialog.accept(self)\n\n    def reject(self) -> None:\n        self._timer.stop()\n\n        def cleanup(out: str) -> None:\n            os.unlink(out)\n\n        try:\n            self._recorder.stop(cleanup)\n        finally:\n            QDialog.reject(self)\n\n\ndef record_audio(\n    parent: QWidget, mw: aqt.AnkiQt, encode: bool, on_done: Callable[[str], None]\n) -> None:\n    def after_record(path: str) -> None:\n        if not encode:\n            on_done(path)\n        else:\n            encode_mp3(mw, path, on_done)\n\n    try:\n        _diag = RecordDialog(parent, mw, after_record)\n    except Exception as e:\n        err_str = str(e)\n        showWarning(markdown(tr.qt_misc_unable_to_record(error=err_str)))\n\n\n# Legacy audio interface\n##########################################################################\n# these will be removed in the future\n\n\ndef clearAudioQueue() -> None:\n    av_player.stop_and_clear_queue()\n\n\ndef play(filename: str) -> None:\n    av_player.play_file(filename)\n\n\ndef playFromText(text: Any) -> None:\n    print(\"playFromText() deprecated\")\n\n\n# legacy globals\n_player = play\n_queueEraser = clearAudioQueue\nmpvManager: MpvManager | None = None\n\n# add everything from this module into anki.sound for backwards compat\n_exports = [i for i in locals().items() if not i[0].startswith(\"__\")]\nfor k, v in _exports:\n    sys.modules[\"anki.sound\"].__dict__[k] = v\n\n# Tag handling\n##########################################################################\n\n\ndef av_refs_to_play_icons(text: str) -> str:\n    \"\"\"Add play icons into the HTML.\n\n    When clicked, the icon will call eg pycmd('play:q:1').\n    \"\"\"\n\n    def repl(match: re.Match) -> str:\n        return f\"\"\"\n<a class=\"replay-button soundLink\" href=# onclick=\"pycmd('{match.group(1)}'); return false;\" draggable=\"false\">\n    <svg class=\"playImage\" viewBox=\"0 0 64 64\" version=\"1.1\">\n        <circle cx=\"32\" cy=\"32\" r=\"29\" />\n        <path d=\"M56.502,32.301l-37.502,20.101l0.329,-40.804l37.173,20.703Z\" />\n    </svg>\n</a>\"\"\"\n\n    return AV_REF_RE.sub(repl, text)\n\n\ndef play_clicked_audio(pycmd: str, card: Card) -> None:\n    \"\"\"eg. if pycmd is 'play:q:0', play the first audio on the question side.\"\"\"\n    play, context, str_idx = pycmd.split(\":\")\n    idx = int(str_idx)\n    if context == \"q\":\n        tags = card.question_av_tags()\n    else:\n        tags = card.answer_av_tags()\n    av_player.play_tags([tags[idx]])\n\n\n# Init defaults\n##########################################################################\n\n\ndef setup_audio(taskman: TaskManager, base_folder: str, media_folder: str) -> None:\n    # legacy global var\n    global mpvManager\n\n    try:\n        mpvManager = MpvManager(base_folder, media_folder)\n    except FileNotFoundError:\n        print(\"mpv not found, reverting to mplayer\")\n    except aqt.mpv.MPVProcessError:\n        print(traceback.format_exc())\n        print(\"mpv too old or failed to open, reverting to mplayer\")\n\n    if mpvManager is not None:\n        av_player.players.append(mpvManager)\n\n        if is_win:\n            mpvPlayer = SimpleMpvPlayer(taskman, base_folder, media_folder)\n            av_player.players.append(mpvPlayer)\n    else:\n        mplayer = SimpleMplayerSlaveModePlayer(taskman, media_folder)\n        av_player.players.append(mplayer)\n\n    # tts support\n    if is_mac:\n        from aqt.tts import MacTTSPlayer\n\n        av_player.players.append(MacTTSPlayer(taskman))\n    elif is_win:\n        from aqt.tts import WindowsTTSPlayer\n\n        av_player.players.append(WindowsTTSPlayer(taskman))\n\n        if platform.release() == \"10\":\n            from aqt.tts import WindowsRTTTSFilePlayer\n\n            # If Windows 10, ensure it's October 2018 update or later\n            if int(platform.version().split(\".\")[-1]) >= 17763:\n                av_player.players.append(WindowsRTTTSFilePlayer(taskman))\n\n\ndef cleanup_audio() -> None:\n    av_player.shutdown()\n"
  },
  {
    "path": "qt/aqt/stats.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable\nfrom typing import Any\n\nimport aqt\nimport aqt.forms\nimport aqt.main\nfrom anki.decks import DeckId\nfrom anki.utils import is_mac\nfrom aqt import gui_hooks\nfrom aqt.operations.deck import set_current_deck\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\nfrom aqt.utils import (\n    disable_help_button,\n    getSaveFile,\n    maybeHideClose,\n    restoreGeom,\n    saveGeom,\n    tooltip,\n    tr,\n)\nfrom aqt.webview import LegacyStatsWebView\n\n\nclass NewDeckStats(QDialog):\n    \"\"\"New deck stats.\"\"\"\n\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        mw.garbage_collect_on_dialog_finish(self)\n        self.mw = mw\n        self.name = \"deckStats\"\n        self.period = 0\n        self.form = aqt.forms.stats.Ui_Dialog()\n        self.oldPos = None\n        self.wholeCollection = False\n        self.setMinimumWidth(700)\n        disable_help_button(self)\n        f = self.form\n        f.setupUi(self)\n        f.groupBox.setVisible(False)\n        f.groupBox_2.setVisible(False)\n        if not is_mac:\n            f.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)\n        restoreGeom(self, self.name, default_size=(800, 800))\n\n        from aqt.deckchooser import DeckChooser\n\n        self.deck_chooser = DeckChooser(\n            self.mw,\n            f.deckArea,\n            on_deck_changed=self.on_deck_changed,\n            dyn=True,  # include filtered decks\n        )\n\n        b = f.buttonBox.addButton(\n            tr.statistics_save_pdf(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n        assert b is not None\n        qconnect(b.clicked, self.saveImage)\n        b.setAutoDefault(False)\n        b = f.buttonBox.button(QDialogButtonBox.StandardButton.Close)\n        assert b is not None\n        b.setAutoDefault(False)\n        maybeHideClose(self.form.buttonBox)\n        gui_hooks.stats_dialog_will_show(self)\n        self.form.web.hide_while_preserving_layout()\n        self.show()\n        self.refresh()\n        self.form.web.set_bridge_command(self._on_bridge_cmd, self)\n        self.activateWindow()\n\n    def reject(self) -> None:\n        self.deck_chooser.cleanup()\n        self.form.web.cleanup()\n        self.form.web = None  # type: ignore\n        saveGeom(self, self.name)\n        aqt.dialogs.markClosed(\"NewDeckStats\")\n        QDialog.reject(self)\n\n    def closeWithCallback(self, callback: Callable[[], None]) -> None:\n        self.reject()\n        callback()\n\n    def on_deck_changed(self, deck_id: int) -> None:\n        set_current_deck(parent=self, deck_id=DeckId(deck_id)).success(\n            lambda _: self.refresh()\n        ).run_in_background()\n\n    def _imagePath(self) -> str | None:\n        name = time.strftime(\"-%Y-%m-%d@%H-%M-%S.pdf\", time.localtime(time.time()))\n        name = f\"anki-{tr.statistics_stats()}{name}\"\n        file = getSaveFile(\n            self,\n            title=tr.statistics_save_pdf(),\n            dir_description=\"stats\",\n            key=\"stats\",\n            ext=\".pdf\",\n            fname=name,\n        )\n        return file\n\n    def saveImage(self) -> None:\n        path = self._imagePath()\n        if not path:\n            return\n\n        # When scrolled down in dark mode, the top of the page in the\n        # final PDF will have a white background, making the text and graphs\n        # unreadable. A simple fix for now is to scroll to the top of the\n        # page first.\n        def after_scroll(arg: Any) -> None:\n            form_web_page = self.form.web.page()\n            assert form_web_page is not None\n            form_web_page.printToPdf(path)\n            tooltip(tr.statistics_saved())\n\n        self.form.web.evalWithCallback(\"window.scrollTo(0, 0);\", after_scroll)\n\n    # legacy add-ons\n    def changePeriod(self, n: Any) -> None:\n        pass\n\n    def changeScope(self, type: Any) -> None:\n        pass\n\n    def _on_bridge_cmd(self, cmd: str) -> bool:\n        if cmd.startswith(\"browserSearch\"):\n            _, query = cmd.split(\":\", 1)\n            browser = aqt.dialogs.open(\"Browser\", self.mw)\n            browser.search_for(query)\n\n        return False\n\n    def refresh(self) -> None:\n        self.form.web.load_sveltekit_page(\"graphs\")\n\n\nclass DeckStats(QDialog):\n    \"\"\"Legacy deck stats, used by some add-ons.\"\"\"\n\n    def __init__(self, mw: aqt.main.AnkiQt) -> None:\n        QDialog.__init__(self, mw, Qt.WindowType.Window)\n        mw.garbage_collect_on_dialog_finish(self)\n        self.mw = mw\n        self.name = \"deckStats\"\n        self.period = 0\n        self.form = aqt.forms.stats.Ui_Dialog()\n        # Hack: Switch out web views dynamically to avoid maintaining multiple\n        # Qt forms for different versions of the stats dialog.\n        self.form.web = LegacyStatsWebView(self.mw)\n        self.oldPos = None\n        self.wholeCollection = False\n        self.setMinimumWidth(700)\n        disable_help_button(self)\n        f = self.form\n        if theme_manager.night_mode and not theme_manager.macos_dark_mode():\n            # the grouping box renders incorrectly in the fusion theme. 5.9+\n            # 5.13 behave differently to 5.14, but it looks bad in either case,\n            # and adjusting the top margin makes the 'save PDF' button show in\n            # the wrong place, so for now we just disable the border instead\n            self.setStyleSheet(\"QGroupBox { border: 0; }\")\n        f.setupUi(self)\n        restoreGeom(self, self.name)\n        b = f.buttonBox.addButton(\n            tr.statistics_save_pdf(), QDialogButtonBox.ButtonRole.ActionRole\n        )\n        assert b is not None\n        qconnect(b.clicked, self.saveImage)\n        b.setAutoDefault(False)\n        qconnect(f.groups.clicked, lambda: self.changeScope(\"deck\"))\n        f.groups.setShortcut(\"g\")\n        qconnect(f.all.clicked, lambda: self.changeScope(\"collection\"))\n        qconnect(f.month.clicked, lambda: self.changePeriod(0))\n        qconnect(f.year.clicked, lambda: self.changePeriod(1))\n        qconnect(f.life.clicked, lambda: self.changePeriod(2))\n        maybeHideClose(self.form.buttonBox)\n        gui_hooks.stats_dialog_old_will_show(self)\n        self.show()\n        self.refresh()\n        self.activateWindow()\n\n    def reject(self) -> None:\n        self.form.web.cleanup()\n        self.form.web = None  # type: ignore\n        saveGeom(self, self.name)\n        aqt.dialogs.markClosed(\"DeckStats\")\n        QDialog.reject(self)\n\n    def closeWithCallback(self, callback: Callable[[], None]) -> None:\n        self.reject()\n        callback()\n\n    def _imagePath(self) -> str | None:\n        name = time.strftime(\"-%Y-%m-%d@%H-%M-%S.pdf\", time.localtime(time.time()))\n        name = f\"anki-{tr.statistics_stats()}{name}\"\n        file = getSaveFile(\n            self,\n            title=tr.statistics_save_pdf(),\n            dir_description=\"stats\",\n            key=\"stats\",\n            ext=\".pdf\",\n            fname=name,\n        )\n        return file\n\n    def saveImage(self) -> None:\n        path = self._imagePath()\n        if not path:\n            return\n        form_web_page = self.form.web.page()\n        assert form_web_page is not None\n        form_web_page.printToPdf(path)\n        tooltip(tr.statistics_saved())\n\n    def changePeriod(self, n: int) -> None:\n        self.period = n\n        self.refresh()\n\n    def changeScope(self, type: str) -> None:\n        self.wholeCollection = type == \"collection\"\n        self.refresh()\n\n    def refresh(self) -> None:\n        self.mw.progress.start(parent=self)\n        stats = self.mw.col.stats()\n        stats.wholeCollection = self.wholeCollection\n        self.report = stats.report(type=self.period)\n        self.form.web.stdHtml(\n            f\"<html><body>{self.report}</body></html>\",\n            js=[\"js/vendor/jquery.min.js\", \"js/vendor/plot.js\"],\n            context=self,\n        )\n        self.mw.progress.finish()\n"
  },
  {
    "path": "qt/aqt/studydeck.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable\n\nimport aqt\nimport aqt.forms\nimport aqt.operations\nfrom anki.collection import OpChangesWithId\nfrom anki.decks import DeckId\nfrom aqt import gui_hooks\nfrom aqt.operations.deck import add_deck_dialog\nfrom aqt.qt import *\nfrom aqt.utils import (\n    HelpPage,\n    HelpPageArgument,\n    disable_help_button,\n    openHelp,\n    restoreGeom,\n    saveGeom,\n    shortcut,\n    showInfo,\n    tr,\n)\n\n\nclass StudyDeck(QDialog):\n    def __init__(\n        self,\n        mw: aqt.AnkiQt,\n        names: Callable[[], list[str]] | None = None,\n        accept: str | None = None,\n        title: str | None = None,\n        help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS,\n        current: str | None = None,\n        cancel: bool = True,\n        parent: QWidget | None = None,\n        dyn: bool = False,\n        buttons: list[str | QPushButton] | None = None,\n        geomKey: str = \"default\",\n        callback: Callable[[StudyDeck], None] | None = None,\n    ) -> None:\n        super().__init__(parent)\n        if not parent:\n            mw.garbage_collect_on_dialog_finish(self)\n        self.mw = mw\n        self.form = aqt.forms.studydeck.Ui_Dialog()\n        self.form.setupUi(self)\n        self.form.filter.installEventFilter(self)\n        gui_hooks.state_did_reset.append(self.onReset)\n        self.geomKey = f\"studyDeck-{geomKey}\"\n        restoreGeom(self, self.geomKey)\n        disable_help_button(self)\n        if not cancel:\n            self.form.buttonBox.removeButton(\n                self.form.buttonBox.button(QDialogButtonBox.StandardButton.Cancel)\n            )\n        if buttons is not None:\n            for button_or_label in buttons:\n                self.form.buttonBox.addButton(\n                    button_or_label, QDialogButtonBox.ButtonRole.ActionRole\n                )\n        else:\n            b = QPushButton(tr.actions_add())\n            b.setShortcut(QKeySequence(\"Ctrl+N\"))\n            b.setToolTip(shortcut(tr.decks_add_new_deck_ctrlandn()))\n            self.form.buttonBox.addButton(b, QDialogButtonBox.ButtonRole.ActionRole)\n            qconnect(b.clicked, self.onAddDeck)\n        if title:\n            self.setWindowTitle(title)\n        if not names:\n            names_ = [\n                d.name\n                for d in self.mw.col.decks.all_names_and_ids(\n                    include_filtered=dyn, skip_empty_default=True\n                )\n            ]\n            self.nameFunc = None\n            self.origNames = names_\n        else:\n            self.nameFunc = names\n            self.origNames = names()\n        self.name: str | None = None\n        self.form.buttonBox.addButton(\n            accept or tr.decks_study(), QDialogButtonBox.ButtonRole.AcceptRole\n        )\n        self.setModal(True)\n        qconnect(self.form.buttonBox.helpRequested, lambda: openHelp(help))\n        qconnect(self.form.filter.textEdited, self.redraw)\n        qconnect(self.form.list.itemDoubleClicked, self.accept)\n        qconnect(self.finished, self.on_finished)\n        self.form.filter.setFocus()\n        self.show()\n        # redraw after show so position at center correct\n        self.redraw(\"\", current)\n        self.callback = callback\n        if callback:\n            self.show()\n        else:\n            self.exec()\n\n    def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool:\n        if isinstance(evt, QKeyEvent) and evt.type() == QEvent.Type.KeyPress:\n            new_row = current_row = self.form.list.currentRow()\n            rows_count = self.form.list.count()\n            key = evt.key()\n\n            if key == Qt.Key.Key_Up:\n                new_row = current_row - 1\n            elif key == Qt.Key.Key_Down:\n                new_row = current_row + 1\n            elif (\n                evt.modifiers() & Qt.KeyboardModifier.ControlModifier\n                and Qt.Key.Key_1 <= key <= Qt.Key.Key_9\n            ):\n                row_index = key - Qt.Key.Key_1\n                if row_index < rows_count:\n                    new_row = row_index\n\n            if rows_count:\n                new_row %= rows_count  # don't let row index overflow/underflow\n            if new_row != current_row:\n                self.form.list.setCurrentRow(new_row)\n                return True\n        return False\n\n    def redraw(self, filt: str, focus: str | None = None) -> None:\n        self.filt = filt\n        self.focus = focus\n        self.names = [n for n in self.origNames if self._matches(n, filt)]\n        l = self.form.list\n        l.clear()\n        l.addItems(self.names)\n        if focus in self.names:\n            idx = self.names.index(focus)\n        else:\n            idx = 0\n        l.setCurrentRow(idx)\n        l.scrollToItem(l.item(idx), QAbstractItemView.ScrollHint.PositionAtCenter)\n\n    def _matches(self, name: str, filt: str) -> bool:\n        name = name.lower()\n        filt = filt.lower()\n        if not filt:\n            return True\n        for word in filt.split(\" \"):\n            if word not in name:\n                return False\n        return True\n\n    def onReset(self) -> None:\n        # model updated?\n        if self.nameFunc:\n            self.origNames = self.nameFunc()\n        self.redraw(self.filt, self.focus)\n\n    def accept(self) -> None:\n        row = self.form.list.currentRow()\n        if row < 0:\n            showInfo(tr.decks_please_select_something())\n            return\n        self.name = self.names[self.form.list.currentRow()]\n        self.accept_with_callback()\n\n    def accept_with_callback(self) -> None:\n        if self.callback:\n            self.callback(self)\n        super().accept()\n\n    def onAddDeck(self) -> None:\n        row = self.form.list.currentRow()\n        if row < 0:\n            default = self.form.filter.text()\n        else:\n            default = self.names[self.form.list.currentRow()]\n\n        def success(out: OpChangesWithId) -> None:\n            deck = self.mw.col.decks.get(DeckId(out.id))\n            assert deck is not None\n            self.name = deck[\"name\"]\n            self.accept_with_callback()\n\n        if diag := add_deck_dialog(parent=self, default_text=default):\n            diag.success(success).run_in_background()\n\n    def on_finished(self) -> None:\n        saveGeom(self, self.geomKey)\n        gui_hooks.state_did_reset.remove(self.onReset)\n"
  },
  {
    "path": "qt/aqt/stylesheets.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom anki.utils import is_mac, is_win\nfrom aqt import colors, props\nfrom aqt.theme import ThemeManager\n\n\ndef button_gradient(start: str, end: str) -> str:\n    return f\"\"\"\nqlineargradient(\n    spread:pad, x1:0.5, y1:0, x2:0.5, y2:1,\n    stop:0 {start},\n    stop:1 {end}\n);\n    \"\"\"\n\n\ndef button_pressed_gradient(start: str, end: str, shadow: str) -> str:\n    return f\"\"\"\nqlineargradient(\n    spread:pad, x1:0.5, y1:0, x2:0.5, y2:1,\n    stop:0 {shadow},\n    stop:0.1 {start},\n    stop:0.9 {end},\n    stop:1 {shadow}\n);\n    \"\"\"\n\n\ndef button_layout(tm: ThemeManager):\n    # https://doc.qt.io/qt-6/stylesheet-reference.html#button-layout\n    if is_win:\n        return 0\n    elif is_mac:\n        return 1\n    # on linux, use non-default layout if available\n    if tm._default_button_layout:\n        return tm._default_button_layout\n    # fallback to GnomeLayout\n    return 3\n\n\nclass CustomStyles:\n    def general(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QFrame,\n    QWidget {{\n        background: none;\n    }}\n    QPushButton,\n    QComboBox,\n    QSpinBox,\n    QDateTimeEdit,\n    QLineEdit,\n    QListWidget,\n    QTreeWidget,\n    QListView,\n    QTextEdit,\n    QPlainTextEdit {{\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QLineEdit,\n    QTextEdit,\n    QPlainTextEdit,\n    QDateTimeEdit,\n    QListWidget,\n    QTreeWidget,\n    QListView {{\n        background: {tm.var(colors.CANVAS_CODE)};\n    }}\n    QLineEdit,\n    QTextEdit,\n    QPlainTextEdit,\n    QDateTimeEdit {{\n        padding: 2px;\n    }}\n    QSpinBox:focus,\n    QDateTimeEdit:focus,\n    QLineEdit:focus,\n    QTextEdit:editable:focus,\n    QPlainTextEdit:editable:focus,\n    QWidget:editable:focus {{\n        border-color: {tm.var(colors.BORDER_FOCUS)};\n    }}\n    QPushButton {{\n        margin-top: 1px;\n    }}\n    QPushButton,\n    QComboBox,\n    QSpinBox {{\n        padding: 2px 6px;\n    }}\n    QGroupBox {{\n        text-align: center;\n        font-weight: bold;\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        padding: 0.75em 0 0.75em 0;\n        background: {tm.var(colors.CANVAS_ELEVATED)};\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n        margin-top: 10px;\n    }}\n    QGroupBox#preview_box,\n    QGroupBox#template_box {{\n        background: none;\n        border: none;\n    }}\n    QGroupBox::title {{\n        subcontrol-origin: margin;\n        subcontrol-position: top left;\n        margin: 0 2px;\n        left: 15px;\n    }}\n    QGroupBox#preview_box::title,\n    QGroupBox#template_box::title {{\n        margin-top: 5px;\n        left: 5px;\n    }}\n    QLabel:disabled {{\n        color: {tm.var(colors.FG_DISABLED)};\n    }}\n    QToolTip {{ color: {tm.var(colors.FG)}; background-color: {tm.var(colors.CANVAS)}; }}\n        \"\"\"\n\n    def menu(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QMenuBar {{\n        border-bottom: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n    }}\n    QMenuBar::item {{\n        background-color: transparent;\n        padding: 2px 4px;\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QMenuBar::item:selected {{\n        background-color: {tm.var(colors.CANVAS_ELEVATED)};\n    }}\n    QMenu {{\n        background-color: {tm.var(colors.CANVAS_OVERLAY)};\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        padding: 4px;\n    }}\n    QMenu::item {{\n        background-color: transparent;\n        padding: 3px 14px;\n        margin-bottom: 4px;\n    }}\n    QMenu::item:selected {{\n        background-color: {tm.var(colors.HIGHLIGHT_BG)};\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QMenu::separator {{\n        height: 1px;\n        background: {tm.var(colors.BORDER_SUBTLE)};\n        margin: 0 8px 4px 8px;\n    }}\n    QMenu::indicator {{\n        border: 1px solid {tm.var(colors.BORDER)};\n        margin-{tm.left()}: 6px;\n        margin-{tm.right()}: -6px;\n    }}\n        \"\"\"\n\n    def button(self, tm: ThemeManager) -> str:\n        # For some reason, Windows needs a larger padding to look the same\n        button_pad = 25 if is_win else 15\n        return f\"\"\"\n    QPushButton {{ padding-left: {button_pad}px; padding-right: {button_pad}px; }}\n    QPushButton,\n    QTabBar::tab:!selected,\n    QComboBox:!editable,\n    QComboBox::drop-down:editable {{\n        background: {tm.var(colors.BUTTON_BG)};\n        border-bottom: 1px solid {tm.var(colors.SHADOW)};\n    }}\n    QPushButton:default {{\n        border: 1px solid {tm.var(colors.BORDER_FOCUS)};\n    }}\n    QPushButton {{\n        margin: 1px;\n    }}\n    QPushButton:focus, QPushButton:default:hover {{\n        border: 2px solid {tm.var(colors.BORDER_FOCUS)};\n        outline: none;\n        margin: 0px;\n    }}\n    QPushButton:hover,\n    QTabBar::tab:hover,\n    QComboBox:!editable:hover,\n    QSpinBox::up-button:hover,\n    QSpinBox::down-button:hover,\n    QDateTimeEdit::up-button:hover,\n    QDateTimeEdit::down-button:hover {{\n        background: {\n            button_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n            )\n        };\n    }}\n    QPushButton:pressed,\n    QPushButton:checked,\n    QSpinBox::up-button:pressed,\n    QSpinBox::down-button:pressed,\n    QDateTimeEdit::up-button:pressed,\n    QDateTimeEdit::down-button:pressed {{\n        background: {\n            button_pressed_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n                tm.var(colors.SHADOW),\n            )\n        };\n    }}\n    QPushButton:flat {{\n        border: none;\n    }}\n    QDialogButtonBox {{\n        button-layout: {button_layout(tm)};\n    }}\n        \"\"\"\n\n    def splitter(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QSplitter::handle,\n    QMainWindow::separator {{\n        height: 16px;\n    }}\n    QSplitter::handle:vertical,\n    QMainWindow::separator:horizontal {{\n        image: url({tm.themed_icon(\"mdi:drag-horizontal-FG_SUBTLE\")});\n    }}\n    QSplitter::handle:horizontal,\n    QMainWindow::separator:vertical {{\n        image: url({tm.themed_icon(\"mdi:drag-vertical-FG_SUBTLE\")});\n    }}\n    \"\"\"\n\n    def combobox(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QComboBox {{\n        padding: {\"1px 6px 2px 4px\" if tm.rtl() else \"1px 4px 2px 6px\"};\n    }}\n    QComboBox:focus {{\n        border-color: {tm.var(colors.BORDER_FOCUS)};\n    }}\n    QComboBox:editable:on,\n    QComboBox:editable:focus,\n    QComboBox::drop-down:focus:editable,\n    QComboBox::drop-down:pressed {{\n        border-color: {tm.var(colors.BORDER_FOCUS)};\n    }}\n    QComboBox:on {{\n        border-bottom: none;\n        border-bottom-right-radius: 0;\n        border-bottom-left-radius: 0;\n    }}\n    QComboBox::item {{\n        color: {tm.var(colors.FG)};\n        background: {tm.var(colors.CANVAS_ELEVATED)};\n    }}\n\n    QComboBox::item:selected {{\n        background: {tm.var(colors.HIGHLIGHT_BG)};\n        color: {tm.var(colors.HIGHLIGHT_FG)};\n    }}\n    QComboBox::item::icon:selected {{\n        position: absolute;\n    }}\n    QComboBox::drop-down {{\n        subcontrol-origin: border;\n        padding: 2px;\n        padding-left: 4px;\n        padding-right: 4px;\n        width: 16px;\n        subcontrol-position: top right;\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n        border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QComboBox::drop-down:!editable {{\n        background: none;\n        border-color: transparent;\n    }}\n    QComboBox::down-arrow {{\n        image: url({tm.themed_icon(\"mdi:chevron-down\")});\n    }}\n    QComboBox::down-arrow:disabled {{\n        image: url({tm.themed_icon(\"mdi:chevron-down-FG_DISABLED\")});\n    }}\n    QComboBox::drop-down:hover:editable {{\n        background: {\n            button_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n            )\n        };\n    }}\n        \"\"\"\n\n    def tabwidget(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QTabWidget {{\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n        background: none;\n    }}\n    QTabWidget::pane {{\n        top: -15px;\n        padding-top: 1em;\n        background: {tm.var(colors.CANVAS_ELEVATED)};\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QTabWidget::tab-bar {{\n        alignment: center;\n    }}\n    QTabBar::tab {{\n        background: none;\n        padding: 4px 8px;\n        min-width: 8ex;\n    }}\n    QTabBar::tab {{\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-bottom-color: {tm.var(colors.SHADOW)};\n    }}\n    QTabBar::tab:first {{\n        border-top-{tm.left()}-radius: {tm.var(props.BORDER_RADIUS)};\n        border-bottom-{tm.left()}-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QTabBar::tab:!first {{\n        margin-{tm.left()}: -1px;\n    }}\n    QTabBar::tab:last {{\n        border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n        border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QTabBar::tab:selected {{\n        color: white;\n        background: {tm.var(colors.BUTTON_PRIMARY_BG)};\n    }}\n    QTabBar::tab:selected:hover {{\n        background: {\n            button_gradient(\n                tm.var(colors.BUTTON_PRIMARY_GRADIENT_START),\n                tm.var(colors.BUTTON_PRIMARY_GRADIENT_END),\n            )\n        };\n    }}\n    QTabBar::tab:focus {{\n        outline: none;\n    }}\n    QTabBar::tab:disabled,\n    QTabBar::tab:disabled:hover {{\n        background: {tm.var(colors.BUTTON_DISABLED)};\n        color: {tm.var(colors.FG_DISABLED)};\n    }}\n    QTabBar::tab:selected:disabled,\n    QTabBar::tab:selected:hover:disabled {{\n        background: {tm.var(colors.BUTTON_PRIMARY_DISABLED)};\n    }}\n        \"\"\"\n\n    def table(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QTableView {{\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n        border-{tm.left()}: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-bottom: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-bottom-left-radius: 0;\n        border-bottom-right-radius: 0;\n        gridline-color: {tm.var(colors.BORDER_SUBTLE)};\n        selection-background-color: {tm.var(colors.SELECTED_BG)};\n        selection-color: {tm.var(colors.SELECTED_FG)};\n        background: {tm.var(colors.CANVAS_CODE)};\n    }}\n    QHeaderView {{\n        background: {tm.var(colors.CANVAS)};\n    }}\n    QHeaderView::section {{\n        padding-{tm.left()}: 0px;\n        padding-{tm.right()}: 15px;\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        background: {tm.var(colors.BUTTON_BG)};\n    }}\n    QHeaderView::section:first {{\n        margin-left: -1px;\n    }}\n    QHeaderView::section:pressed,\n    QHeaderView::section:pressed:!first {{\n        background: {\n            button_pressed_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n                tm.var(colors.SHADOW),\n            )\n        }\n    }}\n    QHeaderView::section:hover {{\n        background: {\n            button_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n            )\n        };\n    }}\n    QHeaderView::section:first {{\n        border-left: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-top-left-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QHeaderView::section:!first {{\n        border-left: none;\n    }}\n    QHeaderView::section:last {{\n        border-right: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-top-right-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QHeaderView::section:only-one {{\n        border-left: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-right: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-top-left-radius: {tm.var(props.BORDER_RADIUS)};\n        border-top-right-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QHeaderView::up-arrow,\n    QHeaderView::down-arrow {{\n        width: 20px;\n        height: 20px;\n        margin-{tm.left()}: -20px;\n    }}\n    QHeaderView::up-arrow {{\n        image: url({tm.themed_icon(\"mdi:menu-up\")});\n    }}\n    QHeaderView::down-arrow {{\n        image: url({tm.themed_icon(\"mdi:menu-down\")});\n    }}\n        \"\"\"\n\n    def spinbox(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QSpinBox::up-button,\n    QSpinBox::down-button,\n    QDateTimeEdit::up-button,\n    QDateTimeEdit::down-button {{\n        subcontrol-origin: border;\n        width: 16px;\n        margin: 1px;\n    }}\n    QSpinBox::up-button,\n    QDateTimeEdit::up-button {{\n        margin-bottom: -1px;\n        subcontrol-position: top right;\n        border-top-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QSpinBox::down-button,\n    QDateTimeEdit::down-button {{\n        margin-top: -1px;\n        subcontrol-position: bottom right;\n        border-bottom-{tm.right()}-radius: {tm.var(props.BORDER_RADIUS)};\n    }}\n    QSpinBox::up-arrow,\n    QDateTimeEdit::up-arrow {{\n        image: url({tm.themed_icon(\"mdi:chevron-up\")});\n    }}\n    QSpinBox::down-arrow,\n    QDateTimeEdit::down-arrow {{\n        image: url({tm.themed_icon(\"mdi:chevron-down\")});\n    }}\n    QSpinBox::up-arrow,\n    QSpinBox::down-arrow,\n    QSpinBox::up-arrow:pressed,\n    QSpinBox::down-arrow:pressed,\n    QSpinBox::up-arrow:disabled:hover, QSpinBox::up-arrow:off:hover,\n    QSpinBox::down-arrow:disabled:hover, QSpinBox::down-arrow:off:hover,\n    QDateTimeEdit::up-arrow,\n    QDateTimeEdit::down-arrow,\n    QDateTimeEdit::up-arrow:pressed,\n    QDateTimeEdit::down-arrow:pressed,\n    QDateTimeEdit::up-arrow:disabled:hover, QDateTimeEdit::up-arrow:off:hover,\n    QDateTimeEdit::down-arrow:disabled:hover, QDateTimeEdit::down-arrow:off:hover {{\n        width: 16px;\n        height: 16px;\n    }}\n    QSpinBox::up-arrow:hover,\n    QSpinBox::down-arrow:hover,\n    QDateTimeEdit::up-arrow:hover,\n    QDateTimeEdit::down-arrow:hover {{\n        width: 20px;\n        height: 20px;\n    }}\n    QSpinBox::up-arrow:disabled, QSpinBox::up-arrow:off,\n    QDateTimeEdit::up-arrow:disabled, QDateTimeEdit::up-arrow:off {{\n        image: url({tm.themed_icon(\"mdi:chevron-up-FG_DISABLED\")});\n    }}\n    QSpinBox::down-arrow:disabled, QSpinBox::down-arrow:off,\n    QDateTimeEdit::down-arrow:disabled, QDateTimeEdit::down-arrow:off {{\n        image: url({tm.themed_icon(\"mdi:chevron-down-FG_DISABLED\")});\n    }}\n        \"\"\"\n\n    def checkbox(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QCheckBox,\n    QRadioButton {{\n        spacing: 8px;\n        margin: 2px 0;\n    }}\n    QCheckBox::indicator,\n    QRadioButton::indicator,\n    QMenu::indicator {{\n        border: 1px solid {tm.var(colors.BORDER)};\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n        background: {tm.var(colors.CANVAS_ELEVATED)};\n        width: 16px;\n        height: 16px;\n    }}\n    QRadioButton::indicator,\n    QMenu::indicator:exclusive {{\n        border-radius: 8px;\n    }}\n    QCheckBox::indicator:focus:!disabled,\n    QCheckBox::indicator:hover:!disabled,\n    QCheckBox::indicator:checked:hover:!disabled,\n    QRadioButton::indicator:focus:!disabled,\n    QRadioButton::indicator:hover:!disabled,\n    QRadioButton::indicator:checked::!disabled {{\n        border: 2px solid {tm.var(colors.BORDER_STRONG)};\n        width: 14px;\n        height: 14px;\n    }}\n    QCheckBox::indicator:checked,\n    QRadioButton::indicator:checked,\n    QMenu::indicator:checked {{\n        image: url({tm.themed_icon(\"mdi:check\")});\n    }}\n    QRadioButton::indicator:checked {{\n        image: url({tm.themed_icon(\"mdi:circle-medium\")});\n    }}\n    QCheckBox::indicator:indeterminate {{\n        image: url({tm.themed_icon(\"mdi:minus-thick\")});\n    }}\n    QCheckBox:disabled,\n    QRadioButton:disabled {{\n        color: {tm.var(colors.FG_DISABLED)};\n    }}\n    QCheckBox::indicator:disabled,\n    QRadioButton::indicator:disabled,\n    QMenu:indicator:disabled {{\n        color: {tm.var(colors.FG_DISABLED)};\n        border-color: {tm.var(colors.FG_DISABLED)};\n    }}\n    QCheckBox::indicator:checked:disabled,\n    QRadioButton::indicator:checked:disabled,\n    QMenu::indicator:checked:disabled {{\n        image: url({tm.themed_icon(\"mdi:check-FG_DISABLED\")});\n    }}\n    QRadioButton::indicator:checked:disabled {{\n        image: url({tm.themed_icon(\"mdi:circle-medium-FG_DISABLED\")});\n    }}\n    QCheckBox::indicator:indeterminate:disabled {{\n        image: url({tm.themed_icon(\"mdi:minus-thick-FG_DISABLED\")});\n    }}\n        \"\"\"\n\n    def scrollbar(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QAbstractScrollArea::corner {{\n        background: none;\n        border: none;\n    }}\n    QScrollBar {{\n        subcontrol-origin: content;\n        background-color: transparent;\n    }}\n    QScrollBar::handle {{\n        border-radius: {tm.var(props.BORDER_RADIUS)};\n        background-color: {tm.var(colors.SCROLLBAR_BG)};\n    }}\n    QScrollBar::handle:hover {{\n        background-color: {tm.var(colors.SCROLLBAR_BG_HOVER)};\n    }}\n    QScrollBar::handle:pressed {{\n        background-color: {tm.var(colors.SCROLLBAR_BG_ACTIVE)};\n    }}\n    QScrollBar:horizontal {{\n        height: 12px;\n    }}\n    QScrollBar::handle:horizontal {{\n        min-width: 60px;\n    }}\n    QScrollBar:vertical {{\n        width: 12px;\n    }}\n    QScrollBar::handle:vertical {{\n        min-height: 60px;\n    }}\n    QScrollBar::add-line {{\n        border: none;\n        background: none;\n    }}\n    QScrollBar::sub-line {{\n        border: none;\n        background: none;\n    }}\n        \"\"\"\n\n    def slider(self, tm: ThemeManager) -> str:\n        return f\"\"\"\n    QSlider::horizontal {{\n        height: 20px;\n    }}\n    QSlider::vertical {{\n        width: 20px;\n    }}\n    QSlider::groove {{\n        border: 1px solid {tm.var(colors.BORDER_SUBTLE)};\n        border-radius: 3px;\n        background: {tm.var(colors.CANVAS_ELEVATED)};\n    }}\n    QSlider::sub-page {{\n        background: {tm.var(colors.BUTTON_PRIMARY_GRADIENT_START)};\n        border-radius: 3px;\n        margin: 1px;\n    }}\n    QSlider::sub-page:disabled {{\n        background: {tm.var(colors.BUTTON_DISABLED)};\n    }}\n    QSlider::add-page {{\n        margin-{tm.right()}: 2px;\n    }}\n    QSlider::groove:vertical {{\n        width: 6px;\n    }}\n    QSlider::groove:horizontal {{\n        height: 6px;\n    }}\n    QSlider::handle {{\n        background: {tm.var(colors.BUTTON_BG)};\n        border: 1px solid {tm.var(colors.BORDER)};\n        border-radius: 9px;\n        width: 18px;\n        height: 18px;\n        border-bottom-color: {tm.var(colors.SHADOW)};\n    }}\n    QSlider::handle:vertical {{\n        margin: 0 -7px;\n    }}\n    QSlider::handle:horizontal {{\n        margin: -7px 0;\n    }}\n    QSlider::handle:hover {{\n        background: {\n            button_gradient(\n                tm.var(colors.BUTTON_GRADIENT_START),\n                tm.var(colors.BUTTON_GRADIENT_END),\n            )\n        }\n    }}\n    \"\"\"\n\n\ncustom_styles = CustomStyles()\n"
  },
  {
    "path": "qt/aqt/switch.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nfrom typing import cast\n\nfrom aqt import colors, props\nfrom aqt.qt import *\nfrom aqt.theme import theme_manager\n\n\nclass Switch(QAbstractButton):\n    \"\"\"A horizontal slider to toggle between two states which can be denoted by strings and/or QIcons.\n\n    The left state is the default and corresponds to isChecked()=False.\n    The suppoorted slots are toggle(), for an animated transition, and setChecked().\n    \"\"\"\n\n    def __init__(\n        self,\n        radius: int = 10,\n        left_label: str = \"\",\n        right_label: str = \"\",\n        left_color: dict[str, str] | None = None,\n        right_color: dict[str, str] | None = None,\n        parent: QWidget | None = None,\n    ) -> None:\n        super().__init__(parent=parent)\n        self.setCheckable(True)\n        super().setChecked(False)\n        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)\n        self._left_label = left_label\n        self._right_label = right_label\n        self._left_color = left_color if left_color else colors.ACCENT_CARD\n        self._right_color = right_color if right_color else colors.ACCENT_NOTE\n        self._path_radius = radius\n        self._knob_radius = radius - 2\n        self._label_padding = 4\n        self._left_knob_position = self._position = radius\n        self._right_knob_position = self.width() - self._path_radius\n        self._left_label_position = self._label_padding / 2\n        self._right_label_position = 2 * self._knob_radius\n        self._hide_label: bool = False\n\n    @pyqtProperty(int)  # type: ignore\n    def position(self) -> int:\n        return self._position\n\n    @position.setter  # type: ignore\n    def position(self, position: int) -> None:\n        self._position = position\n        self.update()\n\n    @property\n    def start_position(self) -> int:\n        return (\n            self._left_knob_position if self.isChecked() else self._right_knob_position\n        )\n\n    @property\n    def end_position(self) -> int:\n        return (\n            self._right_knob_position if self.isChecked() else self._left_knob_position\n        )\n\n    @property\n    def label(self) -> str:\n        return self._right_label if self.isChecked() else self._left_label\n\n    @property\n    def path_color(self) -> QColor:\n        color = self._right_color if self.isChecked() else self._left_color\n        return theme_manager.qcolor(color)\n\n    @property\n    def label_width(self) -> int:\n        font = QFont()\n        font.setPixelSize(int(self._knob_radius))\n        font.setWeight(QFont.Weight.Bold)\n        fm = QFontMetrics(font)\n        return (\n            max(\n                fm.horizontalAdvance(self._left_label),\n                fm.horizontalAdvance(self._right_label),\n            )\n            + 2 * self._label_padding\n        )\n\n    def width(self) -> int:\n        return self.label_width + 2 * self._path_radius\n\n    def height(self) -> int:\n        return 2 * self._path_radius\n\n    def sizeHint(self) -> QSize:\n        return QSize(\n            self.width(),\n            self.height(),\n        )\n\n    def setChecked(self, checked: bool) -> None:\n        super().setChecked(checked)\n        self._position = self.end_position\n        self.update()\n\n    def paintEvent(self, _event: QPaintEvent | None) -> None:\n        painter = QPainter(self)\n        painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)\n        painter.setPen(Qt.PenStyle.NoPen)\n        self._paint_path(painter)\n        if not self._hide_label:\n            self._paint_label(painter)\n        self._paint_knob(painter)\n\n    def _current_path_rectangle(self) -> QRectF:\n        return QRectF(\n            0,\n            0,\n            self.width(),\n            self.height(),\n        )\n\n    def _current_label_rectangle(self) -> QRectF:\n        return QRectF(\n            (\n                self._left_label_position\n                if self.isChecked()\n                else self._right_label_position\n            ),\n            0,\n            self.label_width,\n            self.height(),\n        )\n\n    def _current_knob_rectangle(self) -> QRectF:\n        return QRectF(\n            self.position - self._knob_radius,  # type: ignore\n            2,\n            2 * self._knob_radius,\n            2 * self._knob_radius,\n        )\n\n    def _paint_path(self, painter: QPainter) -> None:\n        painter.setBrush(QBrush(self.path_color))\n        painter.drawRoundedRect(\n            self._current_path_rectangle(), self._path_radius, self._path_radius\n        )\n\n    def _paint_knob(self, painter: QPainter) -> None:\n        color = theme_manager.qcolor(colors.BUTTON_GRADIENT_START)\n        painter.setPen(Qt.PenStyle.NoPen)\n        painter.setBrush(QBrush(color))\n        painter.drawEllipse(self._current_knob_rectangle())\n\n    def _paint_label(self, painter: QPainter) -> None:\n        painter.setPen(theme_manager.qcolor(colors.CANVAS))\n        font = painter.font()\n        font.setPixelSize(int(self._knob_radius))\n        font.setWeight(QFont.Weight.Bold)\n        painter.setFont(font)\n        painter.drawText(\n            self._current_label_rectangle(), Qt.AlignmentFlag.AlignCenter, self.label\n        )\n\n    def mouseReleaseEvent(self, event: QMouseEvent | None) -> None:\n        super().mouseReleaseEvent(event)\n        assert event is not None\n        if event.button() == Qt.MouseButton.LeftButton:\n            self._animate_toggle()\n\n    def enterEvent(self, event: QEnterEvent | None) -> None:\n        self.setCursor(Qt.CursorShape.PointingHandCursor)\n        super().enterEvent(event)\n\n    def toggle(self) -> None:\n        super().toggle()\n        self._animate_toggle()\n\n    def _animate_toggle(self) -> None:\n        animation = QPropertyAnimation(self, cast(QByteArray, b\"position\"), self)\n        animation.setDuration(int(theme_manager.var(props.TRANSITION)))\n        animation.setStartValue(self.start_position)\n        animation.setEndValue(self.end_position)\n        # hide label during animation\n        self._hide_label = True\n        self.update()\n\n        def on_animation_finished() -> None:\n            self._hide_label = False\n            self.update()\n\n        qconnect(animation.finished, on_animation_finished)\n        # make triggered events execute first so the animation runs smoothly afterwards\n        QTimer.singleShot(50, animation.start)\n"
  },
  {
    "path": "qt/aqt/sync.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport functools\nimport os\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\n\nimport aqt\nimport aqt.main\nfrom anki.errors import Interrupted, SyncError, SyncErrorKind\nfrom anki.lang import without_unicode_isolation\nfrom anki.sync import SyncOutput, SyncStatus\nfrom anki.sync_pb2 import SyncAuth\nfrom anki.utils import plat_desc\nfrom aqt import gui_hooks\nfrom aqt.qt import (\n    QDialog,\n    QDialogButtonBox,\n    QGridLayout,\n    QLabel,\n    QLineEdit,\n    Qt,\n    QTimer,\n    QVBoxLayout,\n    qconnect,\n)\nfrom aqt.utils import (\n    ask_user_dialog,\n    disable_help_button,\n    show_warning,\n    showText,\n    showWarning,\n    tooltip,\n    tr,\n)\n\n\ndef get_sync_status(\n    mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None]\n) -> None:\n    auth = mw.pm.sync_auth()\n    if not auth:\n        callback(SyncStatus(required=SyncStatus.NO_CHANGES))\n        return\n\n    def on_future_done(fut: Future[SyncStatus]) -> None:\n        try:\n            out = fut.result()\n        except Exception as e:\n            # swallow errors\n            print(\"sync status check failed:\", str(e))\n            return\n        if out.new_endpoint:\n            mw.pm.set_current_sync_url(out.new_endpoint)\n        callback(out)\n\n    mw.taskman.run_in_background(\n        lambda: mw.col.sync_status(auth),\n        on_future_done,\n        # The check quickly releases the collection, and we don't need to block other callers\n        uses_collection=False,\n    )\n\n\ndef handle_sync_error(mw: aqt.main.AnkiQt, err: Exception) -> None:\n    if isinstance(err, SyncError):\n        if err.kind is SyncErrorKind.AUTH:\n            mw.pm.clear_sync_auth()\n    elif isinstance(err, Interrupted):\n        # no message to show\n        return\n    show_warning(str(err), parent=mw)\n\n\ndef on_normal_sync_timer(mw: aqt.main.AnkiQt) -> None:\n    progress = mw.col.latest_progress()\n    if not progress.HasField(\"normal_sync\"):\n        return\n    sync_progress = progress.normal_sync\n\n    mw.progress.update(\n        label=f\"{sync_progress.added}\\n{sync_progress.removed}\",\n        process=False,\n    )\n    mw.progress.set_title(sync_progress.stage)\n\n    if mw.progress.want_cancel():\n        mw.col.abort_sync()\n\n\ndef sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:\n    auth = mw.pm.sync_auth()\n    if not auth:\n        raise Exception(\"expected auth\")\n\n    def on_timer() -> None:\n        on_normal_sync_timer(mw)\n\n    timer = QTimer(mw)\n    qconnect(timer.timeout, on_timer)\n    timer.start(150)\n\n    def on_future_done(fut: Future[SyncOutput]) -> None:\n        # scheduler version may have changed\n        mw.col._load_scheduler()\n        timer.stop()\n        try:\n            out = fut.result()\n        except Exception as err:\n            handle_sync_error(mw, err)\n            return on_done()\n\n        mw.pm.set_host_number(out.host_number)\n        if out.new_endpoint:\n            mw.pm.set_current_sync_url(out.new_endpoint)\n        if out.server_message:\n            showText(out.server_message, parent=mw)\n        if out.required == out.NO_CHANGES:\n            tooltip(parent=mw, msg=tr.sync_collection_complete())\n            # all done; track media progress\n            mw.media_syncer.start_monitoring()\n            return on_done()\n        else:\n            full_sync(mw, out, on_done)\n\n    mw.taskman.with_progress(\n        lambda: mw.col.sync_collection(auth, mw.pm.media_syncing_enabled()),\n        on_future_done,\n        label=tr.sync_checking(),\n        immediate=True,\n        title=tr.sync_checking(),\n    )\n\n\ndef full_sync(\n    mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None]\n) -> None:\n    server_usn = out.server_media_usn if mw.pm.media_syncing_enabled() else None\n    if out.required == out.FULL_DOWNLOAD:\n        confirm_full_download(mw, server_usn, on_done)\n    elif out.required == out.FULL_UPLOAD:\n        confirm_full_upload(mw, server_usn, on_done)\n    else:\n        button_labels: list[str] = [\n            tr.sync_upload_to_ankiweb(),\n            tr.sync_download_from_ankiweb(),\n            tr.sync_cancel_button(),\n        ]\n\n        def callback(choice: int) -> None:\n            if choice == 0:\n                full_upload(mw, server_usn, on_done)\n            elif choice == 1:\n                full_download(mw, server_usn, on_done)\n            else:\n                on_done()\n\n        ask_user_dialog(\n            tr.sync_conflict_explanation2(),\n            callback=callback,\n            buttons=button_labels,\n            default_button=2,\n            parent=mw,\n            textFormat=Qt.TextFormat.MarkdownText,\n            title=tr.qt_misc_sync(),\n        )\n\n\ndef confirm_full_download(\n    mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]\n) -> None:\n    # confirmation step required, as some users customize their notetypes\n    # in an empty collection, then want to upload them\n    def callback(choice: int) -> None:\n        if choice:\n            on_done()\n        else:\n            mw.closeAllWindows(lambda: full_download(mw, server_usn, on_done))\n\n    ask_user_dialog(\n        tr.sync_confirm_empty_download(), callback=callback, default_button=0, parent=mw\n    )\n\n\ndef confirm_full_upload(\n    mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]\n) -> None:\n    # confirmation step required, as some users have reported an upload\n    # happening despite having their AnkiWeb collection not being empty\n    # (not reproducible - maybe a compiler bug?)\n    def callback(choice: int) -> None:\n        if choice:\n            on_done()\n        else:\n            mw.closeAllWindows(lambda: full_upload(mw, server_usn, on_done))\n\n    ask_user_dialog(\n        tr.sync_confirm_empty_upload(), callback=callback, default_button=0, parent=mw\n    )\n\n\ndef on_full_sync_timer(mw: aqt.main.AnkiQt, label: str) -> None:\n    progress = mw.col.latest_progress()\n    if not progress.HasField(\"full_sync\"):\n        return\n    sync_progress = progress.full_sync\n\n    # If we've reached total, show the \"checking\" label\n    if sync_progress.transferred == sync_progress.total:\n        label = tr.sync_checking()\n\n    total = sync_progress.total\n    transferred = sync_progress.transferred\n\n    # Scale both to kilobytes with floor division\n    max_for_bar = total // 1024\n    value_for_bar = transferred // 1024\n\n    mw.progress.update(\n        value=value_for_bar,\n        max=max_for_bar,\n        process=False,\n        label=label,\n    )\n\n    if mw.progress.want_cancel():\n        mw.col.abort_sync()\n\n\ndef full_download(\n    mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]\n) -> None:\n    label = tr.sync_downloading_from_ankiweb()\n\n    def on_timer() -> None:\n        on_full_sync_timer(mw, label)\n\n    timer = QTimer(mw)\n    qconnect(timer.timeout, on_timer)\n    timer.start(150)\n\n    # hook needs to be called early, on the main thread\n    gui_hooks.collection_will_temporarily_close(mw.col)\n\n    def download() -> None:\n        mw.create_backup_now()\n        mw.col.close_for_full_sync()\n        mw.col.full_upload_or_download(\n            auth=mw.pm.sync_auth(), server_usn=server_usn, upload=False\n        )\n\n    def on_future_done(fut: Future) -> None:\n        timer.stop()\n        mw.reopen(after_full_sync=True)\n        mw.reset()\n        try:\n            fut.result()\n        except Exception as err:\n            handle_sync_error(mw, err)\n        mw.media_syncer.start_monitoring()\n        return on_done()\n\n    mw.taskman.with_progress(\n        download,\n        on_future_done,\n    )\n\n\ndef full_upload(\n    mw: aqt.main.AnkiQt, server_usn: int | None, on_done: Callable[[], None]\n) -> None:\n    gui_hooks.collection_will_temporarily_close(mw.col)\n    mw.col.close_for_full_sync()\n\n    label = tr.sync_uploading_to_ankiweb()\n\n    def on_timer() -> None:\n        on_full_sync_timer(mw, label)\n\n    timer = QTimer(mw)\n    qconnect(timer.timeout, on_timer)\n    timer.start(150)\n\n    def on_future_done(fut: Future) -> None:\n        timer.stop()\n        mw.reopen(after_full_sync=True)\n        mw.reset()\n        try:\n            fut.result()\n        except Exception as err:\n            handle_sync_error(mw, err)\n            return on_done()\n        mw.media_syncer.start_monitoring()\n        return on_done()\n\n    mw.taskman.with_progress(\n        lambda: mw.col.full_upload_or_download(\n            auth=mw.pm.sync_auth(), server_usn=server_usn, upload=True\n        ),\n        on_future_done,\n    )\n\n\ndef sync_login(\n    mw: aqt.main.AnkiQt,\n    on_success: Callable[[], None],\n    username: str = \"\",\n    password: str = \"\",\n) -> None:\n    def on_future_done(fut: Future[SyncAuth], username: str, password: str) -> None:\n        try:\n            auth = fut.result()\n        except SyncError as e:\n            if e.kind is SyncErrorKind.AUTH:\n                showWarning(str(e))\n                sync_login(mw, on_success, username, password)\n            else:\n                handle_sync_error(mw, e)\n            return\n        except Exception as err:\n            handle_sync_error(mw, err)\n            return\n\n        mw.pm.set_sync_key(auth.hkey)\n        mw.pm.set_sync_username(username)\n\n        on_success()\n\n    def callback(username: str, password: str) -> None:\n        if not username and not password:\n            return\n        if username and password:\n            mw.taskman.with_progress(\n                lambda: mw.col.sync_login(\n                    username=username, password=password, endpoint=mw.pm.sync_endpoint()\n                ),\n                functools.partial(on_future_done, username=username, password=password),\n                parent=mw,\n            )\n        else:\n            sync_login(mw, on_success, username, password)\n\n    get_id_and_pass_from_user(mw, callback, username, password)\n\n\ndef get_id_and_pass_from_user(\n    mw: aqt.main.AnkiQt,\n    callback: Callable[[str, str], None],\n    username: str = \"\",\n    password: str = \"\",\n) -> None:\n    diag = QDialog(mw)\n    diag.setWindowTitle(tr.qt_misc_sync())\n    disable_help_button(diag)\n    diag.setWindowModality(Qt.WindowModality.WindowModal)\n    vbox = QVBoxLayout()\n    info_label = QLabel(\n        without_unicode_isolation(\n            tr.sync_account_required(link=\"https://ankiweb.net/account/register\")\n        )\n    )\n    info_label.setOpenExternalLinks(True)\n    info_label.setWordWrap(True)\n    vbox.addWidget(info_label)\n    vbox.addSpacing(20)\n    g = QGridLayout()\n    l1 = QLabel(tr.sync_ankiweb_id_label())\n    g.addWidget(l1, 0, 0)\n    user = QLineEdit()\n    user.setText(username)\n    g.addWidget(user, 0, 1)\n    l1.setBuddy(user)\n    l2 = QLabel(tr.sync_password_label())\n    g.addWidget(l2, 1, 0)\n    passwd = QLineEdit()\n    passwd.setText(password)\n    passwd.setEchoMode(QLineEdit.EchoMode.Password)\n    g.addWidget(passwd, 1, 1)\n    l2.setBuddy(passwd)\n    vbox.addLayout(g)\n    bb = QDialogButtonBox(\n        QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel\n    )  # type: ignore\n    ok_button = bb.button(QDialogButtonBox.StandardButton.Ok)\n    assert ok_button is not None\n    ok_button.setAutoDefault(True)\n    qconnect(bb.accepted, diag.accept)\n    qconnect(bb.rejected, diag.reject)\n    vbox.addWidget(bb)\n    diag.setLayout(vbox)\n    diag.adjustSize()\n    diag.show()\n    user.setFocus()\n\n    def on_finished(result: int) -> None:\n        if result == QDialog.DialogCode.Rejected:\n            callback(\"\", \"\")\n        else:\n            callback(user.text().strip(), passwd.text())\n\n    qconnect(diag.finished, on_finished)\n    diag.open()\n\n\n# export platform version to syncing code\nos.environ[\"PLATFORM\"] = plat_desc()\n"
  },
  {
    "path": "qt/aqt/tagedit.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport re\nfrom collections.abc import Iterable\n\nfrom anki.collection import Collection\nfrom aqt import gui_hooks\nfrom aqt.qt import *\nfrom aqt.qt import sip\n\n\nclass TagEdit(QLineEdit):\n    _completer: QCompleter | TagCompleter\n\n    lostFocus = pyqtSignal()\n\n    # 0 = tags, 1 = decks\n    def __init__(self, parent: QWidget, type: int = 0) -> None:\n        QLineEdit.__init__(self, parent)\n        self.col: Collection | None = None\n        self.model = QStringListModel()\n        self.type = type\n        if type == 0:\n            self._completer = TagCompleter(self.model, parent, self)\n        else:\n            self._completer = QCompleter(self.model, parent)\n        self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)\n        self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)\n        self._completer.setFilterMode(Qt.MatchFlag.MatchContains)\n        self.setCompleter(self._completer)\n\n    def setCol(self, col: Collection) -> None:\n        \"Set the current col, updating list of available tags.\"\n        self.col = col\n        l: Iterable[str]\n        if self.type == 0:\n            l = self.col.tags.all()\n        else:\n            l = (d.name for d in self.col.decks.all_names_and_ids())\n        self.model.setStringList(l)\n\n    def focusInEvent(self, evt: QFocusEvent | None) -> None:\n        QLineEdit.focusInEvent(self, evt)\n\n    def keyPressEvent(self, evt: QKeyEvent | None) -> None:\n        assert evt is not None\n        popup = self._completer.popup()\n        assert popup is not None\n\n        if evt.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down):\n            # show completer on arrow key up/down\n            if not popup.isVisible():\n                self.showCompleter()\n            return\n        if (\n            evt.key() == Qt.Key.Key_Tab\n            and evt.modifiers() & Qt.KeyboardModifier.ControlModifier\n        ):\n            # select next completion\n            if not popup.isVisible():\n                self.showCompleter()\n            index = self._completer.currentIndex()\n            popup.setCurrentIndex(index)\n            cur_row = index.row()\n            if not self._completer.setCurrentRow(cur_row + 1):\n                self._completer.setCurrentRow(0)\n            return\n        if evt.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) and popup.isVisible():\n            # apply first completion if no suggestion selected\n            selected_row = popup.currentIndex().row()\n            if selected_row == -1:\n                self._completer.setCurrentRow(0)\n                index = self._completer.currentIndex()\n                popup.setCurrentIndex(index)\n            self.hideCompleter()\n            QWidget.keyPressEvent(self, evt)\n            return\n        QLineEdit.keyPressEvent(self, evt)\n        if not evt.text():\n            # if it's a modifier, don't show\n            return\n        if evt.key() not in (\n            Qt.Key.Key_Enter,\n            Qt.Key.Key_Return,\n            Qt.Key.Key_Escape,\n            Qt.Key.Key_Space,\n            Qt.Key.Key_Tab,\n            Qt.Key.Key_Backspace,\n            Qt.Key.Key_Delete,\n        ):\n            self.showCompleter()\n        gui_hooks.tag_editor_did_process_key(self, evt)\n\n    def showCompleter(self) -> None:\n        self._completer.setCompletionPrefix(self.text())\n        self._completer.complete()\n\n    def focusOutEvent(self, evt: QFocusEvent | None) -> None:\n        QLineEdit.focusOutEvent(self, evt)\n        self.lostFocus.emit()  # type: ignore\n        popup = self._completer.popup()\n        assert popup is not None\n        popup.hide()\n\n    def hideCompleter(self) -> None:\n        if sip.isdeleted(self._completer):  # type: ignore\n            return\n        popup = self._completer.popup()\n        assert popup is not None\n        popup.hide()\n\n\nclass TagCompleter(QCompleter):\n    def __init__(\n        self,\n        model: QStringListModel,\n        parent: QWidget,\n        edit: TagEdit,\n    ) -> None:\n        QCompleter.__init__(self, model, parent)\n        self.tags: list[str] = []\n        self.edit = edit\n        self.cursor: int | None = None\n\n    def splitPath(self, tags: str | None) -> list[str]:\n        assert tags is not None\n        assert self.edit.col is not None\n        stripped_tags = tags.strip()\n        stripped_tags = re.sub(\"  +\", \" \", stripped_tags)\n        self.tags = self.edit.col.tags.split(stripped_tags)\n        self.tags.append(\"\")\n        p = self.edit.cursorPosition()\n        if tags.endswith(\"  \"):\n            self.cursor = len(self.tags) - 1\n        else:\n            self.cursor = stripped_tags.count(\" \", 0, p)\n        return [self.tags[self.cursor]]\n\n    def pathFromIndex(self, idx: QModelIndex) -> str:\n        if self.cursor is None:\n            return self.edit.text()\n        ret = QCompleter.pathFromIndex(self, idx)\n        self.tags[self.cursor] = ret\n        try:\n            self.tags.remove(\"\")\n        except ValueError:\n            pass\n        return f\"{' '.join(self.tags)} \"\n"
  },
  {
    "path": "qt/aqt/taglimit.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/copyleft/agpl.html\n\nfrom __future__ import annotations\n\nfrom collections.abc import Callable, Sequence\n\nimport aqt\nimport aqt.customstudy\nimport aqt.forms\nfrom anki.lang import with_collapsed_whitespace\nfrom anki.scheduler.base import CustomStudyDefaults\nfrom aqt.qt import *\nfrom aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr\n\n\nclass TagLimit(QDialog):\n    def __init__(\n        self,\n        parent: QWidget,\n        tags: Sequence[CustomStudyDefaults.Tag],\n        on_success: Callable[[list[str], list[str]], None],\n    ) -> None:\n        \"Ask user to select tags. on_success() will be called with selected included and excluded tags.\"\n        QDialog.__init__(self, parent, Qt.WindowType.Window)\n        self.tags = tags\n        self.form = aqt.forms.taglimit.Ui_Dialog()\n        self.form.setupUi(self)\n        self.on_success = on_success\n        disable_help_button(self)\n        s = QShortcut(\n            QKeySequence(\"ctrl+d\"),\n            self.form.activeList,\n            context=Qt.ShortcutContext.WidgetShortcut,\n        )\n        qconnect(s.activated, self.form.activeList.clearSelection)\n        s = QShortcut(\n            QKeySequence(\"ctrl+d\"),\n            self.form.inactiveList,\n            context=Qt.ShortcutContext.WidgetShortcut,\n        )\n        qconnect(s.activated, self.form.inactiveList.clearSelection)\n        self.build_tag_lists()\n        restoreGeom(self, \"tagLimit\")\n        self.open()\n\n    def build_tag_lists(self) -> None:\n        def add_tag(tag: str, select: bool, list: QListWidget) -> None:\n            item = QListWidgetItem(tag.replace(\"_\", \" \"))\n            list.addItem(item)\n            if select:\n                idx = list.indexFromItem(item)\n                list_selection_model = list.selectionModel()\n                assert list_selection_model is not None\n                list_selection_model.select(\n                    idx, QItemSelectionModel.SelectionFlag.Select\n                )\n\n        had_included_tag = False\n\n        for tag in self.tags:\n            if tag.include:\n                had_included_tag = True\n            add_tag(tag.name, tag.include, self.form.activeList)\n            add_tag(tag.name, tag.exclude, self.form.inactiveList)\n\n        if had_included_tag:\n            self.form.activeCheck.setChecked(True)\n\n    def reject(self) -> None:\n        QDialog.reject(self)\n\n    def accept(self) -> None:\n        include_tags = []\n        exclude_tags = []\n        want_active = self.form.activeCheck.isChecked()\n        for c, tag in enumerate(self.tags):\n            # active\n            if want_active:\n                item = self.form.activeList.item(c)\n                idx = self.form.activeList.indexFromItem(item)\n                active_list_selection_model = self.form.activeList.selectionModel()\n                assert active_list_selection_model is not None\n                if active_list_selection_model.isSelected(idx):\n                    include_tags.append(tag.name)\n            # inactive\n            item = self.form.inactiveList.item(c)\n            idx = self.form.inactiveList.indexFromItem(item)\n            inactive_list_selection_model = self.form.inactiveList.selectionModel()\n            assert inactive_list_selection_model is not None\n            if inactive_list_selection_model.isSelected(idx):\n                exclude_tags.append(tag.name)\n\n        if (len(include_tags) + len(exclude_tags)) > 100:\n            showWarning(with_collapsed_whitespace(tr.errors_100_tags_max()))\n            return\n\n        saveGeom(self, \"tagLimit\")\n        QDialog.accept(self)\n\n        self.on_success(include_tags, exclude_tags)\n"
  },
  {
    "path": "qt/aqt/taskman.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nHelper for running tasks on background threads.\n\nSee QueryOp() and CollectionOp() for higher-level routines.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport traceback\nfrom collections.abc import Callable\nfrom concurrent.futures import Future\nfrom concurrent.futures.thread import ThreadPoolExecutor\nfrom threading import Lock, current_thread, main_thread\nfrom typing import Any\n\nimport aqt\nfrom anki.collection import Progress\nfrom aqt.progress import ProgressUpdate\nfrom aqt.qt import *\n\nClosure = Callable[[], None]\n\n\nclass TaskManager(QObject):\n    _closures_pending = pyqtSignal()\n\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        QObject.__init__(self)\n        self.mw = mw.weakref()\n        self._no_collection_executor = ThreadPoolExecutor()\n        self._collection_executor = ThreadPoolExecutor(max_workers=1)\n        self._closures: list[Closure] = []\n        self._closures_lock = Lock()\n        qconnect(self._closures_pending, self._on_closures_pending)\n\n    def run_on_main(self, closure: Closure) -> None:\n        \"Run the provided closure on the main thread.\"\n        with self._closures_lock:\n            self._closures.append(closure)\n        self._closures_pending.emit()  # type: ignore\n\n    def run_in_background(\n        self,\n        task: Callable,\n        on_done: Callable[[Future], None] | None = None,\n        args: dict[str, Any] | None = None,\n        uses_collection=True,\n    ) -> Future:\n        \"\"\"Use QueryOp()/CollectionOp() in new code.\n\n        Run task on a background thread.\n\n        If on_done is provided, it will be called on the main thread with\n        the completed future.\n\n        Args if provided will be passed on as keyword arguments to the task callable.\n\n        Tasks that access the collection are serialized. If you're doing things that\n        don't require the collection (e.g. network requests), you can pass uses_collection\n        =False to allow multiple tasks to run in parallel.\"\"\"\n        # Before we launch a background task, ensure any pending on_done closure are run on\n        # main. Qt's signal/slot system will have posted a notification, but it may\n        # not have been processed yet. The on_done() closures may make small queries\n        # to the database that we want to run first - if we delay them until after the\n        # background task starts, and it takes out a long-running lock on the database,\n        # the UI thread will hang until the end of the op.\n        if current_thread() is main_thread():\n            self._on_closures_pending()\n        else:\n            print(\"bug: run_in_background not called from main thread\")\n            traceback.print_stack()\n\n        if args is None:\n            args = {}\n\n        executor = (\n            self._collection_executor\n            if uses_collection\n            else self._no_collection_executor\n        )\n        fut = executor.submit(task, **args)\n\n        if on_done is not None:\n            fut.add_done_callback(\n                lambda future: self.run_on_main(lambda: on_done(future))\n            )\n\n        return fut\n\n    def with_progress(\n        self,\n        task: Callable,\n        on_done: Callable[[Future], None] | None = None,\n        parent: QWidget | None = None,\n        label: str | None = None,\n        immediate: bool = False,\n        uses_collection=True,\n        title: str = \"Anki\",\n    ) -> None:\n        \"Use QueryOp()/CollectionOp() in new code.\"\n        self.mw.progress.start(\n            parent=parent, label=label, immediate=immediate, title=title\n        )\n\n        def wrapped_done(fut: Future) -> None:\n            self.mw.progress.finish()\n            if on_done:\n                on_done(fut)\n\n        self.run_in_background(task, wrapped_done, uses_collection=uses_collection)\n\n    def with_backend_progress(\n        self,\n        task: Callable,\n        progress_update: Callable[[Progress, ProgressUpdate], None],\n        on_done: Callable[[Future], None] | None = None,\n        parent: QWidget | None = None,\n        start_label: str | None = None,\n        uses_collection=True,\n    ) -> None:\n        self.mw.progress.start_with_backend_updates(\n            progress_update,\n            parent=parent,\n            start_label=start_label,\n        )\n\n        def wrapped_done(fut: Future) -> None:\n            self.mw.progress.finish()\n            # allow the event loop to close the window before we proceed\n            if on_done:\n                self.mw.progress.single_shot(\n                    100, lambda: on_done(fut), requires_collection=False\n                )\n\n        self.run_in_background(task, wrapped_done, uses_collection=uses_collection)\n\n    def _on_closures_pending(self) -> None:\n        \"\"\"Run any pending closures. This runs in the main thread.\"\"\"\n        with self._closures_lock:\n            closures = self._closures\n            self._closures = []\n\n        for closure in closures:\n            try:\n                closure()\n            except Exception as e:\n\n                def raise_exception(exception=e) -> None:\n                    raise exception\n\n                QTimer.singleShot(0, raise_exception)\n"
  },
  {
    "path": "qt/aqt/theme.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport enum\nimport os\nimport re\nimport subprocess\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\n\nimport anki.lang\nimport aqt\nfrom anki.lang import is_rtl\nfrom anki.utils import is_lin, is_mac, is_win\nfrom aqt import QApplication, colors, gui_hooks\nfrom aqt.qt import (\n    QColor,\n    QIcon,\n    QPainter,\n    QPalette,\n    QPixmap,\n    QStyle,\n    QStyleFactory,\n    Qt,\n    qtmajor,\n    qtminor,\n)\n\n\n@dataclass\nclass ColoredIcon:\n    path: str\n    color: dict[str, str]\n\n    def current_color(self, night_mode: bool) -> str:\n        if night_mode:\n            return self.color.get(\"dark\", \"\")\n        else:\n            return self.color.get(\"light\", \"\")\n\n    def with_color(self, color: dict[str, str]) -> ColoredIcon:\n        return ColoredIcon(path=self.path, color=color)\n\n\nclass WidgetStyle(enum.IntEnum):\n    ANKI = 0\n    NATIVE = 1\n\n\nclass Theme(enum.IntEnum):\n    FOLLOW_SYSTEM = 0\n    LIGHT = 1\n    DARK = 2\n\n\nclass ThemeManager:\n    _night_mode_preference = False\n    _icon_cache_light: dict[str, QIcon] = {}\n    _icon_cache_dark: dict[str, QIcon] = {}\n    _icon_size = 128\n    _dark_mode_available: bool | None = None\n    _default_style: str | None = None\n    _current_widget_style: WidgetStyle | None = None\n    _default_button_layout: int | None = None\n\n    def rtl(self) -> bool:\n        return is_rtl(anki.lang.current_lang)\n\n    def left(self) -> str:\n        return \"right\" if self.rtl() else \"left\"\n\n    def right(self) -> str:\n        return \"left\" if self.rtl() else \"right\"\n\n    # Qt applies a gradient to the buttons in dark mode\n    # from about #505050 to #606060.\n    DARK_MODE_BUTTON_BG_MIDPOINT = \"#555555\"\n\n    def macos_dark_mode(self) -> bool:\n        \"True if the user has night mode on.\"\n        if not is_mac:\n            return False\n\n        if not self._night_mode_preference:\n            return False\n\n        if self._dark_mode_available is None:\n            self._dark_mode_available = set_macos_dark_mode(True)\n\n        return self._dark_mode_available\n\n    def get_night_mode(self) -> bool:\n        return self._night_mode_preference\n\n    def set_night_mode(self, val: bool) -> None:\n        self._night_mode_preference = val\n        self._update_stat_colors()\n\n    night_mode = property(get_night_mode, set_night_mode)\n\n    def themed_icon(self, path: str) -> str:\n        \"Fetch themed version of svg.\"\n        from aqt.utils import aqt_data_folder\n\n        if m := re.match(r\"(?:mdi:)(.+)$\", path):\n            name = m.group(1)\n        else:\n            return path\n\n        filename = f\"{name}-{'dark' if self.night_mode else 'light'}.svg\"\n        path = os.path.join(aqt_data_folder(), \"qt\", \"icons\", filename)\n        path = path.replace(\"\\\\\\\\?\\\\\", \"\").replace(\"\\\\\", \"/\")\n        # Workaround for Qt bug. First attempt was percent-escaping the chars,\n        # but Qt can't handle that.\n        # https://forum.qt.io/topic/55274/solved-qss-with-special-characters/11\n        path = re.sub(r\"(['\\u00A1-\\u00FF])\", r\"\\\\\\1\", path)\n        return path\n\n    def icon_from_resources(self, path: str | ColoredIcon) -> QIcon:\n        \"Fetch icon from Qt resources.\"\n        if self.night_mode:\n            cache = self._icon_cache_light\n        else:\n            cache = self._icon_cache_dark\n\n        if isinstance(path, str):\n            key = path\n        else:\n            key = f\"{path.path}-{path.color}\"\n\n        icon = cache.get(key)\n        if icon:\n            return icon\n\n        if isinstance(path, str):\n            # default black/white\n            if \"mdi:\" in path:\n                icon = QIcon(self.themed_icon(path))\n            else:\n                icon = QIcon(path)\n                if self.night_mode:\n                    img = icon.pixmap(self._icon_size, self._icon_size).toImage()\n                    img.invertPixels()\n                    icon = QIcon(QPixmap(img))\n        else:\n            # specified colours\n            icon = QIcon(path.path)\n            pixmap = icon.pixmap(16)\n            painter = QPainter(pixmap)\n            painter.setCompositionMode(\n                QPainter.CompositionMode.CompositionMode_SourceIn\n            )\n            painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode)))\n            painter.end()\n            icon = QIcon(pixmap)\n            return icon\n\n        return cache.setdefault(path, icon)\n\n    def body_class(self, night_mode: bool | None = None, reviewer: bool = False) -> str:\n        \"Returns space-separated class list for platform/theme/global settings.\"\n        classes = []\n        if is_win:\n            classes.append(\"isWin\")\n        elif is_mac:\n            classes.append(\"isMac\")\n        else:\n            classes.append(\"isLin\")\n\n        if night_mode is None:\n            night_mode = self.night_mode\n        if night_mode:\n            classes.extend([\"nightMode\", \"night_mode\"])\n            if self.macos_dark_mode():\n                classes.append(\"macos-dark-mode\")\n        if aqt.mw.pm.reduce_motion() and not reviewer:\n            classes.append(\"reduce-motion\")\n        if not aqt.mw.pm.minimalist_mode():\n            classes.append(\"fancy\")\n        if qtmajor == 5 and qtminor < 15:\n            classes.append(\"no-blur\")\n        return \" \".join(classes)\n\n    def body_classes_for_card_ord(\n        self, card_ord: int, night_mode: bool | None = None\n    ) -> str:\n        \"Returns body classes used when showing a card.\"\n        return f\"card card{card_ord + 1} {self.body_class(night_mode, reviewer=True)}\"\n\n    def var(self, vars: dict[str, str]) -> str:\n        \"\"\"Given day/night colors/props, return the correct one for the current theme.\"\"\"\n        return vars[\"dark\" if self.night_mode else \"light\"]\n\n    def qcolor(self, colors: dict[str, str]) -> QColor:\n        \"\"\"Create QColor instance from CSS string for the current theme.\"\"\"\n\n        if m := re.match(\n            r\"rgba\\((\\d+),\\s*(\\d+),\\s*(\\d+),\\s*(\\d+\\.*\\d+?)\\)\", self.var(colors)\n        ):\n            return QColor(\n                int(m.group(1)),\n                int(m.group(2)),\n                int(m.group(3)),\n                int(255 * float(m.group(4))),\n            )\n        return QColor(self.var(colors))\n\n    def _determine_night_mode(self) -> bool:\n        theme = aqt.mw.pm.theme()\n        if theme == Theme.LIGHT:\n            return False\n        elif theme == Theme.DARK:\n            return True\n        elif is_win:\n            return get_windows_dark_mode()\n        elif is_mac:\n            return get_macos_dark_mode()\n        else:\n            return get_linux_dark_mode()\n\n    def apply_style(self) -> None:\n        \"Apply currently configured style.\"\n        new_theme = self._determine_night_mode()\n        theme_changed = self.night_mode != new_theme\n        new_widget_style = aqt.mw.pm.get_widget_style()\n        style_changed = self._current_widget_style != new_widget_style\n        if not theme_changed and not style_changed:\n            return\n        self.night_mode = new_theme\n        self._current_widget_style = new_widget_style\n        app = aqt.mw.app\n        if not self._default_style:\n            style = app.style()\n            assert style is not None\n            self._default_style = style.objectName()\n            self._default_button_layout = style.styleHint(\n                QStyle.StyleHint.SH_DialogButtonLayout\n            )\n        self._apply_palette(app)\n        self._apply_style(app)\n        gui_hooks.theme_did_change()\n\n    def _apply_style(self, app: QApplication) -> None:\n        buf = \"\"\n\n        if aqt.mw.pm.get_widget_style() == WidgetStyle.ANKI:\n            from aqt.stylesheets import custom_styles\n\n            app.setStyle(QStyleFactory.create(\"fusion\"))  # type: ignore\n\n            buf += \"\".join(\n                [\n                    custom_styles.general(self),\n                    custom_styles.button(self),\n                    custom_styles.checkbox(self),\n                    custom_styles.menu(self),\n                    custom_styles.combobox(self),\n                    custom_styles.tabwidget(self),\n                    custom_styles.table(self),\n                    custom_styles.spinbox(self),\n                    custom_styles.scrollbar(self),\n                    custom_styles.slider(self),\n                    custom_styles.splitter(self),\n                ]\n            )\n\n        else:\n            app.setStyle(QStyleFactory.create(self._default_style))  # type: ignore\n\n        # allow addons to modify the styling\n        buf = gui_hooks.style_did_init(buf)\n\n        app.setStyleSheet(buf)\n\n    def _apply_palette(self, app: QApplication) -> None:\n        set_macos_dark_mode(self.night_mode)\n\n        palette = QPalette()\n        text = self.qcolor(colors.FG)\n        palette.setColor(QPalette.ColorRole.WindowText, text)\n        palette.setColor(QPalette.ColorRole.ToolTipText, text)\n        palette.setColor(QPalette.ColorRole.Text, text)\n        palette.setColor(QPalette.ColorRole.ButtonText, text)\n\n        hlbg = self.qcolor(colors.HIGHLIGHT_BG)\n        palette.setColor(\n            QPalette.ColorRole.HighlightedText, self.qcolor(colors.HIGHLIGHT_FG)\n        )\n        palette.setColor(QPalette.ColorRole.Highlight, hlbg)\n\n        canvas = self.qcolor(colors.CANVAS)\n        palette.setColor(QPalette.ColorRole.Window, canvas)\n        palette.setColor(QPalette.ColorRole.AlternateBase, canvas)\n\n        palette.setColor(QPalette.ColorRole.Button, canvas)\n\n        input_base = self.qcolor(colors.CANVAS_CODE)\n        palette.setColor(QPalette.ColorRole.Base, input_base)\n        palette.setColor(QPalette.ColorRole.ToolTipBase, input_base)\n\n        palette.setColor(\n            QPalette.ColorRole.PlaceholderText, self.qcolor(colors.FG_SUBTLE)\n        )\n\n        disabled_color = self.qcolor(colors.FG_DISABLED)\n        palette.setColor(\n            QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_color\n        )\n        palette.setColor(\n            QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_color\n        )\n        palette.setColor(\n            QPalette.ColorGroup.Disabled,\n            QPalette.ColorRole.HighlightedText,\n            disabled_color,\n        )\n\n        palette.setColor(QPalette.ColorRole.Link, self.qcolor(colors.FG_LINK))\n\n        palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)\n\n        app.setPalette(palette)\n\n    def _update_stat_colors(self) -> None:\n        import anki.stats as s\n\n        s.colLearn = self.var(colors.STATE_NEW)\n        s.colRelearn = self.var(colors.STATE_LEARN)\n        s.colCram = self.var(colors.STATE_SUSPENDED)\n        s.colSusp = self.var(colors.STATE_SUSPENDED)\n        s.colMature = self.var(colors.STATE_REVIEW)\n        s._legacy_nightmode = self._night_mode_preference\n\n\ndef get_windows_dark_mode() -> bool:\n    \"True if Windows system is currently in dark mode.\"\n    if not is_win:\n        return False\n\n    from winreg import (  # type: ignore[attr-defined]\n        HKEY_CURRENT_USER,\n        OpenKey,\n        QueryValueEx,\n    )\n\n    try:\n        key = OpenKey(\n            HKEY_CURRENT_USER,\n            r\"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\",\n        )\n        return not QueryValueEx(key, \"AppsUseLightTheme\")[0]\n    except Exception:\n        # key reportedly missing or set to wrong type on some systems\n        return False\n\n\ndef set_macos_dark_mode(enabled: bool) -> bool:\n    \"True if setting successful.\"\n    from aqt._macos_helper import macos_helper\n\n    if not macos_helper:\n        return False\n    return macos_helper.set_darkmode_enabled(enabled)\n\n\ndef get_macos_dark_mode() -> bool:\n    \"True if macOS system is currently in dark mode.\"\n    from aqt._macos_helper import macos_helper\n\n    if not macos_helper:\n        return False\n    return macos_helper.system_is_dark()\n\n\ndef get_linux_dark_mode() -> bool:\n    \"\"\"True if Linux system is in dark mode.\n    Only works if D-Bus is installed and system uses org.freedesktop.appearance\n    color-scheme to indicate dark mode preference OR if GNOME theme has\n    '-dark' in the name.\"\"\"\n    if not is_lin:\n        return False\n\n    def parse_stdout_dbus_send(stdout: str) -> bool:\n        dbus_response = stdout.split()\n        if len(dbus_response) != 4:\n            return False\n\n        # https://github.com/flatpak/xdg-desktop-portal/blob/main/data/org.freedesktop.impl.portal.Settings.xml#L40\n        PREFER_DARK = \"1\"\n\n        return dbus_response[-1] == PREFER_DARK\n\n    dark_mode_detection_strategies: list[tuple[str, Callable[[str], bool]]] = [\n        (\n            \"dbus-send --session --print-reply=literal --reply-timeout=1000 \"\n            \"--dest=org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop \"\n            \"org.freedesktop.portal.Settings.Read string:'org.freedesktop.appearance' \"\n            \"string:'color-scheme'\",\n            parse_stdout_dbus_send,\n        ),\n        (\n            \"gsettings get org.gnome.desktop.interface gtk-theme\",\n            lambda stdout: \"-dark\" in stdout.lower(),\n        ),\n    ]\n\n    for cmd, parse_stdout in dark_mode_detection_strategies:\n        try:\n            process = subprocess.run(\n                cmd,\n                shell=True,\n                check=True,\n                capture_output=True,\n                encoding=\"utf8\",\n            )\n        except FileNotFoundError:\n            # detection strategy failed, missing program\n            # print(e)\n            continue\n\n        except subprocess.CalledProcessError:\n            # detection strategy failed, command returned error\n            # print(e)\n            continue\n\n        return parse_stdout(process.stdout)\n\n    return False  # all dark mode detection strategies failed\n\n\ntheme_manager = ThemeManager()\n"
  },
  {
    "path": "qt/aqt/toolbar.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport enum\nimport re\nfrom collections.abc import Callable\nfrom typing import Any, cast\n\nimport aqt\nfrom anki.sync import SyncStatus\nfrom aqt import gui_hooks, props\nfrom aqt.qt import *\nfrom aqt.sync import get_sync_status\nfrom aqt.theme import theme_manager\nfrom aqt.utils import tr\nfrom aqt.webview import AnkiWebView, AnkiWebViewKind\n\n\nclass HideMode(enum.IntEnum):\n    FULLSCREEN = 0\n    ALWAYS = 1\n\n\n# wrapper class for set_bridge_command()\nclass TopToolbar:\n    def __init__(self, toolbar: Toolbar) -> None:\n        self.toolbar = toolbar\n\n\n# wrapper class for set_bridge_command()\nclass BottomToolbar:\n    def __init__(self, toolbar: Toolbar) -> None:\n        self.toolbar = toolbar\n\n\nclass ToolbarWebView(AnkiWebView):\n    hide_condition: Callable[..., bool]\n\n    def __init__(\n        self, mw: aqt.AnkiQt, kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT\n    ) -> None:\n        AnkiWebView.__init__(self, mw, kind=kind)\n        self.mw = mw\n        self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)\n        self.disable_zoom()\n        self.hidden = False\n        self.hide_timer = QTimer()\n        self.hide_timer.setSingleShot(True)\n        self.reset_timer()\n\n    def reset_timer(self) -> None:\n        self.hide_timer.stop()\n        self.hide_timer.setInterval(2000)\n\n    def hide(self) -> None:\n        self.hidden = True\n\n    def show(self) -> None:\n        self.hidden = False\n\n\nclass TopWebView(ToolbarWebView):\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        super().__init__(mw, kind=AnkiWebViewKind.TOP_TOOLBAR)\n        self.web_height = 0\n        qconnect(self.hide_timer.timeout, self.hide_if_allowed)\n\n    def eventFilter(self, obj, evt):\n        if handled := super().eventFilter(obj, evt):\n            return handled\n\n        # prevent collapse of both toolbars if pointer is inside one of them\n        if evt.type() == QEvent.Type.Enter:\n            self.reset_timer()\n            self.mw.bottomWeb.reset_timer()\n            return True\n\n        return False\n\n    def on_body_classes_need_update(self) -> None:\n        super().on_body_classes_need_update()\n\n        if self.mw.state == \"review\":\n            if self.mw.pm.hide_top_bar():\n                self.eval(\"\"\"document.body.classList.remove(\"flat\"); \"\"\")\n            else:\n                self.flatten()\n\n        self.adjustHeightToFit()\n        self.show()\n\n    def _onHeight(self, qvar: int | None) -> None:\n        super()._onHeight(qvar)\n        if qvar:\n            self.web_height = int(qvar)\n\n    def hide_if_allowed(self) -> None:\n        if self.mw.state != \"review\":\n            return\n\n        # Invariant: The `hide_if_allowed` method ensures that the fullscreen state is checked\n        # and the menubar will be hidden if necessary\n        # Note: The `eventFilter` and `_reviewState` methods in `qt/aqt/main.py` rely on this invariant\n        if self.mw.fullscreen:\n            self.mw.hide_menubar()\n\n        if self.mw.pm.hide_top_bar():\n            if (\n                self.mw.pm.top_bar_hide_mode() == HideMode.FULLSCREEN\n                and not self.mw.windowState() & Qt.WindowState.WindowFullScreen\n            ):\n                self.show()\n                return\n\n            self.hide()\n\n    def hide(self) -> None:\n        super().hide()\n\n        self.hidden = True\n        self.eval(\n            \"\"\"document.body.classList.add(\"hidden\"); \"\"\",\n        )\n\n    def show(self) -> None:\n        super().show()\n\n        self.eval(\"\"\"document.body.classList.remove(\"hidden\"); \"\"\")\n\n    def flatten(self) -> None:\n        self.eval(\"\"\"document.body.classList.add(\"flat\"); \"\"\")\n\n    def elevate(self) -> None:\n        self.eval(\n            \"\"\"\n            document.body.classList.remove(\"flat\");\n            document.body.style.removeProperty(\"background\");\n            \"\"\"\n        )\n\n    def update_background_image(self) -> None:\n        if self.mw.pm.minimalist_mode():\n            return\n\n        def set_background(computed: str) -> None:\n            # remove offset from copy\n            background = re.sub(r\"-\\d+px \", \"0%\", computed)\n            # ensure alignment with main webview\n            background = re.sub(r\"\\sfixed\", \"\", background)\n            # change computedStyle px value back to 100vw\n            background = re.sub(r\"\\d+px\", \"100vw\", background)\n\n            self.eval(\n                f\"\"\"\n                    document.body.style.setProperty(\"background\", '{background}');\n                \"\"\"\n            )\n            self.set_body_height(self.mw.web.height())\n\n            # offset reviewer background by toolbar height\n            if self.web_height:\n                self.mw.web.eval(\n                    f\"\"\"document.body.style.setProperty(\"background-position-y\", \"-{self.web_height}px\"); \"\"\"\n                )\n\n        self.mw.web.evalWithCallback(\n            \"\"\"window.getComputedStyle(document.body).background; \"\"\",\n            set_background,\n        )\n\n    def set_body_height(self, height: int) -> None:\n        self.eval(\n            f\"\"\"document.body.style.setProperty(\"min-height\", \"{self.mw.web.height()}px\"); \"\"\"\n        )\n\n    def adjustHeightToFit(self) -> None:\n        self.eval(\"\"\"document.body.style.setProperty(\"min-height\", \"0px\"); \"\"\")\n        self.evalWithCallback(\"document.documentElement.offsetHeight\", self._onHeight)\n\n    def resizeEvent(self, event: QResizeEvent | None) -> None:\n        super().resizeEvent(event)\n\n        self.mw.web.evalWithCallback(\n            \"\"\"window.innerHeight; \"\"\",\n            self.set_body_height,\n        )\n\n\nclass BottomWebView(ToolbarWebView):\n    def __init__(self, mw: aqt.AnkiQt) -> None:\n        super().__init__(mw, kind=AnkiWebViewKind.BOTTOM_TOOLBAR)\n        qconnect(self.hide_timer.timeout, self.hide_if_allowed)\n\n    def eventFilter(self, obj, evt):\n        if handled := super().eventFilter(obj, evt):\n            return handled\n\n        if evt.type() == QEvent.Type.Enter:\n            self.reset_timer()\n            self.mw.toolbarWeb.reset_timer()\n            return True\n\n        return False\n\n    def on_body_classes_need_update(self) -> None:\n        super().on_body_classes_need_update()\n        if self.mw.state == \"review\":\n            self.show()\n\n    def animate_height(self, height: int) -> None:\n        self.web_height = height\n\n        if self.mw.pm.reduce_motion() or height == self.height():\n            self.setFixedHeight(height)\n        else:\n            # Collapse/Expand animation\n            self.setMinimumHeight(0)\n            self.animation = QPropertyAnimation(\n                self, cast(QByteArray, b\"maximumHeight\")\n            )\n            self.animation.setDuration(int(theme_manager.var(props.TRANSITION)))\n            self.animation.setStartValue(self.height())\n            self.animation.setEndValue(height)\n            qconnect(self.animation.finished, lambda: self.setFixedHeight(height))\n            self.animation.start()\n\n    def hide_if_allowed(self) -> None:\n        if self.mw.state != \"review\":\n            return\n\n        if self.mw.pm.hide_bottom_bar():\n            if (\n                self.mw.pm.bottom_bar_hide_mode() == HideMode.FULLSCREEN\n                and not self.mw.windowState() & Qt.WindowState.WindowFullScreen\n            ):\n                self.show()\n                return\n\n            self.hide()\n\n    def hide(self) -> None:\n        super().hide()\n\n        self.hidden = True\n        self.animate_height(1)\n\n    def show(self) -> None:\n        super().show()\n\n        self.hidden = False\n        if self.mw.state == \"review\":\n            # delay to account for reflow\n            def cb(height: int | None):\n                # \"When QWebEnginePage is deleted, the callback is triggered with an invalid value\"\n                if height is not None:\n                    self.animate_height(height)\n\n            self.mw.progress.single_shot(\n                50,\n                lambda: self.evalWithCallback(\n                    \"document.documentElement.offsetHeight\", cb\n                ),\n                False,\n            )\n        else:\n            self.adjustHeightToFit()\n\n\nclass Toolbar:\n    def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:\n        self.mw = mw\n        self.web = web\n        self.link_handlers: dict[str, Callable] = {\n            \"study\": self._studyLinkHandler,\n        }\n        self.web.requiresCol = False\n\n    def draw(\n        self,\n        buf: str = \"\",\n        web_context: Any | None = None,\n        link_handler: Callable[[str], Any] | None = None,\n    ) -> None:\n        web_context = web_context or TopToolbar(self)\n        link_handler = link_handler or self._linkHandler\n        self.web.set_bridge_command(link_handler, web_context)\n        body = self._body.format(\n            toolbar_content=self._centerLinks(),\n            left_tray_content=self._left_tray_content(),\n            right_tray_content=self._right_tray_content(),\n        )\n        self.web.stdHtml(\n            body,\n            css=[\"css/toolbar.css\"],\n            js=[\"js/vendor/jquery.min.js\", \"js/toolbar.js\"],\n            context=web_context,\n        )\n        self.web.adjustHeightToFit()\n\n    def redraw(self) -> None:\n        self.set_sync_active(self.mw.media_syncer.is_syncing())\n        self.update_sync_status()\n        gui_hooks.top_toolbar_did_redraw(self)\n\n    # Available links\n    ######################################################################\n\n    def create_link(\n        self,\n        cmd: str,\n        label: str,\n        func: Callable,\n        tip: str | None = None,\n        id: str | None = None,\n    ) -> str:\n        \"\"\"Generates HTML link element and registers link handler\n\n        Arguments:\n            cmd {str} -- Command name used for the JS → Python bridge\n            label {str} -- Display label of the link\n            func {Callable} -- Callable to be called on clicking the link\n\n        Keyword Arguments:\n            tip {Optional[str]} -- Optional tooltip text to show on hovering\n                                   over the link (default: {None})\n            id: {Optional[str]} -- Optional id attribute to supply the link with\n                                   (default: {None})\n\n        Returns:\n            str -- HTML link element\n        \"\"\"\n\n        self.link_handlers[cmd] = func\n\n        title_attr = f'title=\"{tip}\"' if tip else \"\"\n        id_attr = f'id=\"{id}\"' if id else \"\"\n\n        return (\n            f\"\"\"<a class=hitem tabindex=\"-1\" aria-label=\"{label}\" \"\"\"\n            f\"\"\"{title_attr} {id_attr} href=# onclick=\"return pycmd('{cmd}')\">\"\"\"\n            f\"\"\"{label}</a>\"\"\"\n        )\n\n    def _centerLinks(self) -> str:\n        links = [\n            self.create_link(\n                \"decks\",\n                tr.actions_decks(),\n                self._deckLinkHandler,\n                tip=tr.actions_shortcut_key(val=\"D\"),\n                id=\"decks\",\n            ),\n            self.create_link(\n                \"add\",\n                tr.actions_add(),\n                self._addLinkHandler,\n                tip=tr.actions_shortcut_key(val=\"A\"),\n                id=\"add\",\n            ),\n            self.create_link(\n                \"browse\",\n                tr.qt_misc_browse(),\n                self._browseLinkHandler,\n                tip=tr.actions_shortcut_key(val=\"B\"),\n                id=\"browse\",\n            ),\n            self.create_link(\n                \"stats\",\n                tr.qt_misc_stats(),\n                self._statsLinkHandler,\n                tip=tr.actions_shortcut_key(val=\"T\"),\n                id=\"stats\",\n            ),\n        ]\n\n        links.append(self._create_sync_link())\n\n        gui_hooks.top_toolbar_did_init_links(links, self)\n\n        return \"\\n\".join(links)\n\n    # Add-ons\n    ######################################################################\n\n    def _left_tray_content(self) -> str:\n        left_tray_content: list[str] = []\n        gui_hooks.top_toolbar_will_set_left_tray_content(left_tray_content, self)\n        return self._process_tray_content(left_tray_content)\n\n    def _right_tray_content(self) -> str:\n        right_tray_content: list[str] = []\n        gui_hooks.top_toolbar_will_set_right_tray_content(right_tray_content, self)\n        return self._process_tray_content(right_tray_content)\n\n    def _process_tray_content(self, content: list[str]) -> str:\n        return \"\\n\".join(f\"\"\"<div class=\"tray-item\">{item}</div>\"\"\" for item in content)\n\n    # Sync\n    ######################################################################\n\n    def _create_sync_link(self) -> str:\n        name = tr.qt_misc_sync()\n        title = tr.actions_shortcut_key(val=\"Y\")\n        label = \"sync\"\n        self.link_handlers[label] = self._syncLinkHandler\n\n        return f\"\"\"\n<a class=hitem tabindex=\"-1\" aria-label=\"{name}\" title=\"{title}\" id=\"{label}\" href=# onclick=\"return pycmd('{label}')\"\n>{name}<img id=sync-spinner src='/_anki/imgs/refresh.svg'>\n</a>\"\"\"\n\n    def set_sync_active(self, active: bool) -> None:\n        method = \"add\" if active else \"remove\"\n        self.web.eval(\n            f\"document.getElementById('sync-spinner').classList.{method}('spin')\"\n        )\n\n    def set_sync_status(self, status: SyncStatus) -> None:\n        self.web.eval(f\"updateSyncColor({status.required})\")\n\n    def update_sync_status(self) -> None:\n        get_sync_status(self.mw, self.mw.toolbar.set_sync_status)\n\n    # Link handling\n    ######################################################################\n\n    def _linkHandler(self, link: str) -> bool:\n        if link in self.link_handlers:\n            self.link_handlers[link]()\n        return False\n\n    def _deckLinkHandler(self) -> None:\n        self.mw.moveToState(\"deckBrowser\")\n\n    def _studyLinkHandler(self) -> None:\n        # if overview already shown, switch to review\n        if self.mw.state == \"overview\":\n            self.mw.col.startTimebox()\n            self.mw.moveToState(\"review\")\n        else:\n            self.mw.onOverview()\n\n    def _addLinkHandler(self) -> None:\n        self.mw.onAddCard()\n\n    def _browseLinkHandler(self) -> None:\n        self.mw.onBrowse()\n\n    def _statsLinkHandler(self) -> None:\n        self.mw.onStats()\n\n    def _syncLinkHandler(self) -> None:\n        self.mw.on_sync_button_clicked()\n\n    # HTML & CSS\n    ######################################################################\n\n    _body = \"\"\"\n<div class=\"header\">\n  <div class=\"left-tray\">{left_tray_content}</div>\n  <div class=\"toolbar\">{toolbar_content}</div>\n  <div class=\"right-tray\">{right_tray_content}</div>\n</div>\n\"\"\"\n\n\n# Bottom bar\n######################################################################\n\n\nclass BottomBar(Toolbar):\n    _centerBody = \"\"\"\n<center id=outer><table width=100%% id=header><tr><td align=center>\n%s</td></tr></table></center>\n\"\"\"\n\n    def draw(\n        self,\n        buf: str = \"\",\n        web_context: Any | None = None,\n        link_handler: Callable[[str], Any] | None = None,\n    ) -> None:\n        # note: some screens may override this\n        web_context = web_context or BottomToolbar(self)\n        link_handler = link_handler or self._linkHandler\n        self.web.set_bridge_command(link_handler, web_context)\n        self.web.stdHtml(\n            self._centerBody % buf,\n            css=[\"css/toolbar.css\", \"css/toolbar-bottom.css\"],\n            context=web_context,\n        )\n        self.web.adjustHeightToFit()\n"
  },
  {
    "path": "qt/aqt/tts.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nBasic text to speech support.\n\nUsers can use the following in their card template:\n\n{{tts en_US:Field}}\n\nor\n\n{{tts ja_JP voices=Kyoko,Otoya,Another_name:Field}}\n\nThe first argument must be an underscored language code, eg en_US.\n\nIf provided, voices is a comma-separated list of one or more voices that\nthe user would prefer. Spaces must not be included. Underscores will be\nconverted to spaces.\n\nAVPlayer decides which TTSPlayer to use based on the returned rank.\nIn the default implementation, the TTS player is chosen based on the order\nof voices the user has specified. When adding new TTS players, your code\ncan either expose the underlying names the TTS engine provides, or simply\nexpose the name of the engine, which would mean the user could write\n{{tts en_AU voices=MyEngine}} to prioritize your engine.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport subprocess\nfrom concurrent.futures import Future\nfrom dataclasses import dataclass\nfrom operator import attrgetter\nfrom typing import Any, cast\n\nimport anki\nimport anki.template\nimport aqt\nfrom anki import hooks\nfrom anki.collection import TtsVoice as BackendVoice\nfrom anki.sound import AVTag, TTSTag\nfrom anki.utils import checksum, is_win, tmpdir\nfrom aqt import gui_hooks\nfrom aqt.sound import OnDoneCallback, SimpleProcessPlayer\nfrom aqt.utils import tooltip, tr\n\n\n@dataclass\nclass TTSVoice:\n    name: str\n    lang: str\n\n    def __str__(self) -> str:\n        out = f\"{{{{tts {self.lang} voices={self.name}}}}}\"\n        if self.unavailable():\n            out += \" (unavailable)\"\n        return out\n\n    def unavailable(self) -> bool:\n        return False\n\n\n@dataclass\nclass TTSVoiceMatch:\n    voice: TTSVoice\n    rank: int\n\n\nclass TTSPlayer:\n    default_rank = 0\n    _available_voices: list[TTSVoice] | None = None\n\n    def get_available_voices(self) -> list[TTSVoice]:\n        return []\n\n    def voices(self) -> list[TTSVoice]:\n        if self._available_voices is None:\n            self._available_voices = self.get_available_voices()\n        return self._available_voices\n\n    def voice_for_tag(self, tag: TTSTag) -> TTSVoiceMatch | None:\n        avail_voices = self.voices()\n\n        rank = self.default_rank\n\n        # any requested voices match?\n        for requested_voice in tag.voices:\n            for avail in avail_voices:\n                if avail.name == requested_voice and avail.lang == tag.lang:\n                    return TTSVoiceMatch(voice=avail, rank=rank)\n\n            rank -= 1\n\n        # if no requested voices match, use a preferred fallback voice\n        # (for example, Apple Samantha) with rank of -50\n        for avail in avail_voices:\n            if avail.lang == tag.lang:\n                if avail.lang == \"en_US\" and avail.name.startswith(\"Apple_Samantha\"):\n                    return TTSVoiceMatch(voice=avail, rank=-50)\n\n        # if no requested or preferred voices match, we fall back on\n        # the first available voice for the language, with a rank of -100\n        for avail in avail_voices:\n            if avail.lang == tag.lang:\n                return TTSVoiceMatch(voice=avail, rank=-100)\n\n        return None\n\n    def temp_file_for_tag_and_voice(self, tag: AVTag, voice: TTSVoice) -> str:\n        \"\"\"Return a hashed filename, to allow for caching generated files.\n\n        No file extension is included.\"\"\"\n        assert isinstance(tag, TTSTag)\n        buf = f\"{voice.name}-{voice.lang}-{tag.field_text}\"\n        return os.path.join(tmpdir(), f\"tts-{checksum(buf)}\")\n\n\nclass TTSProcessPlayer(SimpleProcessPlayer, TTSPlayer):\n    # mypy gets confused if rank_for_tag is defined in TTSPlayer\n    def rank_for_tag(self, tag: AVTag) -> int | None:\n        if not isinstance(tag, TTSTag):\n            return None\n\n        match = self.voice_for_tag(tag)\n        if match:\n            return match.rank\n        else:\n            return None\n\n\n# tts-voices filter\n##########################################################################\n\n\ndef all_tts_voices() -> list[TTSVoice]:\n    from aqt.sound import av_player\n\n    all_voices: list[TTSVoice] = []\n    for p in av_player.players:\n        getter = getattr(p, \"validated_voices\", getattr(p, \"voices\", None))\n        if getter:\n            all_voices.extend(getter())\n    return all_voices\n\n\ndef on_tts_voices(\n    text: str, field: str, filter: str, ctx: anki.template.TemplateRenderContext\n) -> str:\n    if filter != \"tts-voices\":\n        return text\n    voices = all_tts_voices()\n    voices.sort(key=attrgetter(\"lang\", \"name\"))\n\n    buf = \"<div style='font-size: 14px; text-align: left;'>TTS voices available:<br>\"\n    buf += \"<br>\".join(map(str, voices))\n    if any(v.unavailable() for v in voices):\n        buf += \"<div>One or more voices are unavailable.\"\n        buf += \" Installing a Windows language pack may help.</div>\"\n    return f\"{buf}</div>\"\n\n\nhooks.field_filter.append(on_tts_voices)\n\n# Mac support\n##########################################################################\n\n\n@dataclass\nclass MacVoice(TTSVoice):\n    original_name: str\n\n\nclass MacTTSPlayer(TTSProcessPlayer):\n    \"Invokes a process to play the audio in the background.\"\n\n    VOICE_HELP_LINE_RE = re.compile(r\"^(.+)\\s+(\\S+)\\s+#.*$\")\n\n    def _play(self, tag: AVTag) -> None:\n        assert isinstance(tag, TTSTag)\n        match = self.voice_for_tag(tag)\n        assert match\n        voice = match.voice\n        assert isinstance(voice, MacVoice)\n\n        default_wpm = 170\n        words_per_min = str(int(default_wpm * tag.speed))\n\n        self._process = subprocess.Popen(\n            [\"say\", \"-v\", voice.original_name, \"-r\", words_per_min, \"-f\", \"-\"],\n            stdin=subprocess.PIPE,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        # write the input text to stdin\n        assert self._process.stdin is not None\n        self._process.stdin.write(tag.field_text.encode(\"utf8\"))\n        self._process.stdin.close()\n        self._wait_for_termination(tag)\n\n    def get_available_voices(self) -> list[TTSVoice]:\n        cmd = subprocess.run(\n            [\"say\", \"-v\", \"?\"], capture_output=True, check=True, encoding=\"utf8\"\n        )\n\n        voices = []\n        for line in cmd.stdout.splitlines():\n            voice = self._parse_voice_line(line)\n            if voice:\n                voices.append(voice)\n        return voices\n\n    def _parse_voice_line(self, line: str) -> TTSVoice | None:\n        m = self.VOICE_HELP_LINE_RE.match(line)\n        if not m:\n            return None\n\n        original_name = m.group(1).strip()\n        tidy_name = f\"Apple_{original_name.replace(' ', '_')}\"\n        return MacVoice(name=tidy_name, original_name=original_name, lang=m.group(2))\n\n\nclass MacTTSFilePlayer(MacTTSPlayer):\n    \"Generates an .aiff file, which is played using av_player.\"\n\n    tmppath = os.path.join(tmpdir(), \"tts.aiff\")\n\n    def _play(self, tag: AVTag) -> None:\n        assert isinstance(tag, TTSTag)\n        match = self.voice_for_tag(tag)\n        assert match\n        voice = match.voice\n        assert isinstance(voice, MacVoice)\n\n        default_wpm = 170\n        words_per_min = str(int(default_wpm * tag.speed))\n\n        self._process = subprocess.Popen(\n            [\n                \"say\",\n                \"-v\",\n                voice.original_name,\n                \"-r\",\n                words_per_min,\n                \"-f\",\n                \"-\",\n                \"-o\",\n                self.tmppath,\n            ],\n            stdin=subprocess.PIPE,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        # write the input text to stdin\n        assert self._process.stdin is not None\n        self._process.stdin.write(tag.field_text.encode(\"utf8\"))\n        self._process.stdin.close()\n        self._wait_for_termination(tag)\n\n    def _on_done(self, ret: Future, cb: OnDoneCallback) -> None:\n        ret.result()\n\n        # inject file into the top of the audio queue\n        from aqt.sound import av_player\n\n        av_player.current_player = None\n        av_player.insert_file(self.tmppath)\n\n\n# Windows support\n##########################################################################\n\n\n@dataclass\nclass WindowsVoice(TTSVoice):\n    handle: Any\n\n\nif is_win:\n    # language ID map from https://github.com/sindresorhus/lcid/blob/master/lcid.json\n    LCIDS = {\n        \"4\": \"zh_CHS\",\n        \"1025\": \"ar_SA\",\n        \"1026\": \"bg_BG\",\n        \"1027\": \"ca_ES\",\n        \"1028\": \"zh_TW\",\n        \"1029\": \"cs_CZ\",\n        \"1030\": \"da_DK\",\n        \"1031\": \"de_DE\",\n        \"1032\": \"el_GR\",\n        \"1033\": \"en_US\",\n        \"1034\": \"es_ES\",\n        \"1035\": \"fi_FI\",\n        \"1036\": \"fr_FR\",\n        \"1037\": \"he_IL\",\n        \"1038\": \"hu_HU\",\n        \"1039\": \"is_IS\",\n        \"1040\": \"it_IT\",\n        \"1041\": \"ja_JP\",\n        \"1042\": \"ko_KR\",\n        \"1043\": \"nl_NL\",\n        \"1044\": \"nb_NO\",\n        \"1045\": \"pl_PL\",\n        \"1046\": \"pt_BR\",\n        \"1047\": \"rm_CH\",\n        \"1048\": \"ro_RO\",\n        \"1049\": \"ru_RU\",\n        \"1050\": \"hr_HR\",\n        \"1051\": \"sk_SK\",\n        \"1052\": \"sq_AL\",\n        \"1053\": \"sv_SE\",\n        \"1054\": \"th_TH\",\n        \"1055\": \"tr_TR\",\n        \"1056\": \"ur_PK\",\n        \"1057\": \"id_ID\",\n        \"1058\": \"uk_UA\",\n        \"1059\": \"be_BY\",\n        \"1060\": \"sl_SI\",\n        \"1061\": \"et_EE\",\n        \"1062\": \"lv_LV\",\n        \"1063\": \"lt_LT\",\n        \"1064\": \"tg_TJ\",\n        \"1065\": \"fa_IR\",\n        \"1066\": \"vi_VN\",\n        \"1067\": \"hy_AM\",\n        \"1069\": \"eu_ES\",\n        \"1070\": \"wen_DE\",\n        \"1071\": \"mk_MK\",\n        \"1074\": \"tn_ZA\",\n        \"1076\": \"xh_ZA\",\n        \"1077\": \"zu_ZA\",\n        \"1078\": \"af_ZA\",\n        \"1079\": \"ka_GE\",\n        \"1080\": \"fo_FO\",\n        \"1081\": \"hi_IN\",\n        \"1082\": \"mt_MT\",\n        \"1083\": \"se_NO\",\n        \"1086\": \"ms_MY\",\n        \"1087\": \"kk_KZ\",\n        \"1088\": \"ky_KG\",\n        \"1089\": \"sw_KE\",\n        \"1090\": \"tk_TM\",\n        \"1092\": \"tt_RU\",\n        \"1093\": \"bn_IN\",\n        \"1094\": \"pa_IN\",\n        \"1095\": \"gu_IN\",\n        \"1096\": \"or_IN\",\n        \"1097\": \"ta_IN\",\n        \"1098\": \"te_IN\",\n        \"1099\": \"kn_IN\",\n        \"1100\": \"ml_IN\",\n        \"1101\": \"as_IN\",\n        \"1102\": \"mr_IN\",\n        \"1103\": \"sa_IN\",\n        \"1104\": \"mn_MN\",\n        \"1105\": \"bo_CN\",\n        \"1106\": \"cy_GB\",\n        \"1107\": \"kh_KH\",\n        \"1108\": \"lo_LA\",\n        \"1109\": \"my_MM\",\n        \"1110\": \"gl_ES\",\n        \"1111\": \"kok_IN\",\n        \"1114\": \"syr_SY\",\n        \"1115\": \"si_LK\",\n        \"1118\": \"am_ET\",\n        \"1121\": \"ne_NP\",\n        \"1122\": \"fy_NL\",\n        \"1123\": \"ps_AF\",\n        \"1124\": \"fil_PH\",\n        \"1125\": \"div_MV\",\n        \"1128\": \"ha_NG\",\n        \"1130\": \"yo_NG\",\n        \"1131\": \"quz_BO\",\n        \"1132\": \"ns_ZA\",\n        \"1133\": \"ba_RU\",\n        \"1134\": \"lb_LU\",\n        \"1135\": \"kl_GL\",\n        \"1144\": \"ii_CN\",\n        \"1146\": \"arn_CL\",\n        \"1148\": \"moh_CA\",\n        \"1150\": \"br_FR\",\n        \"1152\": \"ug_CN\",\n        \"1153\": \"mi_NZ\",\n        \"1154\": \"oc_FR\",\n        \"1155\": \"co_FR\",\n        \"1156\": \"gsw_FR\",\n        \"1157\": \"sah_RU\",\n        \"1158\": \"qut_GT\",\n        \"1159\": \"rw_RW\",\n        \"1160\": \"wo_SN\",\n        \"1164\": \"gbz_AF\",\n        \"2049\": \"ar_IQ\",\n        \"2052\": \"zh_CN\",\n        \"2055\": \"de_CH\",\n        \"2057\": \"en_GB\",\n        \"2058\": \"es_MX\",\n        \"2060\": \"fr_BE\",\n        \"2064\": \"it_CH\",\n        \"2067\": \"nl_BE\",\n        \"2068\": \"nn_NO\",\n        \"2070\": \"pt_PT\",\n        \"2077\": \"sv_FI\",\n        \"2080\": \"ur_IN\",\n        \"2092\": \"az_AZ\",\n        \"2094\": \"dsb_DE\",\n        \"2107\": \"se_SE\",\n        \"2108\": \"ga_IE\",\n        \"2110\": \"ms_BN\",\n        \"2115\": \"uz_UZ\",\n        \"2128\": \"mn_CN\",\n        \"2129\": \"bo_BT\",\n        \"2141\": \"iu_CA\",\n        \"2143\": \"tmz_DZ\",\n        \"2155\": \"quz_EC\",\n        \"3073\": \"ar_EG\",\n        \"3076\": \"zh_HK\",\n        \"3079\": \"de_AT\",\n        \"3081\": \"en_AU\",\n        \"3082\": \"es_ES\",\n        \"3084\": \"fr_CA\",\n        \"3098\": \"sr_SP\",\n        \"3131\": \"se_FI\",\n        \"3179\": \"quz_PE\",\n        \"4097\": \"ar_LY\",\n        \"4100\": \"zh_SG\",\n        \"4103\": \"de_LU\",\n        \"4105\": \"en_CA\",\n        \"4106\": \"es_GT\",\n        \"4108\": \"fr_CH\",\n        \"4122\": \"hr_BA\",\n        \"4155\": \"smj_NO\",\n        \"5121\": \"ar_DZ\",\n        \"5124\": \"zh_MO\",\n        \"5127\": \"de_LI\",\n        \"5129\": \"en_NZ\",\n        \"5130\": \"es_CR\",\n        \"5132\": \"fr_LU\",\n        \"5179\": \"smj_SE\",\n        \"6145\": \"ar_MA\",\n        \"6153\": \"en_IE\",\n        \"6154\": \"es_PA\",\n        \"6156\": \"fr_MC\",\n        \"6203\": \"sma_NO\",\n        \"7169\": \"ar_TN\",\n        \"7177\": \"en_ZA\",\n        \"7178\": \"es_DO\",\n        \"7194\": \"sr_BA\",\n        \"7227\": \"sma_SE\",\n        \"8193\": \"ar_OM\",\n        \"8201\": \"en_JA\",\n        \"8202\": \"es_VE\",\n        \"8218\": \"bs_BA\",\n        \"8251\": \"sms_FI\",\n        \"9217\": \"ar_YE\",\n        \"9225\": \"en_CB\",\n        \"9226\": \"es_CO\",\n        \"9275\": \"smn_FI\",\n        \"10241\": \"ar_SY\",\n        \"10249\": \"en_BZ\",\n        \"10250\": \"es_PE\",\n        \"11265\": \"ar_JO\",\n        \"11273\": \"en_TT\",\n        \"11274\": \"es_AR\",\n        \"12289\": \"ar_LB\",\n        \"12297\": \"en_ZW\",\n        \"12298\": \"es_EC\",\n        \"13313\": \"ar_KW\",\n        \"13321\": \"en_PH\",\n        \"13322\": \"es_CL\",\n        \"14337\": \"ar_AE\",\n        \"14346\": \"es_UR\",\n        \"15361\": \"ar_BH\",\n        \"15370\": \"es_PY\",\n        \"16385\": \"ar_QA\",\n        \"16394\": \"es_BO\",\n        \"17417\": \"en_MY\",\n        \"17418\": \"es_SV\",\n        \"18441\": \"en_IN\",\n        \"18442\": \"es_HN\",\n        \"19466\": \"es_NI\",\n        \"20490\": \"es_PR\",\n        \"21514\": \"es_US\",\n        \"31748\": \"zh_CHT\",\n    }\n\n    def lcid_hex_str_to_lang_codes(hex_codes: str) -> list[str]:\n        return [\n            LCIDS.get(str(int(code, 16)), \"unknown\") for code in hex_codes.split(\";\")\n        ]\n\n    class WindowsTTSPlayer(TTSProcessPlayer):\n        default_rank = -1\n        try:\n            import win32com.client\n\n            speaker = win32com.client.Dispatch(\"SAPI.SpVoice\")\n        except Exception as exc:\n            print(\"unable to activate sapi:\", exc)\n            speaker = None\n\n        def get_available_voices(self) -> list[TTSVoice]:\n            if self.speaker is None:\n                return []\n            return [\n                obj\n                for voice in self.speaker.GetVoices()\n                for obj in self._voice_to_objects(voice)\n            ]\n\n        def _voice_to_objects(self, voice: Any) -> list[WindowsVoice]:\n            try:\n                langs = voice.GetAttribute(\"language\")\n            except Exception:\n                # no associated language; ignore\n                return []\n            langs = lcid_hex_str_to_lang_codes(langs)\n            try:\n                name = voice.GetAttribute(\"name\")\n            except Exception:\n                # some voices may not have a name\n                name = \"unknown\"\n            name = self._tidy_name(name)\n            return [WindowsVoice(name=name, lang=lang, handle=voice) for lang in langs]\n\n        def _play(self, tag: AVTag) -> None:\n            assert isinstance(tag, TTSTag)\n            match = self.voice_for_tag(tag)\n            assert match\n            voice = cast(WindowsVoice, match.voice)\n\n            try:\n                native_voice = voice.handle\n                self.speaker.Voice = native_voice\n                self.speaker.Rate = self._rate_for_speed(tag.speed)\n\n                # SAPI SpeechVoiceSpeakFlags: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ee125223(v=vs.85)\n                ASYNC = 1\n                IS_NOT_XML = 16\n                self.speaker.Speak(tag.field_text, ASYNC + IS_NOT_XML)\n                gui_hooks.av_player_did_begin_playing(self, tag)\n\n                # wait 100ms\n                while not self.speaker.WaitUntilDone(100):\n                    if self._terminate_flag:\n                        # stop playing\n                        self.speaker.Skip(\"Sentence\", 2**15)\n                        return\n            finally:\n                self._terminate_flag = False\n\n        def _tidy_name(self, name: str) -> str:\n            \"eg. Microsoft Haruka Desktop -> Microsoft_Haruka.\"\n            return re.sub(r\"^Microsoft (.+) Desktop$\", \"Microsoft_\\\\1\", name).replace(\n                \" \", \"_\"\n            )\n\n        def _rate_for_speed(self, speed: float) -> int:\n            \"eg. 1.5 -> 15, 0.5 -> -5\"\n            speed = (speed * 10) - 10\n            return int(max(-10, min(10, speed)))\n\n    @dataclass\n    class WindowsRTVoice(TTSVoice):\n        id: str\n        available: bool | None = None\n\n        def unavailable(self) -> bool:\n            return self.available is False\n\n        @classmethod\n        def from_backend_voice(cls, voice: BackendVoice) -> WindowsRTVoice:\n            return cls(\n                id=voice.id,\n                name=voice.name.replace(\" \", \"_\"),\n                lang=voice.language.replace(\"-\", \"_\"),\n                available=voice.available,\n            )\n\n    class WindowsRTTTSFilePlayer(TTSProcessPlayer):\n        tmppath = os.path.join(tmpdir(), \"tts.wav\")\n\n        def validated_voices(self) -> list[TTSVoice]:\n            self._available_voices = self._get_available_voices(validate=True)\n            return self._available_voices\n\n        @classmethod\n        def get_available_voices(cls) -> list[TTSVoice]:\n            return cls._get_available_voices(validate=False)\n\n        @staticmethod\n        def _get_available_voices(validate: bool) -> list[TTSVoice]:\n            assert aqt.mw\n            voices = aqt.mw.backend.all_tts_voices(validate=validate)\n            return list(map(WindowsRTVoice.from_backend_voice, voices))\n\n        def _play(self, tag: AVTag) -> None:\n            assert aqt.mw\n            assert isinstance(tag, TTSTag)\n            match = self.voice_for_tag(tag)\n            assert match\n            voice = cast(WindowsRTVoice, match.voice)\n\n            self._taskman.run_on_main(\n                lambda: gui_hooks.av_player_did_begin_playing(self, tag)\n            )\n            aqt.mw.backend.write_tts_stream(\n                path=self.tmppath,\n                voice_id=voice.id,\n                speed=tag.speed,\n                text=tag.field_text,\n            )\n\n        def _on_done(self, ret: Future, cb: OnDoneCallback) -> None:\n            if exception := ret.exception():\n                print(str(exception))\n                tooltip(tr.errors_windows_tts_runtime_error())\n                cb()\n                return\n\n            # inject file into the top of the audio queue\n            from aqt.sound import av_player\n\n            av_player.current_player = None\n            av_player.insert_file(self.tmppath)\n"
  },
  {
    "path": "qt/aqt/undo.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\nfrom anki.collection import UndoStatus\n\n\n@dataclass\nclass UndoActionsInfo:\n    can_undo: bool\n    can_redo: bool\n\n    undo_text: str\n    redo_text: str\n\n    # menu item is hidden when legacy undo is active, since it can't be undone\n    show_redo: bool\n\n    @staticmethod\n    def from_undo_status(status: UndoStatus) -> UndoActionsInfo:\n        from aqt import tr\n\n        return UndoActionsInfo(\n            can_undo=bool(status.undo),\n            can_redo=bool(status.redo),\n            undo_text=(\n                tr.undo_undo_action(val=status.undo) if status.undo else tr.undo_undo()\n            ),\n            redo_text=(\n                tr.undo_redo_action(action=status.redo)\n                if status.redo\n                else tr.undo_redo()\n            ),\n            show_redo=status.last_step > 0,\n        )\n"
  },
  {
    "path": "qt/aqt/update.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport aqt\nfrom anki.buildinfo import buildhash\nfrom anki.collection import CheckForUpdateResponse, Collection\nfrom anki.utils import dev_mode, int_time, int_version, plat_desc\nfrom aqt.operations import QueryOp\nfrom aqt.package import (\n    launcher_executable as _launcher_executable,\n)\nfrom aqt.package import (\n    update_and_restart as _update_and_restart,\n)\nfrom aqt.qt import *\nfrom aqt.utils import openLink, show_warning, showText, tr\n\n\ndef check_for_update() -> None:\n    from aqt import mw\n\n    def do_check(_col: Collection) -> CheckForUpdateResponse:\n        return mw.backend.check_for_update(\n            version=int_version(),\n            buildhash=buildhash,\n            os=plat_desc(),\n            install_id=mw.pm.meta[\"id\"],\n            last_message_id=max(0, mw.pm.meta[\"lastMsg\"]),\n        )\n\n    def on_done(resp: CheckForUpdateResponse) -> None:\n        # is clock off?\n        if not dev_mode:\n            diff = abs(resp.current_time - int_time())\n            if diff > 300:\n                diff_text = tr.qt_misc_second(count=diff)\n                warn = (\n                    tr.qt_misc_in_order_to_ensure_your_collection(val=\"%s\") % diff_text\n                )\n                show_warning(\n                    warn,\n                    parent=mw,\n                    textFormat=Qt.TextFormat.RichText,\n                    callback=mw.app.closeAllWindows,\n                )\n                return\n        # should we show a message?\n        if msg := resp.message:\n            showText(msg, parent=mw, type=\"html\")\n            mw.pm.meta[\"lastMsg\"] = resp.last_message_id\n        # has Anki been updated?\n        if ver := resp.new_version:\n            if mw.pm.meta.get(\"suppressUpdate\", None) != ver:\n                prompt_to_update(mw, ver)\n\n    def on_fail(exc: Exception) -> None:\n        print(f\"update check failed: {exc}\")\n\n    QueryOp(parent=mw, op=do_check, success=on_done).failure(\n        on_fail\n    ).without_collection().run_in_background()\n\n\ndef prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:\n    msg = (\n        tr.qt_misc_anki_updatedanki_has_been_released(val=ver)\n        + tr.qt_misc_would_you_like_to_download_it()\n    )\n\n    msgbox = QMessageBox(mw)\n    msgbox.setStandardButtons(\n        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No\n    )\n    msgbox.setIcon(QMessageBox.Icon.Information)\n    msgbox.setText(msg)\n\n    button = QPushButton(tr.qt_misc_ignore_this_update())\n    msgbox.addButton(button, QMessageBox.ButtonRole.RejectRole)\n    msgbox.setDefaultButton(QMessageBox.StandardButton.Yes)\n    ret = msgbox.exec()\n\n    if msgbox.clickedButton() == button:\n        # ignore this update\n        mw.pm.meta[\"suppressUpdate\"] = ver\n    elif ret == QMessageBox.StandardButton.Yes:\n        if _launcher_executable():\n            _update_and_restart()\n        else:\n            openLink(aqt.appWebsiteDownloadSection)\n"
  },
  {
    "path": "qt/aqt/url_schemes.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nfrom markdown import markdown\n\nfrom aqt.qt import QMessageBox, Qt, QUrl\nfrom aqt.utils import MessageBox, getText, openLink, tr\n\n\ndef show_url_schemes_dialog() -> None:\n    from aqt import mw\n\n    default = \" \".join(mw.pm.allowed_url_schemes())\n    schemes, ok = getText(\n        prompt=tr.preferences_url_scheme_prompt(),\n        title=tr.preferences_url_schemes(),\n        default=default,\n    )\n    if ok:\n        mw.pm.set_allowed_url_schemes(schemes.split(\" \"))\n        mw.pm.save()\n\n\ndef is_supported_scheme(url: QUrl) -> bool:\n    from aqt import mw\n\n    scheme = url.scheme().lower()\n    allowed_schemes = mw.pm.allowed_url_schemes()\n\n    return scheme in allowed_schemes or scheme in [\"http\", \"https\"]\n\n\ndef always_allow_scheme(url: QUrl) -> None:\n    from aqt import mw\n\n    scheme = url.scheme().lower()\n    mw.pm.always_allow_scheme(scheme)\n\n\ndef open_url_if_supported_scheme(url: QUrl) -> None:\n    from aqt import mw\n\n    if is_supported_scheme(url):\n        openLink(url)\n    else:\n\n        def on_button(idx: int) -> None:\n            if idx == 0:\n                openLink(url)\n            elif idx == 1:\n                always_allow_scheme(url)\n                openLink(url)\n\n        msg = markdown(\n            tr.preferences_url_scheme_warning(link=url.toString(), scheme=url.scheme())\n        )\n        MessageBox(\n            msg,\n            buttons=[\n                tr.preferences_url_scheme_allow_once(),\n                tr.preferences_url_scheme_always_allow(),\n                (tr.actions_cancel(), QMessageBox.ButtonRole.RejectRole),\n            ],\n            parent=mw,\n            callback=on_button,\n            textFormat=Qt.TextFormat.RichText,\n            default_button=2,\n            icon=QMessageBox.Icon.Warning,\n        )\n"
  },
  {
    "path": "qt/aqt/utils.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfrom __future__ import annotations\n\nimport enum\nimport inspect\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nfrom collections.abc import Callable, Sequence\nfrom functools import partial, wraps\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Literal, Union\n\nfrom send2trash import send2trash\n\nimport aqt\nfrom anki._legacy import DeprecatedNamesMixinForModule\nfrom anki.collection import Collection, HelpPage\nfrom anki.lang import TR, tr_legacyglobal  # noqa: F401\nfrom anki.utils import (\n    call,\n    invalid_filename,\n    is_mac,\n    is_win,\n    no_bundled_libs,\n    version_with_build,\n)\nfrom aqt.qt import *\nfrom aqt.qt import (\n    PYQT_VERSION_STR,\n    QT_VERSION_STR,  # noqa: F401\n    QAction,\n    QApplication,\n    QCheckBox,\n    QColor,\n    QComboBox,\n    QDesktopServices,\n    QDialog,\n    QDialogButtonBox,\n    QEvent,\n    QFileDialog,\n    QFrame,\n    QHeaderView,\n    QIcon,\n    QLabel,\n    QLineEdit,\n    QListWidget,\n    QMainWindow,\n    QMenu,\n    QMessageBox,\n    QMouseEvent,\n    QNativeGestureEvent,\n    QOffscreenSurface,\n    QOpenGLContext,\n    QPalette,\n    QPixmap,\n    QPlainTextEdit,\n    QPoint,\n    QPushButton,\n    QSize,\n    QSplitter,\n    QStandardPaths,\n    Qt,\n    QTextBrowser,\n    QTextOption,\n    QTimer,\n    QUrl,\n    QVBoxLayout,\n    QWheelEvent,\n    QWidget,\n    pyqtSlot,\n    qconnect,\n    qtmajor,\n    qtminor,\n    qVersion,\n    traceback,\n)\nfrom aqt.theme import theme_manager\n\nif TYPE_CHECKING:\n    TextFormat = Literal[\"plain\", \"rich\", \"markdown\"]\n\n\ndef aqt_data_path() -> Path:\n    import _aqt.colors\n\n    data_folder = Path(inspect.getfile(_aqt.colors)).with_name(\"data\")\n    if data_folder.exists():\n        return data_folder.absolute()\n    else:\n        # should only happen when running unit tests\n        print(\"warning, data folder not found\")\n        return Path(\".\")\n\n\ndef aqt_data_folder() -> str:\n    return str(aqt_data_path())\n\n\n# shortcut to access Fluent translations; set as\ntr = tr_legacyglobal\n\nHelpPageArgument = Union[\"HelpPage.V\", str]\n\n\ndef openHelp(section: HelpPageArgument) -> None:\n    assert tr.backend is not None\n    backend = tr.backend()\n    assert backend is not None\n    if isinstance(section, str):\n        link = backend.help_page_link(page=HelpPage.INDEX) + section\n    else:\n        link = backend.help_page_link(page=section)\n    openLink(link)\n\n\ndef openLink(link: str | QUrl) -> None:\n    tooltip(tr.qt_misc_loading(), period=1000)\n    with no_bundled_libs():\n        QDesktopServices.openUrl(QUrl(link))\n\n\nclass MessageBox(QMessageBox):\n    def __init__(\n        self,\n        text: str,\n        callback: Callable[[int], None] | None = None,\n        parent: QWidget | None = None,\n        icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon,\n        help: HelpPageArgument | None = None,\n        title: str = \"Anki\",\n        buttons: (\n            Sequence[\n                str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole]\n            ]\n            | None\n        ) = None,\n        default_button: int = 0,\n        textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,\n        modality: Qt.WindowModality = Qt.WindowModality.WindowModal,\n    ) -> None:\n        parent = parent or aqt.mw.app.activeWindow() or aqt.mw\n        super().__init__(parent)\n        self.setText(text)\n        self.setWindowTitle(title)\n        self.setWindowModality(modality)\n        self.setIcon(icon)\n        if icon == QMessageBox.Icon.Question and theme_manager.night_mode:\n            img = self.iconPixmap().toImage()\n            img.invertPixels()\n            self.setIconPixmap(QPixmap(img))\n        self.setTextFormat(textFormat)\n        if buttons is None:\n            buttons = [QMessageBox.StandardButton.Ok]\n        for i, button in enumerate(buttons):\n            if isinstance(button, str):\n                b = self.addButton(button, QMessageBox.ButtonRole.ActionRole)\n            elif isinstance(button, QMessageBox.StandardButton):\n                b = self.addButton(button)\n                # a translator has complained the default Qt translation is inappropriate, so we override it\n                if button == QMessageBox.StandardButton.Discard:\n                    assert b is not None\n                    b.setText(tr.actions_discard())\n            elif isinstance(button, tuple):\n                b = self.addButton(button[0], button[1])\n            else:\n                continue\n            if callback is not None:\n                assert b is not None\n                qconnect(b.clicked, partial(callback, i))\n            if i == default_button:\n                self.setDefaultButton(b)\n        if help is not None:\n            b = self.addButton(QMessageBox.StandardButton.Help)\n            assert b is not None\n            qconnect(b.clicked, lambda: openHelp(help))\n        self.open()\n\n\ndef ask_user(\n    text: str,\n    callback: Callable[[bool], None],\n    defaults_yes: bool = True,\n    **kwargs: Any,\n) -> MessageBox:\n    \"Shows a yes/no question, passes the answer to the callback function as a bool.\"\n    return MessageBox(\n        text,\n        callback=lambda response: callback(not response),\n        icon=QMessageBox.Icon.Question,\n        buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No],\n        default_button=not defaults_yes,\n        **kwargs,\n    )\n\n\ndef ask_user_dialog(\n    text: str,\n    callback: Callable[[int], None],\n    buttons: (\n        Sequence[str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole]]\n        | None\n    ) = None,\n    default_button: int = 1,\n    parent: QWidget | None = None,\n    title: str = \"Anki\",\n    **kwargs: Any,\n) -> MessageBox:\n    \"Shows a question to the user, passes the index of the button clicked to the callback.\"\n    if buttons is None:\n        buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No]\n    return MessageBox(\n        text,\n        callback=callback,\n        icon=QMessageBox.Icon.Question,\n        buttons=buttons,\n        default_button=default_button,\n        parent=parent,\n        title=title,\n        **kwargs,\n    )\n\n\ndef show_info(\n    text: str,\n    callback: Callable | None = None,\n    parent: QWidget | None = None,\n    **kwargs: Any,\n) -> MessageBox:\n    \"Show a small info window with an OK button.\"\n    if \"icon\" not in kwargs:\n        kwargs[\"icon\"] = QMessageBox.Icon.Information\n    return MessageBox(\n        text,\n        callback=(lambda _: callback()) if callback is not None else None,\n        parent=parent,\n        **kwargs,\n    )\n\n\ndef show_warning(\n    text: str,\n    callback: Callable | None = None,\n    parent: QWidget | None = None,\n    **kwargs: Any,\n) -> MessageBox:\n    \"Show a small warning window with an OK button.\"\n    return show_info(\n        text, icon=QMessageBox.Icon.Warning, callback=callback, parent=parent, **kwargs\n    )\n\n\ndef show_critical(\n    text: str,\n    callback: Callable | None = None,\n    parent: QWidget | None = None,\n    **kwargs: Any,\n) -> MessageBox:\n    \"Show a small critical error window with an OK button.\"\n    return show_info(\n        text, icon=QMessageBox.Icon.Critical, callback=callback, parent=parent, **kwargs\n    )\n\n\ndef showWarning(\n    text: str,\n    parent: QWidget | None = None,\n    help: HelpPageArgument | None = None,\n    title: str = \"Anki\",\n    textFormat: TextFormat | None = None,\n) -> int:\n    \"Show a small warning with an OK button.\"\n    return showInfo(text, parent, help, \"warning\", title=title, textFormat=textFormat)\n\n\ndef showCritical(\n    text: str,\n    parent: QDialog | None = None,\n    help: str = \"\",\n    title: str = \"Anki\",\n    textFormat: TextFormat | None = None,\n) -> int:\n    \"Show a small critical error with an OK button.\"\n    return showInfo(text, parent, help, \"critical\", title=title, textFormat=textFormat)\n\n\ndef showInfo(\n    text: str,\n    parent: QWidget | None = None,\n    help: HelpPageArgument | None = None,\n    type: str = \"info\",\n    title: str = \"Anki\",\n    textFormat: TextFormat | None = None,\n    customBtns: list[QMessageBox.StandardButton] | None = None,\n) -> int:\n    \"Show a small info window with an OK button.\"\n    parent_widget: QWidget\n    if parent is None:\n        parent_widget = aqt.mw.app.activeWindow() or aqt.mw\n    else:\n        parent_widget = parent\n    if type == \"warning\":\n        icon = QMessageBox.Icon.Warning\n    elif type == \"critical\":\n        icon = QMessageBox.Icon.Critical\n    else:\n        icon = QMessageBox.Icon.Information\n    mb = QMessageBox(parent_widget)\n    if textFormat == \"plain\":\n        mb.setTextFormat(Qt.TextFormat.PlainText)\n    elif textFormat == \"rich\":\n        mb.setTextFormat(Qt.TextFormat.RichText)\n    elif textFormat == \"markdown\":\n        mb.setTextFormat(Qt.TextFormat.MarkdownText)\n    elif textFormat is not None:\n        raise Exception(\"unexpected textFormat type\")\n    mb.setText(text)\n    mb.setIcon(icon)\n    mb.setWindowTitle(title)\n    if customBtns:\n        default = None\n        for btn in customBtns:\n            b = mb.addButton(btn)\n            if not default:\n                default = b\n        mb.setDefaultButton(default)\n    else:\n        b = mb.addButton(QMessageBox.StandardButton.Ok)\n        assert b is not None\n        b.setDefault(True)\n    if help is not None:\n        b = mb.addButton(QMessageBox.StandardButton.Help)\n        assert b is not None\n        qconnect(b.clicked, lambda: openHelp(help))\n        b.setAutoDefault(False)\n    return mb.exec()\n\n\ndef showText(\n    txt: str,\n    parent: QWidget | None = None,\n    type: str = \"text\",\n    run: bool = True,\n    geomKey: str | None = None,\n    minWidth: int = 500,\n    minHeight: int = 400,\n    title: str = \"Anki\",\n    copyBtn: bool = False,\n    plain_text_edit: bool = False,\n) -> tuple[QDialog, QDialogButtonBox] | None:\n    if not parent:\n        parent = aqt.mw.app.activeWindow() or aqt.mw\n    diag = QDialog(parent)\n    diag.setWindowTitle(title)\n    disable_help_button(diag)\n    layout = QVBoxLayout(diag)\n    diag.setLayout(layout)\n    text: QPlainTextEdit | QTextBrowser\n    if plain_text_edit:\n        # used by the importer\n        text = QPlainTextEdit()\n        text.setReadOnly(True)\n        text.setWordWrapMode(QTextOption.WrapMode.NoWrap)\n        text.setPlainText(txt)\n    else:\n        text = QTextBrowser()\n        text.setOpenExternalLinks(True)\n        if type == \"text\":\n            text.setPlainText(txt)\n        else:\n            text.setHtml(txt)\n    layout.addWidget(text)\n    box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)\n    layout.addWidget(box)\n    if copyBtn:\n\n        def onCopy() -> None:\n            clipboard = QApplication.clipboard()\n            assert clipboard is not None\n            clipboard.setText(text.toPlainText())\n\n        btn = QPushButton(tr.qt_misc_copy_to_clipboard())\n        qconnect(btn.clicked, onCopy)\n        box.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)\n\n    def onReject() -> None:\n        if geomKey:\n            saveGeom(diag, geomKey)\n        QDialog.reject(diag)\n\n    qconnect(box.rejected, onReject)\n\n    def onFinish() -> None:\n        if geomKey:\n            saveGeom(diag, geomKey)\n\n    qconnect(box.accepted, onFinish)\n    diag.setMinimumHeight(minHeight)\n    diag.setMinimumWidth(minWidth)\n    if geomKey:\n        restoreGeom(diag, geomKey)\n    if run:\n        diag.exec()\n        return None\n    else:\n        return diag, box\n\n\ndef askUser(\n    text: str,\n    parent: QWidget | None = None,\n    help: HelpPageArgument | None = None,\n    defaultno: bool = False,\n    msgfunc: Callable | None = None,\n    title: str = \"Anki\",\n) -> bool:\n    \"Show a yes/no question. Return true if yes.\"\n    if not parent:\n        parent = aqt.mw.app.activeWindow()\n    if not msgfunc:\n        msgfunc = QMessageBox.question\n    sb = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No\n    if help:\n        sb |= QMessageBox.StandardButton.Help\n    while 1:\n        if defaultno:\n            default = QMessageBox.StandardButton.No\n        else:\n            default = QMessageBox.StandardButton.Yes\n        r = msgfunc(parent, title, text, sb, default)\n        if r == QMessageBox.StandardButton.Help:\n            assert help is not None\n            openHelp(help)\n        else:\n            break\n    return r == QMessageBox.StandardButton.Yes\n\n\nclass ButtonedDialog(QMessageBox):\n    def __init__(\n        self,\n        text: str,\n        buttons: list[str],\n        parent: QWidget | None = None,\n        help: HelpPageArgument | None = None,\n        title: str = \"Anki\",\n    ):\n        QMessageBox.__init__(self, parent)\n        self._buttons: list[QPushButton | None] = []\n        self.setWindowTitle(title)\n        self.help = help\n        self.setIcon(QMessageBox.Icon.Warning)\n        self.setText(text)\n        for b in buttons:\n            self._buttons.append(self.addButton(b, QMessageBox.ButtonRole.AcceptRole))\n        if help:\n            self.addButton(tr.actions_help(), QMessageBox.ButtonRole.HelpRole)\n            buttons.append(tr.actions_help())\n\n    def run(self) -> str:\n        self.exec()\n        clicked_button = self.clickedButton()\n        assert clicked_button is not None\n        txt = clicked_button.text()\n        if txt == \"Help\":\n            # FIXME stop dialog closing?\n            assert self.help is not None\n            openHelp(self.help)\n        # work around KDE 'helpfully' adding accelerators to button text of Qt apps\n        return txt.replace(\"&\", \"\")\n\n    def setDefault(self, idx: int) -> None:\n        self.setDefaultButton(self._buttons[idx])\n\n\ndef askUserDialog(\n    text: str,\n    buttons: list[str],\n    parent: QWidget | None = None,\n    help: HelpPageArgument | None = None,\n    title: str = \"Anki\",\n) -> ButtonedDialog:\n    if not parent:\n        parent = aqt.mw\n    diag = ButtonedDialog(text, buttons, parent, help, title=title)\n    return diag\n\n\nclass GetTextDialog(QDialog):\n    def __init__(\n        self,\n        parent: QWidget | None,\n        question: str,\n        help: HelpPageArgument | None = None,\n        edit: QLineEdit | None = None,\n        default: str = \"\",\n        title: str = \"Anki\",\n        minWidth: int = 400,\n    ) -> None:\n        QDialog.__init__(self, parent)\n        self.setWindowTitle(title)\n        disable_help_button(self)\n        self.question = question\n        self.help = help\n        self.qlabel = QLabel(question)\n        self.setMinimumWidth(minWidth)\n        v = QVBoxLayout()\n        v.addWidget(self.qlabel)\n        if not edit:\n            edit = QLineEdit()\n        self.l = edit\n        if default:\n            self.l.setText(default)\n            self.l.selectAll()\n        v.addWidget(self.l)\n        buts = (\n            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel\n        )\n        if help:\n            buts |= QDialogButtonBox.StandardButton.Help\n        b = QDialogButtonBox(buts)  # type: ignore\n        v.addWidget(b)\n        self.setLayout(v)\n        ok_button = b.button(QDialogButtonBox.StandardButton.Ok)\n        assert ok_button is not None\n        qconnect(ok_button.clicked, self.accept)\n\n        cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel)\n        assert cancel_button is not None\n        qconnect(cancel_button.clicked, self.reject)\n\n        if help:\n            help_button = b.button(QDialogButtonBox.StandardButton.Help)\n            assert help_button is not None\n            qconnect(help_button.clicked, self.helpRequested)\n        self.l.setFocus()\n\n    def accept(self) -> None:\n        return QDialog.accept(self)\n\n    def reject(self) -> None:\n        return QDialog.reject(self)\n\n    def helpRequested(self) -> None:\n        if self.help is not None:\n            openHelp(self.help)\n\n\ndef getText(\n    prompt: str,\n    parent: QWidget | None = None,\n    help: HelpPageArgument | None = None,\n    edit: QLineEdit | None = None,\n    default: str = \"\",\n    title: str = \"Anki\",\n    geomKey: str | None = None,\n    **kwargs: Any,\n) -> tuple[str, int]:\n    \"Returns (string, succeeded).\"\n    if not parent:\n        parent = aqt.mw.app.activeWindow() or aqt.mw\n    d = GetTextDialog(\n        parent, prompt, help=help, edit=edit, default=default, title=title, **kwargs\n    )\n    d.setWindowModality(Qt.WindowModality.WindowModal)\n    if geomKey:\n        restoreGeom(d, geomKey)\n    ret = d.exec()\n    if geomKey and ret:\n        saveGeom(d, geomKey)\n    return (str(d.l.text()), ret)\n\n\ndef getOnlyText(*args: Any, **kwargs: Any) -> str:\n    (s, r) = getText(*args, **kwargs)\n    if r:\n        return s\n    else:\n        return \"\"\n\n\n# fixme: these utilities could be combined into a single base class\n# unused by Anki, but used by add-ons\ndef chooseList(\n    prompt: str, choices: list[str], startrow: int = 0, parent: Any | None = None\n) -> int:\n    if not parent:\n        parent = aqt.mw.app.activeWindow()\n    d = QDialog(parent)\n    disable_help_button(d)\n    d.setWindowModality(Qt.WindowModality.WindowModal)\n    l = QVBoxLayout()\n    d.setLayout(l)\n    t = QLabel(prompt)\n    l.addWidget(t)\n    c = QListWidget()\n    c.addItems(choices)\n    c.setCurrentRow(startrow)\n    l.addWidget(c)\n    bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)\n    qconnect(bb.accepted, d.accept)\n    l.addWidget(bb)\n    d.exec()\n    return c.currentRow()\n\n\ndef getTag(\n    parent: QWidget, deck: Collection, question: str, **kwargs: Any\n) -> tuple[str, int]:\n    from aqt.tagedit import TagEdit\n\n    te = TagEdit(parent)\n    te.setCol(deck)\n    ret = getText(question, parent, edit=te, geomKey=\"getTag\", **kwargs)\n    te.hideCompleter()\n    return ret\n\n\ndef disable_help_button(widget: QWidget) -> None:\n    \"Disable the help button in the window titlebar.\"\n    widget.setWindowFlags(\n        widget.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint\n    )\n\n\ndef setWindowIcon(widget: QWidget) -> None:\n    icon = QIcon()\n    icon.addPixmap(QPixmap(\"icons:anki.png\"), QIcon.Mode.Normal, QIcon.State.Off)\n    widget.setWindowIcon(icon)\n\n\n# File handling\n######################################################################\n\n\ndef getFile(\n    parent: QWidget,\n    title: str,\n    # single file returned unless multi=True\n    cb: Callable[[str | Sequence[str]], None] | None,\n    filter: str = \"*\",\n    dir: str | None = None,\n    key: str | None = None,\n    multi: bool = False,  # controls whether a single or multiple files is returned\n) -> Sequence[str] | str | None:\n    \"Ask the user for a file.\"\n    if dir and key:\n        raise Exception(\"expected dir or key\")\n    if not dir:\n        assert aqt.mw.pm.profile is not None\n        dirkey = f\"{key}Directory\"\n        dir = aqt.mw.pm.profile.get(dirkey, \"\")\n    else:\n        dirkey = None\n    d = QFileDialog(parent)\n    mode = (\n        QFileDialog.FileMode.ExistingFiles\n        if multi\n        else QFileDialog.FileMode.ExistingFile\n    )\n    d.setFileMode(mode)\n    assert dir is not None\n    if os.path.exists(dir):\n        d.setDirectory(dir)\n    d.setWindowTitle(title)\n    d.setNameFilter(filter)\n    ret = []\n\n    def accept() -> None:\n        files = list(d.selectedFiles())\n        if dirkey:\n            assert aqt.mw.pm.profile is not None\n            dir = os.path.dirname(files[0])\n            aqt.mw.pm.profile[dirkey] = dir\n        result = files if multi else files[0]\n        if cb:\n            cb(result)\n        ret.append(result)\n\n    qconnect(d.accepted, accept)\n    if key:\n        restoreState(d, key)\n    d.exec()\n    if key:\n        saveState(d, key)\n    return ret[0] if ret else None\n\n\ndef running_in_sandbox():\n    \"\"\"Check whether running in Flatpak or Snap. When in such a sandbox, Qt\n    will not report the true location of user-chosen files, but instead a\n    temporary location from which the sandboxing software will copy the file to\n    the user-chosen destination. Thus file renames are impossible and caching\n    the reported file location is unhelpful.\"\"\"\n    in_flatpak = (\n        QStandardPaths.locate(\n            QStandardPaths.StandardLocation.RuntimeLocation,\n            \"flatpak-info\",\n        )\n        != \"\"\n    )\n    in_snap = bool(os.environ.get(\"SNAP\"))\n    return in_flatpak or in_snap\n\n\ndef getSaveFile(\n    parent: QDialog,\n    title: str,\n    dir_description: str,\n    key: str,\n    ext: str,\n    fname: str = \"\",\n) -> str | None:\n    \"\"\"Ask the user for a file to save. Use DIR_DESCRIPTION as config\n    variable. The file dialog will default to open with FNAME.\"\"\"\n    assert aqt.mw.pm.profile is not None\n    config_key = f\"{dir_description}Directory\"\n\n    defaultPath = QStandardPaths.writableLocation(\n        QStandardPaths.StandardLocation.DocumentsLocation\n    )\n    base = aqt.mw.pm.profile.get(config_key, defaultPath)\n    path = os.path.join(base, fname)\n    file = QFileDialog.getSaveFileName(\n        parent,\n        title,\n        path,\n        f\"{key} (*{ext})\",\n        options=QFileDialog.Option.DontConfirmOverwrite,\n    )[0]\n    if file and not running_in_sandbox():\n        # add extension\n        if not file.lower().endswith(ext):\n            file += ext\n        # save new default\n        dir = os.path.dirname(file)\n        aqt.mw.pm.profile[config_key] = dir\n        # check if it exists\n        if os.path.exists(file) and not askUser(\n            tr.qt_misc_this_file_exists_are_you_sure(), parent\n        ):\n            return None\n    return file\n\n\nclass _QtStateKeyKind(enum.Enum):\n    HEADER = enum.auto()\n    SPLITTER = enum.auto()\n    STATE = enum.auto()\n    GEOMETRY = enum.auto()\n\n\ndef _qt_state_key(kind: _QtStateKeyKind, key: str) -> str:\n    \"\"\"Construct a key used to save/restore geometry, state, etc.\n\n    Adds Qt version number to key so that different data is saved per Qt version,\n    preventing crashes and bugs when restoring data saved with a different Qt version.\n    \"\"\"\n    qt_suffix = f\"{qtmajor}.{qtminor}\" if qtmajor > 5 else \"\"\n    return f\"{key}{kind.name.capitalize()}{qt_suffix}\"\n\n\ndef saveGeom(widget: QWidget, key: str) -> None:\n    # restoring a fullscreen window breaks the tab functionality of 5.15\n    if not widget.isFullScreen() or qtmajor == 6:\n        assert aqt.mw.pm.profile is not None\n        key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key)\n        aqt.mw.pm.profile[key] = widget.saveGeometry()\n\n\ndef restoreGeom(\n    widget: QWidget,\n    key: str,\n    adjustSize: bool = False,\n    default_size: tuple[int, int] | None = None,\n) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key)\n    if existing_geom := aqt.mw.pm.profile.get(key):\n        widget.restoreGeometry(existing_geom)\n        ensureWidgetInScreenBoundaries(widget)\n    elif adjustSize:\n        widget.adjustSize()\n    elif default_size:\n        widget.resize(*default_size)\n\n\ndef ensureWidgetInScreenBoundaries(widget: QWidget) -> None:\n    window = widget.window()\n    assert window is not None\n    handle = window.windowHandle()\n    if not handle:\n        # window has not yet been shown, retry later\n        aqt.mw.progress.timer(\n            50, lambda: ensureWidgetInScreenBoundaries(widget), False, parent=widget\n        )\n        return\n\n    # ensure widget is smaller than screen bounds\n    screen = handle.screen()\n    assert screen is not None\n    geom = screen.availableGeometry()\n    wsize = widget.size()\n    cappedWidth = min(geom.width(), wsize.width())\n    cappedHeight = min(geom.height(), wsize.height())\n    if cappedWidth < wsize.width() or cappedHeight < wsize.height():\n        widget.resize(QSize(cappedWidth, cappedHeight))\n\n    # ensure widget is inside top left\n    wpos = widget.pos()\n    x = max(geom.x(), wpos.x())\n    y = max(geom.y(), wpos.y())\n    # and bottom right\n    x = min(x, geom.width() + geom.x() - cappedWidth)\n    y = min(y, geom.height() + geom.y() - cappedHeight)\n    if x != wpos.x() or y != wpos.y():\n        widget.move(x, y)\n\n\ndef saveState(widget: QFileDialog | QMainWindow, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.STATE, key)\n    aqt.mw.pm.profile[key] = widget.saveState()\n\n\ndef restoreState(widget: QFileDialog | QMainWindow, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.STATE, key)\n    if data := aqt.mw.pm.profile.get(key):\n        widget.restoreState(data)\n\n\ndef saveSplitter(widget: QSplitter, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.SPLITTER, key)\n    aqt.mw.pm.profile[key] = widget.saveState()\n\n\ndef restoreSplitter(widget: QSplitter, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.SPLITTER, key)\n    if data := aqt.mw.pm.profile.get(key):\n        widget.restoreState(data)\n\n\ndef saveHeader(widget: QHeaderView, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.HEADER, key)\n    aqt.mw.pm.profile[key] = widget.saveState()\n\n\ndef restoreHeader(widget: QHeaderView, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key = _qt_state_key(_QtStateKeyKind.HEADER, key)\n    if state := aqt.mw.pm.profile.get(key):\n        widget.restoreState(state)\n\n\ndef save_is_checked(widget: QCheckBox, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key += \"IsChecked\"\n    aqt.mw.pm.profile[key] = widget.isChecked()\n\n\ndef restore_is_checked(widget: QCheckBox, key: str) -> None:\n    assert aqt.mw.pm.profile is not None\n    key += \"IsChecked\"\n    if aqt.mw.pm.profile.get(key) is not None:\n        widget.setChecked(aqt.mw.pm.profile[key])\n\n\ndef save_combo_index_for_session(widget: QComboBox, key: str) -> None:\n    textKey = f\"{key}ComboActiveText\"\n    indexKey = f\"{key}ComboActiveIndex\"\n    aqt.mw.pm.session[textKey] = widget.currentText()\n    aqt.mw.pm.session[indexKey] = widget.currentIndex()\n\n\ndef restore_combo_index_for_session(\n    widget: QComboBox, history: list[str], key: str\n) -> None:\n    textKey = f\"{key}ComboActiveText\"\n    indexKey = f\"{key}ComboActiveIndex\"\n    text = aqt.mw.pm.session.get(textKey)\n    index = aqt.mw.pm.session.get(indexKey)\n    if text is not None and index is not None:\n        if index < len(history) and history[index] == text:\n            widget.setCurrentIndex(index)\n\n\ndef save_combo_history(comboBox: QComboBox, history: list[str], name: str) -> str:\n    assert aqt.mw.pm.profile is not None\n    name += \"BoxHistory\"\n    line_edit = comboBox.lineEdit()\n    assert line_edit is not None\n    text_input = line_edit.text()\n    if text_input in history:\n        history.remove(text_input)\n    history.insert(0, text_input)\n    history = history[:50]\n    comboBox.clear()\n    comboBox.addItems(history)\n    aqt.mw.pm.session[name] = text_input\n    aqt.mw.pm.profile[name] = history\n    return text_input\n\n\ndef restore_combo_history(comboBox: QComboBox, name: str) -> list[str]:\n    assert aqt.mw.pm.profile is not None\n    name += \"BoxHistory\"\n    history = aqt.mw.pm.profile.get(name, [])\n    comboBox.addItems([\"\"] + history)\n    if history:\n        session_input = aqt.mw.pm.session.get(name)\n        if session_input and session_input == history[0]:\n            line_edit = comboBox.lineEdit()\n            assert line_edit is not None\n            line_edit.setText(session_input)\n            line_edit.selectAll()\n    return history\n\n\ndef mungeQA(col: Collection, txt: str) -> str:\n    print(\"mungeQA() deprecated; use mw.prepare_card_text_for_display()\")\n    txt = col.media.escape_media_filenames(txt)\n    return txt\n\n\ndef openFolder(path: str) -> None:\n    if is_win:\n        subprocess.run([\"explorer\", f\"file://{path}\"], check=False)\n    else:\n        with no_bundled_libs():\n            QDesktopServices.openUrl(QUrl.fromLocalFile(path))\n\n\ndef show_in_folder(path: str) -> None:\n    if is_win:\n        _show_in_folder_win32(path)\n    elif is_mac:\n        script = f\"\"\"\n        tell application \"Finder\"\n            activate\n            select POSIX file \"{path}\"\n        end tell\n        \"\"\"\n        call(osascript_to_args(script))\n    else:\n        # For linux, there are multiple file managers. Let's test if one of the\n        # most common file managers is found and use it in case it is installed.\n        # If none of this list are installed, use a fallback. The fallback\n        # might open the image in a web browser, image viewer or others,\n        # depending on the users defaults.\n        file_managers = [\n            \"nautilus\",  # GNOME\n            \"dolphin\",  # KDE\n            \"pcmanfm\",  # LXDE\n            \"thunar\",  # XFCE\n            \"nemo\",  # Cinnamon\n            \"caja\",  # MATE\n        ]\n\n        available_file_manager = None\n\n        # Test if a file manager is installed and use it, fallback otherwise\n        for file_manager in file_managers:\n            if shutil.which(file_manager):\n                available_file_manager = file_manager\n                break\n\n        if available_file_manager:\n            subprocess.run([available_file_manager, path], check=False)\n        else:\n            # Just open the file in any other platform\n            with no_bundled_libs():\n                QDesktopServices.openUrl(QUrl.fromLocalFile(path))\n\n\ndef _show_in_folder_win32(path: str) -> None:\n    import win32con\n    import win32gui\n\n    from aqt import mw\n\n    def focus_explorer():\n        hwnd = win32gui.FindWindow(\"CabinetWClass\", None)\n        if hwnd:\n            win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)\n            win32gui.SetForegroundWindow(hwnd)\n\n    subprocess.run([\"explorer\", \"/select,\", path], check=False)\n    mw.progress.single_shot(500, focus_explorer)\n\n\ndef osascript_to_args(script: str):\n    args = [\n        item\n        for line in script.splitlines()\n        for item in (\"-e\", line.strip())\n        if line.strip()\n    ]\n    return [\"osascript\"] + args\n\n\ndef shortcut(key: str) -> str:\n    if is_mac:\n        return re.sub(\"(?i)ctrl\", \"Command\", key)\n    return key\n\n\ndef maybeHideClose(bbox: QDialogButtonBox) -> None:\n    if is_mac:\n        b = bbox.button(QDialogButtonBox.StandardButton.Close)\n        if b:\n            bbox.removeButton(b)\n\n\ndef downArrow() -> str:\n    if is_win:\n        return \"▼\"\n    # windows 10 is lacking the smaller arrow on English installs\n    return \"▾\"\n\n\ndef current_window() -> QWidget | None:\n    if widget := QApplication.focusWidget():\n        return widget.window()\n    else:\n        return None\n\n\ndef send_to_trash(path: Path) -> None:\n    \"Place file/folder in recycling bin, or delete permanently on failure.\"\n    if not path.exists():\n        return\n    try:\n        send2trash(path)\n    except Exception as exc:\n        # Linux users may not have a trash folder set up\n        print(\"trash failure:\", path, exc)\n        if path.is_dir():\n            shutil.rmtree(path)\n        else:\n            path.unlink()\n\n\n# Tooltips\n######################################################################\n\n_tooltipTimer: QTimer | None = None\n_tooltipLabel: QLabel | None = None\n\n\ndef tooltip(\n    msg: str,\n    period: int = 3000,\n    parent: QWidget | None = None,\n    x_offset: int = 0,\n    y_offset: int = 100,\n) -> None:\n    global _tooltipTimer, _tooltipLabel\n\n    class CustomLabel(QLabel):\n        silentlyClose = True\n\n        def mousePressEvent(self, evt: QMouseEvent | None) -> None:\n            assert evt is not None\n            evt.accept()\n            self.hide()\n\n    closeTooltip()\n    aw = parent or aqt.mw.app.activeWindow() or aqt.mw\n    lab = CustomLabel(\n        f\"\"\"<table cellpadding=10>\n<tr>\n<td>{msg}</td>\n</tr>\n</table>\"\"\",\n        aw,\n    )\n    lab.setFrameStyle(QFrame.Shape.Panel)\n    lab.setLineWidth(2)\n    lab.setWindowFlags(Qt.WindowType.ToolTip)\n    if not theme_manager.night_mode:\n        p = QPalette()\n        p.setColor(QPalette.ColorRole.Window, QColor(\"#feffc4\"))\n        p.setColor(QPalette.ColorRole.WindowText, QColor(\"#000000\"))\n        lab.setPalette(p)\n    lab.move(aw.mapToGlobal(QPoint(0 + x_offset, aw.height() - y_offset)))\n    lab.show()\n    _tooltipTimer = aqt.mw.progress.timer(\n        period, closeTooltip, False, requiresCollection=False, parent=aw\n    )\n    _tooltipLabel = lab\n\n\ndef closeTooltip() -> None:\n    global _tooltipLabel, _tooltipTimer\n    if _tooltipLabel:\n        try:\n            _tooltipLabel.deleteLater()\n        except RuntimeError:\n            # already deleted as parent window closed\n            pass\n        _tooltipLabel = None\n    if _tooltipTimer:\n        try:\n            _tooltipTimer.deleteLater()\n        except RuntimeError:\n            pass\n        _tooltipTimer = None\n\n\n# true if invalid; print warning\ndef checkInvalidFilename(str: str, dirsep: bool = True) -> bool:\n    bad = invalid_filename(str, dirsep)\n    if bad:\n        showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad))\n        return True\n    return False\n\n\n# Menus\n######################################################################\n# This code will be removed in the future, please don't rely on it.\n\nMenuListChild = Union[\"SubMenu\", QAction, \"MenuItem\", \"MenuList\"]\n\n\nclass MenuList:\n    def __init__(self) -> None:\n        traceback.print_stack(file=sys.stdout)\n        print(\n            \"MenuList will be removed; please copy it into your add-on's code if you need it.\"\n        )\n        self.children: list[MenuListChild | None] = []\n\n    def addItem(self, title: str, func: Callable) -> MenuItem:\n        item = MenuItem(title, func)\n        self.children.append(item)\n        return item\n\n    def addSeparator(self) -> None:\n        self.children.append(None)\n\n    def addMenu(self, title: str) -> SubMenu:\n        submenu = SubMenu(title)\n        self.children.append(submenu)\n        return submenu\n\n    def addChild(self, child: SubMenu | QAction | MenuList) -> None:\n        self.children.append(child)\n\n    def renderTo(self, qmenu: QMenu) -> None:\n        for child in self.children:\n            if child is None:\n                qmenu.addSeparator()\n            elif isinstance(child, QAction):\n                qmenu.addAction(child)\n            else:\n                child.renderTo(qmenu)\n\n    def popupOver(self, widget: QPushButton) -> None:\n        qmenu = QMenu()\n        self.renderTo(qmenu)\n        qmenu.exec(widget.mapToGlobal(QPoint(0, 0)))\n\n\nclass SubMenu(MenuList):\n    def __init__(self, title: str) -> None:\n        super().__init__()\n        self.title = title\n\n    def renderTo(self, menu: QMenu) -> None:\n        submenu = menu.addMenu(self.title)\n        assert submenu is not None\n        super().renderTo(submenu)\n\n\nclass MenuItem:\n    def __init__(self, title: str, func: Callable) -> None:\n        self.title = title\n        self.func = func\n\n    def renderTo(self, qmenu: QMenu) -> None:\n        a = qmenu.addAction(self.title)\n        assert a is not None\n        qconnect(a.triggered, self.func)\n\n\ndef qtMenuShortcutWorkaround(qmenu: QMenu) -> None:\n    for act in qmenu.actions():\n        act.setShortcutVisibleInContextMenu(True)\n\n\n######################################################################\n\n\ndef disallow_full_screen() -> bool:\n    \"\"\"Test for OpenGl on Windows, which is known to cause issues with full screen mode.\"\"\"\n    from aqt import mw\n    from aqt.profiles import VideoDriver\n\n    return is_win and (\n        mw.pm.video_driver() == VideoDriver.OpenGL\n        and not os.environ.get(\"ANKI_SOFTWAREOPENGL\")\n    )\n\n\ndef add_ellipsis_to_action_label(*actions: QAction | QPushButton) -> None:\n    \"\"\"Pass actions to add '...' to their labels, indicating that more input is\n    required before they can be performed.\n\n    This approach is used so that the same fluent translations can be used on\n    mobile, where the '...' convention does not exist.\n    \"\"\"\n    for action in actions:\n        action.setText(tr.actions_with_ellipsis(action=action.text()))\n\n\ndef supportText() -> str:\n    import platform\n\n    from aqt import mw\n\n    platname = platform.platform()\n\n    return \"\"\"\\\nAnki {} {}\nPython {} Qt {} PyQt {}\nPlatform: {}\n\"\"\".format(\n        version_with_build(),\n        \"(ao)\" if mw.addonManager.dirty else \"\",\n        platform.python_version(),\n        qVersion(),\n        PYQT_VERSION_STR,\n        platname,\n    )\n\n\n######################################################################\n\n\n# adapted from version detection in qutebrowser\ndef opengl_vendor() -> str | None:\n    if qtmajor != 5:\n        return \"unknown\"\n    old_context = QOpenGLContext.currentContext()\n    old_surface = None if old_context is None else old_context.surface()\n\n    surface = QOffscreenSurface()\n    surface.create()\n\n    ctx = QOpenGLContext()\n    ok = ctx.create()\n    if not ok:\n        return None\n\n    ok = ctx.makeCurrent(surface)\n    if not ok:\n        return None\n\n    try:\n        if ctx.isOpenGLES():\n            # Can't use versionFunctions there\n            return None\n\n        vp = QOpenGLVersionProfile()  # type: ignore\n        vp.setVersion(2, 0)\n\n        try:\n            vf = ctx.versionFunctions(vp)  # type: ignore\n        except ImportError:\n            return None\n\n        if vf is None:\n            return None\n\n        return vf.glGetString(vf.GL_VENDOR)\n    finally:\n        ctx.doneCurrent()\n        if old_context and old_surface:\n            old_context.makeCurrent(old_surface)\n\n\ndef gfxDriverIsBroken() -> bool:\n    driver = opengl_vendor()\n    return driver == \"nouveau\"\n\n\n######################################################################\n\n\ndef startup_info() -> Any:\n    \"Use subprocess.Popen(startupinfo=...) to avoid opening a console window.\"\n    if sys.platform != \"win32\":\n        return None\n    si = subprocess.STARTUPINFO()  # pytype: disable=module-attr\n    si.dwFlags |= subprocess.STARTF_USESHOWWINDOW  # pytype: disable=module-attr\n    return si\n\n\ndef ensure_editor_saved(func: Callable) -> Callable:\n    \"\"\"Ensure the current editor's note is saved before running the wrapped function.\n\n    Must be used on functions that may be invoked from a shortcut key while the\n    editor has focus. For functions that can't be activated while the editor has\n    focus, you don't need this.\n\n    Will look for the editor as self.editor.\n    \"\"\"\n\n    @wraps(func)\n    def decorated(self: Any, *args: Any, **kwargs: Any) -> None:\n        self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs))\n\n    return decorated\n\n\ndef skip_if_selection_is_empty(func: Callable) -> Callable:\n    \"\"\"Make the wrapped method a no-op and show a hint if the table selection is empty.\"\"\"\n\n    @wraps(func)\n    def decorated(self: Any, *args: Any, **kwargs: Any) -> None:\n        if self.table.len_selection() > 0:\n            func(self, *args, **kwargs)\n        else:\n            tooltip(tr.browsing_no_selection())\n\n    return decorated\n\n\ndef no_arg_trigger(func: Callable) -> Callable:\n    \"\"\"Tells Qt this function takes no args.\n\n    This ensures PyQt doesn't attempt to pass a `toggled` arg\n    into functions connected to a `triggered` signal.\n    \"\"\"\n\n    return pyqtSlot()(func)  # type: ignore\n\n\ndef is_gesture_or_zoom_event(evt: QEvent) -> bool:\n    \"\"\"If the event is a gesture and/or will trigger zoom.\n\n    Includes zoom by pinching, and Ctrl-scrolling on Win and Linux.\n    \"\"\"\n\n    return isinstance(evt, QNativeGestureEvent) or (\n        isinstance(evt, QWheelEvent)\n        and not is_mac\n        and KeyboardModifiersPressed().control\n    )\n\n\nclass KeyboardModifiersPressed:\n    \"Util for type-safe checks of currently-pressed modifier keys.\"\n\n    def __init__(self) -> None:\n        from aqt import mw\n\n        self._modifiers = mw.app.keyboardModifiers()\n\n    @property\n    def shift(self) -> bool:\n        return bool(self._modifiers & Qt.KeyboardModifier.ShiftModifier)\n\n    @property\n    def control(self) -> bool:\n        return bool(self._modifiers & Qt.KeyboardModifier.ControlModifier)\n\n    @property\n    def alt(self) -> bool:\n        return bool(self._modifiers & Qt.KeyboardModifier.AltModifier)\n\n    @property\n    def meta(self) -> bool:\n        return bool(self._modifiers & Qt.KeyboardModifier.MetaModifier)\n\n\n# add-ons attempting to import isMac from this module :-(\n_deprecated_names = DeprecatedNamesMixinForModule(globals())\n\n\nif not TYPE_CHECKING:\n\n    def __getattr__(name: str) -> Any:\n        return _deprecated_names.__getattr__(name)\n"
  },
  {
    "path": "qt/aqt/webview.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport dataclasses\nimport json\nimport os\nimport re\nimport sys\nfrom collections.abc import Callable, Sequence\nfrom enum import Enum\nfrom typing import TYPE_CHECKING, Any, Type, cast\n\nfrom typing_extensions import TypedDict, Unpack\n\nimport anki\nimport anki.lang\nfrom anki._legacy import deprecated\nfrom anki.lang import is_rtl\nfrom anki.utils import hmr_mode, is_lin, is_mac, is_win\nfrom aqt import colors, gui_hooks\nfrom aqt.qt import *\nfrom aqt.qt import sip\nfrom aqt.theme import theme_manager\nfrom aqt.utils import askUser, is_gesture_or_zoom_event, openLink, showInfo, tr\n\nserverbaseurl = re.compile(r\"^.+:\\/\\/[^\\/]+\")\n\nif TYPE_CHECKING:\n    from aqt.mediasrv import PageContext\n\nBridgeCommandHandler = Callable[[str], Any]\n\n\nclass AnkiWebViewKind(Enum):\n    \"\"\"Enum registry of all web views managed by Anki\n\n    The value of each entry corresponds to the web view's title.\n\n    When introducing a new web view, please add it to the registry below.\n    \"\"\"\n\n    DEFAULT = \"default\"\n    MAIN = \"main webview\"\n    TOP_TOOLBAR = \"top toolbar\"\n    BOTTOM_TOOLBAR = \"bottom toolbar\"\n    DECK_OPTIONS = \"deck options\"\n    EDITOR = \"editor\"\n    LEGACY_DECK_STATS = \"legacy deck stats\"\n    DECK_STATS = \"deck stats\"\n    PREVIEWER = \"previewer\"\n    CHANGE_NOTETYPE = \"change notetype\"\n    CARD_LAYOUT = \"card layout\"\n    BROWSER_CARD_INFO = \"browser card info\"\n    IMPORT_CSV = \"csv import\"\n    EMPTY_CARDS = \"empty cards\"\n    FIND_DUPLICATES = \"find duplicates\"\n    FIELDS = \"fields\"\n    IMPORT_LOG = \"import log\"\n    IMPORT_ANKI_PACKAGE = \"anki package import\"\n\n\nclass AuthInterceptor(QWebEngineUrlRequestInterceptor):\n    _api_enabled = False\n\n    def __init__(self, parent: QObject | None = None, api_enabled: bool = False):\n        super().__init__(parent)\n        self._api_enabled = api_enabled\n\n    def interceptRequest(self, info):\n        from aqt.mediasrv import _APIKEY\n\n        if self._api_enabled and info.requestUrl().host() == \"127.0.0.1\":\n            info.setHttpHeader(b\"Authorization\", f\"Bearer {_APIKEY}\".encode(\"utf-8\"))\n\n\ndef _create_bridge_script() -> QWebEngineScript:\n    qwebchannel = \":/qtwebchannel/qwebchannel.js\"\n    jsfile = QFile(qwebchannel)\n    if not jsfile.open(QIODevice.OpenModeFlag.ReadOnly):\n        print(f\"Error opening '{qwebchannel}': {jsfile.error()}\", file=sys.stderr)\n    jstext = bytes(cast(bytes, jsfile.readAll())).decode(\"utf-8\")\n    jsfile.close()\n\n    script = QWebEngineScript()\n    script.setSourceCode(\n        jstext\n        + \"\"\"\n        var pycmd, bridgeCommand;\n        new QWebChannel(qt.webChannelTransport, function(channel) {\n            bridgeCommand = pycmd = function (arg, cb) {\n                var resultCB = function (res) {\n                    // pass result back to user-provided callback\n                    if (cb) {\n                        cb(JSON.parse(res));\n                    }\n                }\n            \n                channel.objects.py.cmd(arg, resultCB);\n                return false;                   \n            }\n            pycmd(\"domDone\");\n        });\n    \"\"\"\n    )\n    script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)\n    script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)\n    script.setRunsOnSubFrames(False)\n\n    return script\n\n\n_bridge_script = _create_bridge_script()\n\n_profile_with_api_access: QWebEngineProfile | None = None\n_profile_without_api_access: QWebEngineProfile | None = None\n\n\nclass AnkiWebPage(QWebEnginePage):\n    def __init__(\n        self,\n        onBridgeCmd: BridgeCommandHandler,\n        kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT,\n        parent: QObject | None = None,\n    ) -> None:\n        profile = self._profileForPage(kind)\n        self._inject_user_script(profile, _bridge_script)\n        QWebEnginePage.__init__(self, profile, parent)\n        self._onBridgeCmd = onBridgeCmd\n        self._kind = kind\n        self._setupBridge()\n        self.open_links_externally = True\n\n    def _profileForPage(self, kind: AnkiWebViewKind) -> QWebEngineProfile:\n        have_api_access = kind in (\n            AnkiWebViewKind.DECK_OPTIONS,\n            AnkiWebViewKind.EDITOR,\n            AnkiWebViewKind.DECK_STATS,\n            AnkiWebViewKind.CHANGE_NOTETYPE,\n            AnkiWebViewKind.BROWSER_CARD_INFO,\n            AnkiWebViewKind.IMPORT_ANKI_PACKAGE,\n            AnkiWebViewKind.IMPORT_CSV,\n            AnkiWebViewKind.IMPORT_LOG,\n        )\n\n        global _profile_with_api_access, _profile_without_api_access\n\n        # Use cached profile if available\n        if have_api_access and _profile_with_api_access is not None:\n            return _profile_with_api_access\n        elif not have_api_access and _profile_without_api_access is not None:\n            return _profile_without_api_access\n\n        # Create a new profile if not cached\n        profile = QWebEngineProfile()\n\n        interceptor = AuthInterceptor(profile, api_enabled=have_api_access)\n        profile.setUrlRequestInterceptor(interceptor)\n        if have_api_access:\n            _profile_with_api_access = profile\n        else:\n            _profile_without_api_access = profile\n\n        return profile\n\n    def _setupBridge(self) -> None:\n        # Add-on compatibility: For existing add-on callers that override the init\n        # and invoke _setupBridge directly (e.g. in order to use a custom web profile),\n        # we need to ensure that the bridge script is injected into the profile scripts,\n        # if it has yet to be injected.\n        profile = self.profile()\n        assert profile is not None\n        scripts = profile.scripts()\n        assert scripts is not None\n\n        if not scripts.contains(_bridge_script):\n            print(\"add-on callers should not call _setupBridge directly\")\n            self._inject_user_script(profile, _bridge_script)\n\n        class Bridge(QObject):\n            def __init__(self, bridge_handler: Callable[[str], Any]) -> None:\n                super().__init__()\n                self.onCmd = bridge_handler\n\n            @pyqtSlot(str, result=str)  # type: ignore\n            def cmd(self, str: str) -> Any:\n                return json.dumps(self.onCmd(str))\n\n        self._bridge = Bridge(self._onCmd)\n\n        self._channel = QWebChannel(self)\n        self._channel.registerObject(\"py\", self._bridge)\n        self.setWebChannel(self._channel)\n\n    def _inject_user_script(\n        self, profile: QWebEngineProfile, script: QWebEngineScript\n    ) -> None:\n        scripts = profile.scripts()\n        assert scripts is not None\n        scripts.insert(script)\n\n    def javaScriptConsoleMessage(\n        self,\n        level: QWebEnginePage.JavaScriptConsoleMessageLevel,\n        msg: str | None,\n        line: int,\n        srcID: str | None,\n    ) -> None:\n        # not translated because console usually not visible,\n        # and may only accept ascii text\n        assert srcID is not None\n        if srcID.startswith(\"data\"):\n            srcID = \"\"\n        else:\n            srcID = serverbaseurl.sub(\"\", srcID[:80], 1)\n        if level == QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel:\n            level_str = \"info\"\n        elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel:\n            level_str = \"warning\"\n        elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel:\n            level_str = \"error\"\n        else:\n            level_str = str(level)\n        buf = \"JS %(t)s %(f)s:%(a)d %(b)s\" % dict(\n            t=level_str, a=line, f=srcID, b=f\"{msg}\\n\"\n        )\n        if \"MathJax localStorage\" in buf:\n            # silence localStorage noise\n            return\n        elif \"link preload\" in buf:\n            # silence 'link preload' warning on the first card\n            return\n        # ensure we don't try to write characters the terminal can't handle\n        buf = buf.encode(sys.stdout.encoding, \"backslashreplace\").decode(\n            sys.stdout.encoding\n        )\n        # output to stdout because it may raise error messages on the anki GUI\n        # https://github.com/ankitects/anki/pull/560\n        sys.stdout.write(buf)\n\n    def acceptNavigationRequest(\n        self, url: QUrl, navType: Any, isMainFrame: bool\n    ) -> bool:\n        from aqt.mediasrv import is_sveltekit_page\n\n        if (\n            not self.open_links_externally\n            or \"_anki/pages\" in url.path()\n            or url.path() == \"/_anki/legacyPageData\"\n            or is_sveltekit_page(url.path()[1:])\n        ):\n            return super().acceptNavigationRequest(url, navType, isMainFrame)\n\n        if not isMainFrame:\n            return True\n        # data: links generated by setHtml()\n        if url.scheme() == \"data\":\n            return True\n        # catch buggy <a href='#' onclick='func()'> links\n        from aqt import mw\n\n        if url.matches(\n            QUrl(mw.serverURL()), cast(Any, QUrl.UrlFormattingOption.RemoveFragment)\n        ):\n            print(\"onclick handler needs to return false\")\n            return False\n        # load all other links in browser\n        from aqt.url_schemes import open_url_if_supported_scheme\n\n        open_url_if_supported_scheme(url)\n        return False\n\n    def _onCmd(self, str: str) -> Any:\n        return self._onBridgeCmd(str)\n\n    def javaScriptAlert(self, frame: Any, text: str | None) -> None:\n        if text is None:\n            return\n\n        showInfo(text)\n\n    def javaScriptConfirm(self, frame: Any, text: str | None) -> bool:\n        if text is None:\n            return False\n\n        return askUser(text)\n\n\n# Add-ons\n##########################################################################\n\n\n@dataclasses.dataclass\nclass WebContent:\n    \"\"\"Stores all dynamically modified content that a particular web view\n    will be populated with.\n\n    Attributes:\n        body {str} -- HTML body\n        head {str} -- HTML head\n        css {List[str]} -- List of media server subpaths,\n                           each pointing to a CSS file\n        js {List[str]} -- List of media server subpaths,\n                          each pointing to a JS file\n\n    Important Notes:\n        - When modifying the attributes specified above, please make sure your\n        changes only perform the minimum required edits to make your add-on work.\n        You should avoid overwriting or interfering with existing data as much\n        as possible, instead opting to append your own changes, e.g.:\n\n            def on_webview_will_set_content(web_content: WebContent, context) -> None:\n                web_content.body += \"<my_html>\"\n                web_content.head += \"<my_head>\"\n\n        - The paths specified in `css` and `js` need to be accessible by Anki's\n          media server. All list members without a specified subpath are assumed\n          to be located under `/_anki`, which is the media server subpath used\n          for all web assets shipped with Anki.\n\n          Add-ons may expose their own web assets by utilizing\n          aqt.addons.AddonManager.setWebExports(). Web exports registered\n          in this manner may then be accessed under the `/_addons` subpath.\n\n          E.g., to allow access to a `my-addon.js` and `my-addon.css` residing\n          in a \"web\" subfolder in your add-on package, first register the\n          corresponding web export:\n\n          > from aqt import mw\n          > mw.addonManager.setWebExports(__name__, r\"web/.*(css|js)\")\n\n          Then append the subpaths to the corresponding web_content fields\n          within a function subscribing to gui_hooks.webview_will_set_content:\n\n              def on_webview_will_set_content(web_content: WebContent, context) -> None:\n                  addon_package = mw.addonManager.addonFromModule(__name__)\n                  web_content.css.append(\n                      f\"/_addons/{addon_package}/web/my-addon.css\")\n                  web_content.js.append(\n                      f\"/_addons/{addon_package}/web/my-addon.js\")\n\n          Note that '/' will also match the os specific path separator.\n    \"\"\"\n\n    body: str = \"\"\n    head: str = \"\"\n    css: list[str] = dataclasses.field(default_factory=lambda: [])\n    js: list[str] = dataclasses.field(default_factory=lambda: [])\n\n\n# Main web view\n##########################################################################\n\n\nclass AnkiWebView(QWebEngineView):\n    allow_drops = False\n    _kind: AnkiWebViewKind\n\n    def __init__(\n        self,\n        parent: QWidget | None = None,\n        title: str = \"\",  # used by add-ons; in Anki code use kind instead to set title\n        kind: AnkiWebViewKind = AnkiWebViewKind.DEFAULT,\n    ) -> None:\n        QWebEngineView.__init__(self, parent=parent)\n        self._kind = kind\n        self.set_title(kind.value)\n        self.setPage(AnkiWebPage(self._onBridgeCmd, kind, self))\n        # reduce flicker\n        self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS))\n\n        # in new code, use .set_bridge_command() instead of setting this directly\n        self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd\n\n        self._domDone = True\n        self._pendingActions: list[tuple[str, Sequence[Any]]] = []\n        self.requiresCol = True\n        self._disable_zoom = False\n\n        self.resetHandlers()\n        self._filterSet = False\n        gui_hooks.theme_did_change.append(self.on_theme_did_change)\n        gui_hooks.body_classes_need_update.append(self.on_body_classes_need_update)\n\n        qconnect(self.loadFinished, self._on_load_finished)\n\n    def _on_load_finished(self) -> None:\n        self.eval(\n            \"\"\"\n        document.addEventListener(\"keydown\", function(evt) {\n            if (evt.key === \"Escape\") {\n                pycmd(\"close\");\n            }\n        });\n        \"\"\"\n        )\n\n    def page(self) -> AnkiWebPage:\n        return cast(AnkiWebPage, super().page())\n\n    @property\n    def kind(self) -> AnkiWebViewKind:\n        \"\"\"Used by add-ons to identify the webview kind\"\"\"\n        return self._kind\n\n    def set_title(self, title: str) -> None:\n        self.title = title  # type: ignore[assignment]\n\n    def disable_zoom(self) -> None:\n        self._disable_zoom = True\n\n    def createWindow(self, windowType: QWebEnginePage.WebWindowType) -> QWebEngineView:\n        # intercept opening a new window (hrefs\n        # with target=\"_blank\") and return view\n        return AnkiWebView()\n\n    def eventFilter(self, obj: QObject | None, evt: QEvent | None) -> bool:\n        if evt is None:\n            return False\n        if self._disable_zoom and is_gesture_or_zoom_event(evt):\n            return True\n\n        if (\n            isinstance(evt, QMouseEvent)\n            and evt.type() == QEvent.Type.MouseButtonRelease\n        ):\n            from aqt import mw\n\n            if evt.button() == Qt.MouseButton.MiddleButton and is_lin:\n                if mw.pm.middle_click_paste_enabled():\n                    self.onMiddleClickPaste()\n                return True\n\n        return False\n\n    def set_open_links_externally(self, enable: bool) -> None:\n        self.page().open_links_externally = enable\n\n    def onEsc(self) -> None:\n        w = self.parent()\n        while w:\n            if isinstance(w, QDialog) or isinstance(w, QMainWindow):\n                from aqt import mw\n\n                # esc in a child window closes the window\n                if w != mw:\n                    w.close()\n                else:\n                    # in the main window, removes focus from type in area\n                    parent = self.parent()\n                    assert isinstance(parent, QWidget)\n                    parent.setFocus()\n                break\n            w = w.parent()\n\n    def onCopy(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Copy)\n\n    def onCut(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Cut)\n\n    def onPaste(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Paste)\n\n    def onMiddleClickPaste(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.Paste)\n\n    def onSelectAll(self) -> None:\n        self.triggerPageAction(QWebEnginePage.WebAction.SelectAll)\n\n    def contextMenuEvent(self, evt: QContextMenuEvent | None) -> None:\n        m = QMenu(self)\n        self._maybe_add_copy_action(m)\n        gui_hooks.webview_will_show_context_menu(self, m)\n        if m.actions():\n            m.popup(QCursor.pos())\n\n    def _maybe_add_copy_action(self, menu: QMenu) -> None:\n        if self.hasSelection():\n            a = menu.addAction(tr.actions_copy())\n            assert a is not None\n            qconnect(a.triggered, self.onCopy)\n\n    def dropEvent(self, evt: QDropEvent | None) -> None:\n        if self.allow_drops:\n            super().dropEvent(evt)\n\n    def setHtml(  #  type: ignore[override]\n        self, html: str, context: PageContext | None = None\n    ) -> None:\n        from aqt.mediasrv import PageContext\n\n        # discard any previous pending actions\n        self._pendingActions = []\n        self._domDone = True\n        if context is None:\n            context = PageContext.UNKNOWN\n        self._queueAction(\"setHtml\", html, context)\n        self.set_open_links_externally(True)\n        self.allow_drops = False\n        self.show()\n\n    def _setHtml(self, html: str, context: PageContext) -> None:\n        \"\"\"Send page data to media server, then surf to it.\n\n        This function used to be implemented by QWebEngine's\n        .setHtml() call. It is no longer used, as it has a\n        maximum size limit, and due to security changes, it\n        will stop working in the future.\"\"\"\n        from aqt import mw\n\n        oldFocus = mw.app.focusWidget()\n        self._domDone = False\n\n        webview_id = id(self)\n        mw.mediaServer.set_page_html(webview_id, html, context)\n        self.load_url(QUrl(f\"{mw.serverURL()}_anki/legacyPageData?id={webview_id}\"))\n\n        # work around webengine stealing focus on setHtml()\n        # fixme: check which if any qt versions this is still required on\n        if oldFocus:\n            oldFocus.setFocus()\n\n    def load_url(self, url: QUrl) -> None:\n        # allow queuing actions when loading url directly\n        self._domDone = False\n        self.allow_drops = False\n        super().load(url)\n\n    def app_zoom_factor(self) -> float:\n        # overridden scale factor?\n        webscale = os.environ.get(\"ANKI_WEBSCALE\")\n        if webscale:\n            return float(webscale)\n\n        if qtmajor > 5 or is_mac:\n            return 1\n        screen = QApplication.desktop().screen()  # type: ignore\n        if screen is None:\n            return 1\n\n        dpi = screen.logicalDpiX()\n        factor = dpi / 96.0\n        if is_lin:\n            factor = max(1, factor)\n            return factor\n        return 1\n\n    def setPlaybackRequiresGesture(self, value: bool) -> None:\n        settings = self.settings()\n        assert settings is not None\n        settings.setAttribute(\n            QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, value\n        )\n\n    def _getQtIntScale(self, screen: QWidget) -> int:\n        # try to detect if Qt has scaled the screen\n        # - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,\n        #   and a dpi of 150% = 2x\n        # - a screen with a normal physical dpi of 72 will have a dpi of 32\n        #   if the scale factor has been rounded to 2x\n        # - different screens have different physical DPIs (eg 72, 93, 102)\n        # - until a better solution presents itself, assume a physical DPI at\n        #   or above 70 is unscaled\n        if screen.physicalDpiX() > 70:\n            return 1\n        elif screen.physicalDpiX() > 35:\n            return 2\n        else:\n            return 3\n\n    def standard_css(self) -> str:\n        color_hl = theme_manager.var(colors.BORDER_FOCUS)\n\n        if is_win:\n            # T: include a font for your language on Windows, eg: \"Segoe UI\", \"MS Mincho\"\n            family = tr.qt_misc_segoe_ui()\n            button_style = f\"\"\"\nbutton {{ font-family: {family}; }}\n            \"\"\"\n            font = f\"font-family:{family};\"\n        elif is_mac:\n            family = \"Helvetica\"\n            font = f'font-family:\"{family}\";'\n            button_style = \"\"\"\nbutton {\n    --canvas: #fff;\n    -webkit-appearance: none;\n    background: var(--canvas);\n    border-radius: var(--border-radius);\n    padding: 3px 12px;\n    border: 1px solid var(--border);\n    box-shadow: 0px 1px 3px var(--border-subtle);\n    font-family: Helvetica\n}\n.night-mode button { --canvas: #606060; --fg: #eee; }\n\"\"\"\n        else:\n            family = self.font().family()\n            font = f'font-family:\"{family}\", sans-serif;'\n            button_style = \"\"\"\n/* Buttons */\nbutton{{ \n    font-family: \"{family}\", sans-serif;\n}}\n/* Input field focus outline */\ntextarea:focus, input:focus, input[type]:focus, .uneditable-input:focus,\ndiv[contenteditable=\"true\"]:focus {{   \n    outline: 0 none;\n    border-color: {color_hl};\n}}\"\"\".format(\n                family=family,\n                color_hl=color_hl,\n            )\n\n        zoom = self.app_zoom_factor()\n\n        return f\"\"\"\nbody {{ zoom: {zoom}; background-color: var(--canvas); }}\nhtml {{ {font} }}\n{button_style}\n:root {{ --canvas: {colors.CANVAS[\"light\"]} }}\n:root[class*=night-mode] {{ --canvas: {colors.CANVAS[\"dark\"]} }}\n\"\"\"\n\n    def stdHtml(\n        self,\n        body: str,\n        css: list[str] | None = None,\n        js: list[str] | None = None,\n        head: str = \"\",\n        context: Any | None = None,\n        default_css: bool = True,\n    ) -> None:\n        css = ([\"css/webview.css\"] if default_css else []) + (\n            [] if css is None else css\n        )\n        web_content = WebContent(\n            body=body,\n            head=head,\n            js=[\"js/webview.js\"] + ([\"js/vendor/jquery.min.js\"] if js is None else js),\n            css=css,\n        )\n\n        gui_hooks.webview_will_set_content(web_content, context)\n\n        csstxt = \"\"\n        if \"css/webview.css\" in css:\n            # we want our dynamic styling to override the defaults in\n            # css/webview.css, but come before user-provided stylesheets so that\n            # they can override us if necessary\n            web_content.css.remove(\"css/webview.css\")\n            csstxt = self.bundledCSS(\"css/webview.css\")\n            csstxt += f\"<style>{self.standard_css()}</style>\"\n\n        csstxt += \"\\n\".join(self.bundledCSS(fname) for fname in web_content.css)\n        jstxt = \"\\n\".join(self.bundledScript(fname) for fname in web_content.js)\n\n        from aqt import mw\n\n        head = mw.baseHTML() + csstxt + web_content.head\n        body_class = theme_manager.body_class()\n\n        if theme_manager.night_mode:\n            doc_class = \"night-mode\"\n            bs_theme = \"dark\"\n        else:\n            doc_class = \"\"\n            bs_theme = \"light\"\n\n        if is_rtl(anki.lang.current_lang):\n            lang_dir = \"rtl\"\n        else:\n            lang_dir = \"ltr\"\n\n        html = f\"\"\"\n<!doctype html>\n<html class=\"{doc_class}\" dir=\"{lang_dir}\" data-bs-theme=\"{bs_theme}\">\n<head>\n    <title>{self.title}</title>\n{head}\n</head>\n\n<body class=\"{body_class}\">\n{jstxt}\n{web_content.body}</body>\n</html>\"\"\"\n        # print(html)\n        import aqt.browser.previewer\n        import aqt.clayout\n        import aqt.deckoptions\n        import aqt.editor\n        import aqt.reviewer\n        from aqt.mediasrv import PageContext\n\n        if isinstance(context, aqt.editor.Editor):\n            page_context = PageContext.EDITOR\n        elif isinstance(context, aqt.reviewer.Reviewer):\n            page_context = PageContext.REVIEWER\n        elif isinstance(context, aqt.browser.previewer.Previewer):\n            page_context = PageContext.PREVIEWER\n        elif isinstance(context, aqt.clayout.CardLayout):\n            page_context = PageContext.CARD_LAYOUT\n        elif isinstance(context, aqt.deckoptions.DeckOptionsDialog):\n            page_context = PageContext.DECK_OPTIONS\n        else:\n            page_context = PageContext.UNKNOWN\n        self.setHtml(html, page_context)\n\n    @classmethod\n    def webBundlePath(cls, path: str) -> str:\n        from aqt import mw\n\n        if path.startswith(\"/\"):\n            subpath = \"\"\n        else:\n            subpath = \"/_anki/\"\n\n        return f\"http://127.0.0.1:{mw.mediaServer.getPort()}{subpath}{path}\"\n\n    def bundledScript(self, fname: str) -> str:\n        return f'<script src=\"{self.webBundlePath(fname)}\"></script>'\n\n    def bundledCSS(self, fname: str) -> str:\n        return '<link rel=\"stylesheet\" type=\"text/css\" href=\"%s\">' % self.webBundlePath(\n            fname\n        )\n\n    def eval(self, js: str) -> None:\n        self.evalWithCallback(js, None)\n\n    def evalWithCallback(self, js: str, cb: Callable | None) -> None:\n        self._queueAction(\"eval\", js, cb)\n\n    def _evalWithCallback(self, js: str, cb: Callable[[Any], Any] | None) -> None:\n        page = self.page()\n        assert page is not None\n\n        def handler(val: Any) -> None:\n            if self._shouldIgnoreWebEvent():\n                print(\"ignored late js callback\", cb)\n                return\n            if cb:\n                cb(val)\n\n            # Without the following, stale frames showing previous or corrupt content get occasionally displayed. (see #3668 for more details)\n            self.update()\n\n        page.runJavaScript(js, handler)\n\n    def _queueAction(self, name: str, *args: Any) -> None:\n        self._pendingActions.append((name, args))\n        self._maybeRunActions()\n\n    def _maybeRunActions(self) -> None:\n        if sip.isdeleted(self):\n            return\n        while self._pendingActions and self._domDone:\n            name, args = self._pendingActions.pop(0)\n\n            if name == \"eval\":\n                self._evalWithCallback(*args)\n            elif name == \"setHtml\":\n                self._setHtml(*args)\n            else:\n                raise Exception(f\"unknown action: {name}\")\n\n    def _openLinksExternally(self, url: str) -> None:\n        openLink(url)\n\n    def _shouldIgnoreWebEvent(self) -> bool:\n        # async web events may be received after the profile has been closed\n        # or the underlying webview has been deleted\n        from aqt import mw\n\n        if sip.isdeleted(self):\n            return True\n        if not mw.col and self.requiresCol:\n            return True\n        return False\n\n    def _onBridgeCmd(self, cmd: str) -> Any:\n        if self._shouldIgnoreWebEvent():\n            print(\"ignored late bridge cmd\", cmd)\n            return\n\n        if not self._filterSet:\n            focus_proxy = self.focusProxy()\n            assert focus_proxy is not None\n            focus_proxy.installEventFilter(self)\n            self._filterSet = True\n\n        if cmd == \"domDone\":\n            self._domDone = True\n            self._maybeRunActions()\n        elif cmd == \"close\":\n            self.onEsc()\n        else:\n            handled, result = gui_hooks.webview_did_receive_js_message(\n                (False, None), cmd, self._bridge_context\n            )\n            if handled:\n                return result\n            else:\n                return self.onBridgeCmd(cmd)\n\n    def defaultOnBridgeCmd(self, cmd: str) -> None:\n        print(\"unhandled bridge cmd:\", cmd)\n\n    # legacy\n    def resetHandlers(self) -> None:\n        self.onBridgeCmd = self.defaultOnBridgeCmd\n        self._bridge_context = None\n\n    def adjustHeightToFit(self) -> None:\n        self.evalWithCallback(\"document.documentElement.offsetHeight\", self._onHeight)\n\n    def _onHeight(self, qvar: int | None) -> None:\n        from aqt import mw\n\n        if qvar is None:\n            mw.progress.single_shot(1000, mw.reset)\n            return\n\n        self.setFixedHeight(int(qvar))\n\n    def set_bridge_command(self, func: Callable[[str], Any], context: Any) -> None:\n        \"\"\"Set a handler for pycmd() messages received from Javascript.\n\n        Context is the object calling this routine, eg an instance of\n        aqt.reviewer.Reviewer or aqt.deckbrowser.DeckBrowser.\"\"\"\n        self.onBridgeCmd = func\n        self._bridge_context = context\n\n    def hide_while_preserving_layout(self) -> None:\n        \"Hide but keep existing size.\"\n        sp = self.sizePolicy()\n        sp.setRetainSizeWhenHidden(True)\n        self.setSizePolicy(sp)\n        self.hide()\n\n    def add_dynamic_styling_and_props_then_show(self) -> None:\n        \"Add dynamic styling, title, set platform-specific body classes and reveal.\"\n        css = self.standard_css()\n        body_classes = theme_manager.body_class().split(\" \")\n\n        def after_injection(arg: Any) -> None:\n            gui_hooks.webview_did_inject_style_into_page(self)\n            self.show()\n\n        if theme_manager.night_mode:\n            night_mode = 'document.documentElement.classList.add(\"night-mode\");'\n        else:\n            night_mode = \"\"\n        self.evalWithCallback(\n            f\"\"\"\n(function(){{\n    document.title = `{self.title}`;\n    const style = document.createElement('style');\n    style.innerHTML = `{css}`;\n    document.head.appendChild(style);\n    document.body.classList.add({\", \".join([f'\"{c}\"' for c in body_classes])});\n    {night_mode}\n}})();\n\"\"\",\n            after_injection,\n        )\n\n    def load_ts_page(self, name: str) -> None:\n        from aqt import mw\n\n        self.set_open_links_externally(True)\n        if theme_manager.night_mode:\n            extra = \"#night\"\n        else:\n            extra = \"\"\n        self.load_url(QUrl(f\"{mw.serverURL()}_anki/pages/{name}.html{extra}\"))\n        self.add_dynamic_styling_and_props_then_show()\n\n    def load_sveltekit_page(self, path: str) -> None:\n        from aqt import mw\n\n        self.set_open_links_externally(True)\n        if theme_manager.night_mode:\n            extra = \"#night\"\n        else:\n            extra = \"\"\n\n        if hmr_mode:\n            server = \"http://127.0.0.1:5173/\"\n        else:\n            server = mw.serverURL()\n\n        self.load_url(QUrl(f\"{server}{path}{extra}\"))\n        self.add_dynamic_styling_and_props_then_show()\n\n    def force_load_hack(self) -> None:\n        \"\"\"Force process to initialize.\n        Must be done on Windows prior to changing current working directory.\"\"\"\n        self.requiresCol = False\n        self._domReady = False\n        self.page().setContent(cast(QByteArray, bytes(\"\", \"ascii\")))\n\n    def cleanup(self) -> None:\n        try:\n            from aqt import mw\n        except ImportError:\n            # this will fail when __del__ is called during app shutdown\n            return\n\n        gui_hooks.theme_did_change.remove(self.on_theme_did_change)\n        gui_hooks.body_classes_need_update.remove(self.on_body_classes_need_update)\n        # defer page cleanup so that in-flight requests have a chance to complete first\n        # https://forums.ankiweb.net/t/error-when-exiting-browsing-when-the-software-is-installed-in-the-path-c-program-files-anki/38363\n        mw.progress.single_shot(5000, lambda: mw.mediaServer.clear_page_html(id(self)))\n        self.page().deleteLater()\n\n    def on_theme_did_change(self) -> None:\n        # avoid flashes if page reloaded\n        self.page().setBackgroundColor(theme_manager.qcolor(colors.CANVAS))\n        # update night-mode class, and legacy nightMode/night-mode body classes\n        self.eval(\n            f\"\"\"\n(function() {{\n    const doc = document.documentElement;\n    const body = document.body.classList;\n    if ({1 if theme_manager.night_mode else 0}) {{\n        doc.dataset.bsTheme = \"dark\";\n        doc.classList.add(\"night-mode\");\n        body.add(\"night_mode\");\n        body.add(\"nightMode\");\n        {\"body.add('macos-dark-mode');\" if theme_manager.macos_dark_mode() else \"\"}\n    }} else {{\n        doc.dataset.bsTheme = \"light\";\n        doc.classList.remove(\"night-mode\");\n        body.remove(\"night_mode\");\n        body.remove(\"nightMode\");\n        body.remove(\"macos-dark-mode\");\n    }}\n}})();\n\"\"\"\n        )\n\n    def on_body_classes_need_update(self) -> None:\n        from aqt import mw\n\n        self.eval(\n            f\"\"\"document.body.classList.toggle(\"fancy\", {json.dumps(not mw.pm.minimalist_mode())}); \"\"\"\n        )\n        self.eval(\n            f\"\"\"document.body.classList.toggle(\"reduce-motion\", {json.dumps(mw.pm.reduce_motion())}); \"\"\"\n        )\n\n    @deprecated(info=\"use theme_manager.qcolor() instead\")\n    def get_window_bg_color(self, night_mode: bool | None = None) -> QColor:\n        return theme_manager.qcolor(colors.CANVAS)\n\n\n# Pre-configured classes for use in Qt Designer\n##########################################################################\n\n\nclass _AnkiWebViewKwargs(TypedDict, total=False):\n    parent: QWidget | None\n    title: str\n    kind: AnkiWebViewKind\n\n\ndef _create_ankiwebview_subclass(\n    name: str,\n    /,\n    **fixed_kwargs: Unpack[_AnkiWebViewKwargs],\n) -> Type[AnkiWebView]:\n    def __init__(self, *args: Any, **kwargs: _AnkiWebViewKwargs) -> None:\n        # user‑supplied kwargs override fixed kwargs\n        merged = cast(_AnkiWebViewKwargs, {**fixed_kwargs, **kwargs})\n        AnkiWebView.__init__(self, *args, **merged)\n\n    __init__.__qualname__ = f\"{name}.__init__\"\n    if fixed_kwargs:\n        __init__.__doc__ = (\n            f\"Auto‑generated wrapper that pre‑sets \"\n            f\"{', '.join(f'{k}={v!r}' for k, v in fixed_kwargs.items())}.\"\n        )\n\n    cls: Type[AnkiWebView] = type(name, (AnkiWebView,), {\"__init__\": __init__})\n\n    return cls\n\n\n# These subclasses are used in Qt Designer UI files to allow for configuring\n# web views at initialization time (custom widgets can otherwise only be\n# initialized with the default constructor)\nStatsWebView = _create_ankiwebview_subclass(\n    \"StatsWebView\", kind=AnkiWebViewKind.DECK_STATS\n)\nLegacyStatsWebView = _create_ankiwebview_subclass(\n    \"LegacyStatsWebView\", kind=AnkiWebViewKind.LEGACY_DECK_STATS\n)\nEmptyCardsWebView = _create_ankiwebview_subclass(\n    \"EmptyCardsWebView\", kind=AnkiWebViewKind.EMPTY_CARDS\n)\nFindDupesWebView = _create_ankiwebview_subclass(\n    \"FindDupesWebView\", kind=AnkiWebViewKind.FIND_DUPLICATES\n)\n"
  },
  {
    "path": "qt/aqt/widgetgallery.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport aqt\nimport aqt.main\nfrom aqt.qt import QDialog, QWidget, qconnect\nfrom aqt.theme import WidgetStyle\nfrom aqt.utils import restoreGeom, saveGeom\n\n\nclass WidgetGallery(QDialog):\n    silentlyClose = True\n\n    def __init__(self, parent: QWidget) -> None:\n        assert aqt.mw\n        super().__init__(parent)\n\n        self.form = aqt.forms.widgets.Ui_Dialog()\n        self.form.setupUi(self)\n        restoreGeom(self, \"WidgetGallery\")\n\n        qconnect(\n            self.form.disableCheckBox.stateChanged,\n            lambda: self.form.testGrid.setEnabled(\n                not self.form.disableCheckBox.isChecked()\n            ),\n        )\n\n        self.form.styleComboBox.addItems(\n            [member.name.lower().capitalize() for member in WidgetStyle]\n        )\n        self.form.styleComboBox.setCurrentIndex(aqt.mw.pm.get_widget_style())\n        qconnect(\n            self.form.styleComboBox.currentIndexChanged,\n            aqt.mw.pm.set_widget_style,\n        )\n\n    def reject(self) -> None:\n        super().reject()\n        saveGeom(self, \"WidgetGallery\")\n"
  },
  {
    "path": "qt/aqt/winpaths.py",
    "content": "\"\"\"\nSystem File Locations\nRetrieves common system path names on Windows XP/Vista\nDepends only on ctypes, and retrieves path locations in Unicode\n\"\"\"\n\nimport ctypes\nfrom ctypes import windll, wintypes  # type: ignore\n\n__license__ = \"MIT\"\n__version__ = \"0.2\"\n__author__ = \"Ryan Ginstrom\"\n__description__ = \"Retrieves common Windows system paths as Unicode strings\"\n\n\nclass PathConstants:\n    \"\"\"\n    Define constants here to avoid dependency on shellcon.\n    Put it in a class to avoid polluting namespace\n    \"\"\"\n\n    CSIDL_DESKTOP = 0\n    CSIDL_PROGRAMS = 2\n    CSIDL_PERSONAL = 5\n    CSIDL_FAVORITES = 6\n    CSIDL_STARTUP = 7\n    CSIDL_RECENT = 8\n    CSIDL_SENDTO = 9\n    CSIDL_BITBUCKET = 10\n    CSIDL_STARTMENU = 11\n    CSIDL_MYDOCUMENTS = 12\n    CSIDL_MYMUSIC = 13\n    CSIDL_MYVIDEO = 14\n    CSIDL_DESKTOPDIRECTORY = 16\n    CSIDL_DRIVES = 17\n    CSIDL_NETWORK = 18\n    CSIDL_NETHOOD = 19\n    CSIDL_FONTS = 20\n    CSIDL_TEMPLATES = 21\n    CSIDL_COMMON_STARTMENU = 22\n    CSIDL_COMMON_PROGRAMS = 23\n    CSIDL_COMMON_STARTUP = 24\n    CSIDL_COMMON_DESKTOPDIRECTORY = 25\n    CSIDL_APPDATA = 26\n    CSIDL_PRINTHOOD = 27\n    CSIDL_LOCAL_APPDATA = 28\n    CSIDL_ALTSTARTUP = 29\n    CSIDL_COMMON_ALTSTARTUP = 30\n    CSIDL_COMMON_FAVORITES = 31\n    CSIDL_INTERNET_CACHE = 32\n    CSIDL_COOKIES = 33\n    CSIDL_HISTORY = 34\n    CSIDL_COMMON_APPDATA = 35\n    CSIDL_WINDOWS = 36\n    CSIDL_SYSTEM = 37\n    CSIDL_PROGRAM_FILES = 38\n    CSIDL_MYPICTURES = 39\n    CSIDL_PROFILE = 40\n    CSIDL_SYSTEMX86 = 41\n    CSIDL_PROGRAM_FILESX86 = 42\n    CSIDL_PROGRAM_FILES_COMMON = 43\n    CSIDL_PROGRAM_FILES_COMMONX86 = 44\n    CSIDL_COMMON_TEMPLATES = 45\n    CSIDL_COMMON_DOCUMENTS = 46\n    CSIDL_COMMON_ADMINTOOLS = 47\n    CSIDL_ADMINTOOLS = 48\n    CSIDL_CONNECTIONS = 49\n    CSIDL_COMMON_MUSIC = 53\n    CSIDL_COMMON_PICTURES = 54\n    CSIDL_COMMON_VIDEO = 55\n    CSIDL_RESOURCES = 56\n    CSIDL_RESOURCES_LOCALIZED = 57\n    CSIDL_COMMON_OEM_LINKS = 58\n    CSIDL_CDBURN_AREA = 59\n    # 60 unused\n    CSIDL_COMPUTERSNEARME = 61\n\n\nclass WinPathsException(Exception):\n    pass\n\n\ndef _err_unless_zero(result):\n    if result == 0:\n        return result\n    else:\n        raise WinPathsException(f\"Failed to retrieve windows path: {result}\")\n\n\n_SHGetFolderPath = windll.shell32.SHGetFolderPathW\n_SHGetFolderPath.argtypes = [\n    wintypes.HWND,\n    ctypes.c_int,\n    wintypes.HANDLE,\n    wintypes.DWORD,\n    wintypes.LPCWSTR,\n]\n_SHGetFolderPath.restype = _err_unless_zero\n\n\ndef _get_path_buf(csidl):\n    path_buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH)\n    _SHGetFolderPath(0, csidl, 0, 0, path_buf)\n    return path_buf.value\n\n\ndef get_local_appdata():\n    return _get_path_buf(PathConstants.CSIDL_LOCAL_APPDATA)\n\n\ndef get_appdata():\n    return _get_path_buf(PathConstants.CSIDL_APPDATA)\n\n\ndef get_desktop():\n    return _get_path_buf(PathConstants.CSIDL_DESKTOP)\n\n\ndef get_programs():\n    \"\"\"current user -> Start menu -> Programs\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_PROGRAMS)\n\n\ndef get_admin_tools():\n    \"\"\"current user -> Start menu -> Programs -> Admin tools\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_ADMINTOOLS)\n\n\ndef get_common_admin_tools():\n    \"\"\"all users -> Start menu -> Programs -> Admin tools\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_COMMON_ADMINTOOLS)\n\n\ndef get_common_appdata():\n    return _get_path_buf(PathConstants.CSIDL_COMMON_APPDATA)\n\n\ndef get_common_documents():\n    return _get_path_buf(PathConstants.CSIDL_COMMON_DOCUMENTS)\n\n\ndef get_cookies():\n    return _get_path_buf(PathConstants.CSIDL_COOKIES)\n\n\ndef get_history():\n    return _get_path_buf(PathConstants.CSIDL_HISTORY)\n\n\ndef get_internet_cache():\n    return _get_path_buf(PathConstants.CSIDL_INTERNET_CACHE)\n\n\ndef get_my_pictures():\n    \"\"\"Get the user's My Pictures folder\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_MYPICTURES)\n\n\ndef get_personal():\n    \"\"\"AKA 'My Documents'\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_PERSONAL)\n\n\nget_my_documents = get_personal\n\n\ndef get_program_files():\n    return _get_path_buf(PathConstants.CSIDL_PROGRAM_FILES)\n\n\ndef get_program_files_common():\n    return _get_path_buf(PathConstants.CSIDL_PROGRAM_FILES_COMMON)\n\n\ndef get_system():\n    \"\"\"Use with care and discretion\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_SYSTEM)\n\n\ndef get_windows():\n    \"\"\"Use with care and discretion\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_WINDOWS)\n\n\ndef get_favorites():\n    return _get_path_buf(PathConstants.CSIDL_FAVORITES)\n\n\ndef get_startup():\n    \"\"\"current user -> start menu -> programs -> startup\"\"\"\n    return _get_path_buf(PathConstants.CSIDL_STARTUP)\n\n\ndef get_recent():\n    return _get_path_buf(PathConstants.CSIDL_RECENT)\n"
  },
  {
    "path": "qt/hatch_build.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Any, Dict\n\nfrom hatchling.builders.hooks.plugin.interface import BuildHookInterface\n\n\nclass CustomBuildHook(BuildHookInterface):\n    \"\"\"Build hook to copy generated files into both sdist and wheel.\"\"\"\n\n    PLUGIN_NAME = \"custom\"\n\n    def initialize(self, version: str, build_data: Dict[str, Any]) -> None:\n        \"\"\"Initialize the build hook.\"\"\"\n        force_include = build_data.setdefault(\"force_include\", {})\n\n        # Pin anki==<our version>\n        self._set_anki_dependency(version, build_data)\n\n        # Look for generated files in out/qt/_aqt\n        project_root = Path(self.root).parent\n        generated_root = project_root / \"out\" / \"qt\" / \"_aqt\"\n\n        if not os.environ.get(\"ANKI_WHEEL_TAG\"):\n            # On Windows, uv invokes this build hook during the initial uv sync,\n            # when the tag has not been declared by our build script.\n            return\n\n        assert generated_root.exists(), \"you should build with --wheel\"\n        self._add_aqt_files(force_include, generated_root)\n\n    def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None:\n        # Get current dependencies and replace 'anki' with exact version\n        dependencies = build_data.setdefault(\"dependencies\", [])\n\n        # Remove any existing anki dependency\n        dependencies[:] = [dep for dep in dependencies if not dep.startswith(\"anki\")]\n\n        # Handle version detection\n        actual_version = version\n        if version == \"standard\":\n            # Read actual version from .version file\n            project_root = Path(self.root).parent\n            version_file = project_root / \".version\"\n            if version_file.exists():\n                actual_version = version_file.read_text().strip()\n\n        # Only add exact version for real releases, not editable installs\n        if actual_version != \"editable\":\n            dependencies.append(f\"anki=={actual_version}\")\n        else:\n            # For editable installs, just add anki without version constraint\n            dependencies.append(\"anki\")\n\n    def _add_aqt_files(self, force_include: Dict[str, str], aqt_root: Path) -> None:\n        \"\"\"Add _aqt files to the build.\"\"\"\n        for path in aqt_root.rglob(\"*\"):\n            if path.is_file() and not self._should_exclude(path):\n                relative_path = path.relative_to(aqt_root)\n                # Place files under _aqt/ in the distribution\n                dist_path = \"_aqt\" / relative_path\n                force_include[str(path)] = str(dist_path)\n\n    def _should_exclude(self, path: Path) -> bool:\n        \"\"\"Check if a file should be excluded from the wheel.\"\"\"\n        # Exclude __pycache__\n        if \"/__pycache__/\" in str(path):\n            return True\n\n        if path.suffix in [\".ui\", \".scss\", \".map\", \".ts\"]:\n            return True\n        if path.name.startswith(\"tsconfig\"):\n            return True\n        return False\n"
  },
  {
    "path": "qt/icons/README.md",
    "content": "Source files used to produce some of the svg/png files.\n"
  },
  {
    "path": "qt/launcher/Cargo.toml",
    "content": "[package]\nname = \"launcher\"\nversion = \"1.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanki_i18n.workspace = true\nanki_io.workspace = true\nanki_process.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\ndirs.workspace = true\nlocale_config.workspace = true\nserde_json.workspace = true\n\n[target.'cfg(all(unix, not(target_os = \"macos\")))'.dependencies]\nlibc.workspace = true\n\n[target.'cfg(windows)'.dependencies]\nwindows.workspace = true\nwidestring.workspace = true\nlibc.workspace = true\nlibc-stdhandle.workspace = true\n\n[[bin]]\nname = \"build_win\"\npath = \"src/bin/build_win.rs\"\n\n[[bin]]\nname = \"anki-console\"\npath = \"src/bin/anki_console.rs\"\n\n[target.'cfg(windows)'.build-dependencies]\nembed-resource.workspace = true\n"
  },
  {
    "path": "qt/launcher/addon/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport contextlib\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\nfrom typing import Any\n\nfrom anki.utils import pointVersion\nfrom aqt import mw\nfrom aqt.qt import QAction\nfrom aqt.utils import askUser, is_mac, is_win, showInfo\n\n\ndef launcher_executable() -> str | None:\n    \"\"\"Return the path to the Anki launcher executable.\"\"\"\n    return os.getenv(\"ANKI_LAUNCHER\")\n\n\ndef uv_binary() -> str | None:\n    \"\"\"Return the path to the uv binary.\"\"\"\n    return os.environ.get(\"ANKI_LAUNCHER_UV\")\n\n\ndef launcher_root() -> str | None:\n    \"\"\"Return the path to the launcher root directory (AnkiProgramFiles).\"\"\"\n    return os.environ.get(\"UV_PROJECT\")\n\n\ndef venv_binary(cmd: str) -> str | None:\n    \"\"\"Return the path to a binary in the launcher's venv.\"\"\"\n    root = launcher_root()\n    if not root:\n        return None\n\n    root_path = Path(root)\n    if is_win:\n        binary_path = root_path / \".venv\" / \"Scripts\" / cmd\n    else:\n        binary_path = root_path / \".venv\" / \"bin\" / cmd\n\n    return str(binary_path)\n\n\ndef add_python_requirements(reqs: list[str]) -> tuple[bool, str]:\n    \"\"\"Add Python requirements to the launcher venv using uv add.\n\n    Returns (success, output)\"\"\"\n\n    binary = uv_binary()\n    if not binary:\n        return (False, \"Not in packaged build.\")\n\n    uv_cmd = [binary, \"add\"] + reqs\n    result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False)\n\n    if result.returncode == 0:\n        root = launcher_root()\n        if root:\n            sync_marker = Path(root) / \".sync_complete\"\n            sync_marker.touch()\n        return (True, result.stdout)\n    else:\n        return (False, result.stderr)\n\n\ndef trigger_launcher_run() -> None:\n    \"\"\"Create a trigger file to request launcher UI on next run.\"\"\"\n    try:\n        root = launcher_root()\n        if not root:\n            return\n\n        trigger_path = Path(root) / \".want-launcher\"\n        trigger_path.touch()\n    except Exception as e:\n        print(e)\n\n\ndef update_and_restart() -> None:\n    \"\"\"Update and restart Anki using the launcher.\"\"\"\n    launcher = launcher_executable()\n    assert launcher\n\n    trigger_launcher_run()\n\n    with contextlib.suppress(ResourceWarning):\n        env = os.environ.copy()\n        env[\"ANKI_LAUNCHER_WANT_TERMINAL\"] = \"1\"\n        creationflags = 0\n        if sys.platform == \"win32\":\n            creationflags = (\n                subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS\n            )\n        # On Windows, changing the handles breaks ANSI display\n        io = None if sys.platform == \"win32\" else subprocess.DEVNULL\n\n        subprocess.Popen(\n            [launcher],\n            start_new_session=True,\n            stdin=io,\n            stdout=io,\n            stderr=io,\n            env=env,\n            creationflags=creationflags,\n        )\n\n    mw.app.quit()\n\n\ndef confirm_then_upgrade():\n    if not askUser(\"Change to a different Anki version?\"):\n        return\n    update_and_restart()\n\n\n# return modified command array that points to bundled command, and return\n# required environment\ndef _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:\n    cmd = cmd[:]\n    env = os.environ.copy()\n    # keep LD_LIBRARY_PATH when in snap environment\n    if \"LD_LIBRARY_PATH\" in env and \"SNAP\" not in env:\n        del env[\"LD_LIBRARY_PATH\"]\n\n    # Try to find binary in anki-audio package for Windows/Mac\n    if is_win or is_mac:\n        try:\n            import anki_audio\n\n            audio_pkg_path = Path(anki_audio.__file__).parent\n            if is_win:\n                packaged_path = audio_pkg_path / (cmd[0] + \".exe\")\n            else:  # is_mac\n                packaged_path = audio_pkg_path / cmd[0]\n\n            if packaged_path.exists():\n                cmd[0] = str(packaged_path)\n                return cmd, env\n        except ImportError:\n            # anki-audio not available, fall back to old behavior\n            pass\n\n    packaged_path = Path(sys.prefix) / cmd[0]\n    if packaged_path.exists():\n        cmd[0] = str(packaged_path)\n\n    return cmd, env\n\n\ndef on_addon_config():\n    showInfo(\n        \"This add-on is automatically added when installing older Anki versions, so that they work with the launcher. You can remove it if you wish.\"\n    )\n\n\ndef setup():\n    mw.addonManager.setConfigAction(__name__, on_addon_config)\n\n    if pointVersion() >= 250600:\n        return\n    if not launcher_executable():\n        return\n\n    # Add action to tools menu\n    action = QAction(\"Upgrade/Downgrade\", mw)\n    action.triggered.connect(confirm_then_upgrade)\n    mw.form.menuTools.addAction(action)\n\n    # Monkey-patch audio tools to use anki-audio\n    if is_win or is_mac:\n        import aqt\n        import aqt.sound\n\n        aqt.sound._packagedCmd = _packagedCmd\n\n    # Inject launcher functions into launcher module\n    import aqt.package\n\n    aqt.package.launcher_executable = launcher_executable\n    aqt.package.update_and_restart = update_and_restart\n    aqt.package.trigger_launcher_run = trigger_launcher_run\n    aqt.package.uv_binary = uv_binary\n    aqt.package.launcher_root = launcher_root\n    aqt.package.venv_binary = venv_binary\n    aqt.package.add_python_requirements = add_python_requirements\n\n\nsetup()\n"
  },
  {
    "path": "qt/launcher/addon/manifest.json",
    "content": "{\n    \"name\": \"Anki Launcher\",\n    \"package\": \"anki-launcher\",\n    \"min_point_version\": 50,\n    \"max_point_version\": 250600\n}\n"
  },
  {
    "path": "qt/launcher/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nfn main() {\n    #[cfg(windows)]\n    {\n        embed_resource::compile(\"win/anki-manifest.rc\", embed_resource::NONE)\n            .manifest_required()\n            .unwrap();\n    }\n    println!(\"cargo:rerun-if-changed=../../out/buildhash\");\n    let buildhash = std::fs::read_to_string(\"../../out/buildhash\").unwrap_or_default();\n    println!(\"cargo:rustc-env=BUILDHASH={buildhash}\");\n}\n"
  },
  {
    "path": "qt/launcher/lin/README.md",
    "content": "# Installing Anki\n\n## Running directly\n\nTo run without installing, change to this folder in a terminal, and run the\nfollowing command:\n\n./anki\n\n## Installing\n\nTo install system wide, run 'sudo ./install.sh'\n\nTo remove in the future, run 'sudo /usr/local/share/anki/uninstall.sh'. You should\ndo this before installing a newer version.\n\n## Audio\n\nTo play and record audio, mpv and lame must be installed. If mpv is not\ninstalled or too old, Anki will try to fall back on using mplayer.\n\n## Problems\n\nIf Anki fails to start, please run it from a terminal to see what errors it\noutputs, and then post on our support site.\n"
  },
  {
    "path": "qt/launcher/lin/anki",
    "content": "#!/bin/bash\n# Universal Anki launcher script\n\n# Get the directory where this script is located (resolve symlinks)\nSCRIPT_DIR=\"$(cd \"$(dirname \"$(readlink -f \"${BASH_SOURCE[0]}\")\")\" && pwd)\"\n\n# Determine architecture\nARCH=$(uname -m)\ncase \"$ARCH\" in\n    x86_64|amd64)\n        LAUNCHER=\"$SCRIPT_DIR/launcher.amd64\"\n        ;;\n    aarch64|arm64)\n        LAUNCHER=\"$SCRIPT_DIR/launcher.arm64\"\n        ;;\n    *)\n        echo \"Error: Unsupported architecture: $ARCH\"\n        echo \"Supported architectures: x86_64, aarch64\"\n        exit 1\n        ;;\nesac\n\n# Check if launcher exists\nif [ ! -f \"$LAUNCHER\" ]; then\n    echo \"Error: Launcher not found: $LAUNCHER\"\n    exit 1\nfi\n\n# Execute the appropriate launcher with all arguments\nexec \"$LAUNCHER\" \"$@\""
  },
  {
    "path": "qt/launcher/lin/anki.1",
    "content": ".\\\"                                      Hey, EMACS: -*- nroff -*-\n.\\\" First parameter, NAME, should be all caps\n.\\\" Second parameter, SECTION, should be 1-8, maybe w/ subsection\n.\\\" other parameters are allowed: see man(7), man(1)\n.TH ANKI 1 \"August 11, 2007\"\n.\\\" Please adjust this date whenever revising the manpage.\n.\\\"\n.\\\" Some roff macros, for reference:\n.\\\" .nh        disable hyphenation\n.\\\" .hy        enable hyphenation\n.\\\" .ad l      left justify\n.\\\" .ad b      justify to both left and right margins\n.\\\" .nf        disable filling\n.\\\" .fi        enable filling\n.\\\" .br        insert line break\n.\\\" .sp <n>    insert n+1 empty lines\n.\\\" for manpage-specific macros, see man(7)\n.SH NAME\nanki \\- flexible, intelligent flashcard program\n.SH DESCRIPTION\n\\fBAnki\\fP is a program designed to help you remember facts (such as words and\nphrases in a foreign language) as easily, quickly and efficiently as possible.\nTo do this, it tracks how well you remember each fact, and uses that\ninformation to optimally schedule review times. With a minimal amount of\neffort, you can greatly increase the amount of material you remember, making\nstudy more productive, and more fun.\n\nAnki is based on a theory called \\fIspaced repetition\\fP. In simple terms, it means\nthat each time you review some material, you should wait longer than last time\nbefore reviewing it again. This maximizes the time spent studying difficult\nmaterial and minimizes the time spent reviewing things you already know. The\nconcept is simple, but the vast majority of memory trainers and flashcard\nprograms out there either avoid the concept all together, or implement\ninflexible and suboptimal methods that were originally designed for pen and\npaper.\n\n.SH OPTIONS\n.B \\-b ~/.anki\nUse ~/.anki instead of ~/Anki as Anki's base folder\n\n.B \\-p ProfileName\nLoad a specific profile\n\n.B \\-l <lang>\nStart the program in a specific language (de=German, en=English, etc)\n.SH SEE ALSO\nAnki home page: <http://ankisrs.net/>\n.SH AUTHOR\nAnki was written by Damien Elmes <anki@ichi2.net>.\n.PP\nThis manual page was written by Nicholas Breen <nbreen@ofb.net>,\nfor the Debian project (but may be used by others), and has been\nupdated for Anki 2 by Damien Elmes.\n"
  },
  {
    "path": "qt/launcher/lin/anki.desktop",
    "content": "[Desktop Entry]\nName=Anki\nComment=An intelligent spaced-repetition memory training program\nGenericName=Flashcards\nExec=anki %f\nTryExec=anki\nIcon=anki\nCategories=Education;Languages;KDE;Qt;\nTerminal=false\nType=Application\nVersion=1.0\nMimeType=application/x-apkg;application/x-anki;application/x-ankiaddon;\n#should be removed eventually as it was upstreamed as to be an XDG specification called SingleMainWindow\nX-GNOME-SingleWindow=true\nSingleMainWindow=true\nStartupWMClass=anki\n"
  },
  {
    "path": "qt/launcher/lin/anki.xml",
    "content": "<?xml version=\"1.0\"?>\n <mime-info xmlns='http://www.freedesktop.org/standards/shared-mime-info'>\n\n   <mime-type type=\"application/x-colpkg\">\n   <comment>Anki 2.1 collection package</comment>\n   <glob pattern=\"*.colpkg\"/>\n   </mime-type>\n\n   <mime-type type=\"application/x-apkg\">  \n   <comment>Anki 2.0 deck package</comment>\n   <glob pattern=\"*.apkg\"/>\n   </mime-type>\n\n   <mime-type type=\"application/x-ankiaddon\">  \n   <comment>Anki 2.1 add-on package</comment>\n   <glob pattern=\"*.ankiaddon\"/>\n   </mime-type>\n\n </mime-info>\n"
  },
  {
    "path": "qt/launcher/lin/anki.xpm",
    "content": "/* XPM */\nstatic char * anki_xpm[] = {\n\"32 32 256 2\",\n\"  \tc None\",\n\". \tc #525252\",\n\"+ \tc #515151\",\n\"@ \tc #505050\",\n\"# \tc #4F4F4F\",\n\"$ \tc #4D4D4D\",\n\"% \tc #4B4B4B\",\n\"& \tc #4A4A4A\",\n\"* \tc #494949\",\n\"= \tc #484848\",\n\"- \tc #474747\",\n\"; \tc #464646\",\n\"> \tc #454545\",\n\", \tc #444444\",\n\"' \tc #424242\",\n\") \tc #404040\",\n\"! \tc #595959\",\n\"~ \tc #5E5E5E\",\n\"{ \tc #707070\",\n\"] \tc #787878\",\n\"^ \tc #7C7C7C\",\n\"/ \tc #7B7B7B\",\n\"( \tc #7A7A7A\",\n\"_ \tc #797979\",\n\": \tc #777777\",\n\"< \tc #767676\",\n\"[ \tc #757575\",\n\"} \tc #747474\",\n\"| \tc #737373\",\n\"1 \tc #727272\",\n\"2 \tc #6D6D6D\",\n\"3 \tc #606060\",\n\"4 \tc #636363\",\n\"5 \tc #828282\",\n\"6 \tc #808080\",\n\"7 \tc #7F7F7F\",\n\"8 \tc #7E7E7E\",\n\"9 \tc #7D7D7D\",\n\"0 \tc #6C6C6C\",\n\"a \tc #616161\",\n\"b \tc #898989\",\n\"c \tc #888888\",\n\"d \tc #868686\",\n\"e \tc #848484\",\n\"f \tc #818181\",\n\"g \tc #989898\",\n\"h \tc #656565\",\n\"i \tc #646464\",\n\"j \tc #8A8A8A\",\n\"k \tc #8E8E8E\",\n\"l \tc #8C8C8C\",\n\"m \tc #858585\",\n\"n \tc #838383\",\n\"o \tc #929292\",\n\"p \tc #A7A7A7\",\n\"q \tc #949494\",\n\"r \tc #C7C7C7\",\n\"s \tc #E8E9E9\",\n\"t \tc #6E6E6E\",\n\"u \tc #696969\",\n\"v \tc #959595\",\n\"w \tc #939393\",\n\"x \tc #919191\",\n\"y \tc #8F8F8F\",\n\"z \tc #999999\",\n\"A \tc #F6FBFE\",\n\"B \tc #DFEFFB\",\n\"C \tc #E6F1F9\",\n\"D \tc #BADEF5\",\n\"E \tc #D4E9F7\",\n\"F \tc #A5A5A5\",\n\"G \tc #575757\",\n\"H \tc #979797\",\n\"I \tc #969696\",\n\"J \tc #8D8D8D\",\n\"K \tc #8B8B8B\",\n\"L \tc #878787\",\n\"M \tc #E5EFF5\",\n\"N \tc #97CDF1\",\n\"O \tc #8DC8EF\",\n\"P \tc #7ABFED\",\n\"Q \tc #D4EAF9\",\n\"R \tc #C6C6C6\",\n\"S \tc #5B5B5B\",\n\"T \tc #9E9E9E\",\n\"U \tc #9C9C9C\",\n\"V \tc #9B9B9B\",\n\"W \tc #E5E7E8\",\n\"X \tc #B4DAF5\",\n\"Y \tc #90C9F0\",\n\"Z \tc #94CBF1\",\n\"` \tc #ABD6F3\",\n\" .\tc #E4F2FB\",\n\"..\tc #D6D7D7\",\n\"+.\tc #5F5F5F\",\n\"@.\tc #A2A2A2\",\n\"#.\tc #A0A0A0\",\n\"$.\tc #9F9F9F\",\n\"%.\tc #9D9D9D\",\n\"&.\tc #9A9A9A\",\n\"*.\tc #B5B5B5\",\n\"=.\tc #E8F3FA\",\n\"-.\tc #AED8F4\",\n\";.\tc #A9D5F3\",\n\">.\tc #ADD7F4\",\n\",.\tc #CDE7F8\",\n\"'.\tc #EAF5FC\",\n\").\tc #E7E7E7\",\n\"!.\tc #626262\",\n\"~.\tc #909090\",\n\"{.\tc #A1A1A1\",\n\"].\tc #D8D8D8\",\n\"^.\tc #EFF2F3\",\n\"/.\tc #ECF1F4\",\n\"(.\tc #E8F3FC\",\n\"_.\tc #F0F0F0\",\n\":.\tc #B6B6B6\",\n\"<.\tc #666666\",\n\"[.\tc #010101\",\n\"}.\tc #686868\",\n\"|.\tc #A9A9A9\",\n\"1.\tc #B0B0B0\",\n\"2.\tc #E9EAEA\",\n\"3.\tc #F7FBFD\",\n\"4.\tc #D7D7D7\",\n\"5.\tc #6A6A6A\",\n\"6.\tc #000000\",\n\"7.\tc #5D5D5D\",\n\"8.\tc #585858\",\n\"9.\tc #A8A8A8\",\n\"0.\tc #E1E1E1\",\n\"a.\tc #ACACAC\",\n\"b.\tc #5A5A5A\",\n\"c.\tc #717171\",\n\"d.\tc #EEF0F1\",\n\"e.\tc #CCCCCC\",\n\"f.\tc #565656\",\n\"g.\tc #676767\",\n\"h.\tc #C9C9C9\",\n\"i.\tc #AAD6F4\",\n\"j.\tc #DBEBF6\",\n\"k.\tc #ADADAD\",\n\"l.\tc #6F6F6F\",\n\"m.\tc #ECF3F7\",\n\"n.\tc #4CA9E7\",\n\"o.\tc #4EAAE7\",\n\"p.\tc #D2E9F9\",\n\"q.\tc #319CE3\",\n\"r.\tc #118CDF\",\n\"s.\tc #E4E4E4\",\n\"t.\tc #C2C2C2\",\n\"u.\tc #C0C0C0\",\n\"v.\tc #C8C8C8\",\n\"w.\tc #EEEFF0\",\n\"x.\tc #9DD0F2\",\n\"y.\tc #2998E2\",\n\"z.\tc #1C91E0\",\n\"A.\tc #92CBF0\",\n\"B.\tc #96CDF1\",\n\"C.\tc #98CEF1\",\n\"D.\tc #99CEF1\",\n\"E.\tc #F0F8FD\",\n\"F.\tc #5C5C5C\",\n\"G.\tc #ECECEC\",\n\"H.\tc #EEF5F9\",\n\"I.\tc #C1E1F7\",\n\"J.\tc #93CBF0\",\n\"K.\tc #58AEE9\",\n\"L.\tc #3BA0E5\",\n\"M.\tc #2F9AE3\",\n\"N.\tc #2596E2\",\n\"O.\tc #1990E0\",\n\"P.\tc #108BDF\",\n\"Q.\tc #0686DD\",\n\"R.\tc #47A6E7\",\n\"S.\tc #E9EFF3\",\n\"T.\tc #171717\",\n\"U.\tc #DBEDFA\",\n\"V.\tc #70BAEB\",\n\"W.\tc #67B6EA\",\n\"X.\tc #5BB0E8\",\n\"Y.\tc #52ABE7\",\n\"Z.\tc #45A5E6\",\n\"`.\tc #3CA1E5\",\n\" +\tc #309BE3\",\n\".+\tc #2796E2\",\n\"++\tc #50ABE8\",\n\"@+\tc #DCEDF9\",\n\"#+\tc #A5A6A6\",\n\"$+\tc #4C4C4C\",\n\"%+\tc #0F0F0F\",\n\"&+\tc #ECEDEE\",\n\"*+\tc #E1F1FB\",\n\"=+\tc #94CBF0\",\n\"-+\tc #7ABEED\",\n\";+\tc #6EB9EB\",\n\">+\tc #64B4EA\",\n\",+\tc #58AEE8\",\n\"'+\tc #4FAAE7\",\n\")+\tc #43A4E5\",\n\"!+\tc #3FA2E5\",\n\"~+\tc #CBE6F8\",\n\"{+\tc #D0D0D0\",\n\"]+\tc #101010\",\n\"^+\tc #F1F6FA\",\n\"/+\tc #B7DCF5\",\n\"(+\tc #84C4EE\",\n\"_+\tc #7BBFED\",\n\":+\tc #6FB9EB\",\n\"<+\tc #66B5EA\",\n\"[+\tc #5AAFE8\",\n\"}+\tc #5BAFE8\",\n\"|+\tc #F1F5F7\",\n\"1+\tc #6B6B6B\",\n\"2+\tc #D1D1D1\",\n\"3+\tc #E2F1FB\",\n\"4+\tc #8EC8F0\",\n\"5+\tc #82C2EE\",\n\"6+\tc #78BEED\",\n\"7+\tc #6CB8EB\",\n\"8+\tc #63B3EA\",\n\"9+\tc #D5EBF9\",\n\"0+\tc #B9B9B9\",\n\"a+\tc #545454\",\n\"b+\tc #111111\",\n\"c+\tc #C5C5C5\",\n\"d+\tc #E7F4FC\",\n\"e+\tc #A5D3F3\",\n\"f+\tc #AAD5F4\",\n\"g+\tc #ACD7F4\",\n\"h+\tc #8FC9F0\",\n\"i+\tc #CACACA\",\n\"j+\tc #ECF6FC\",\n\"k+\tc #C2E1F6\",\n\"l+\tc #CBE5F7\",\n\"m+\tc #F0F7FD\",\n\"n+\tc #F9FCFE\",\n\"o+\tc #C7E4F7\",\n\"p+\tc #B1D9F4\",\n\"q+\tc #F1F8FC\",\n\"r+\tc #121212\",\n\"s+\tc #CFCFCF\",\n\"t+\tc #F5FAFD\",\n\"u+\tc #EFF7FC\",\n\"v+\tc #F3F3F4\",\n\"w+\tc #F1F1F1\",\n\"x+\tc #0D0D0D\",\n\"y+\tc #BFBFBF\",\n\"z+\tc #FDFEFE\",\n\"A+\tc #EBEBEB\",\n\"B+\tc #AEAEAE\",\n\"C+\tc #040404\",\n\"D+\tc #1B1B1B\",\n\"E+\tc #A3A3A3\",\n\"F+\tc #0E0E0E\",\n\"G+\tc #020202\",\n\"                                                                \",\n\"                . + @ # $ $ % & * = - ; > , ' ' )               \",\n\"            ! ~ { ] ^ / ( _ _ ] : < [ } | | 1 2 3 $ '           \",\n\"            4 / 5 6 7 8 9 ^ / ( ( _ ] : < [ } } | 0 %           \",\n\"          a ^ b c d e 5 f 6 7 8 9 ^ / ( _ 9 g f < [ h &         \",\n\"          i j k l j c d m n 5 f 6 o p q j r s g _ ] t +         \",\n\"          u v w x y k l j b d m n z A B C D E F ^ / } G         \",\n\"          0 z H I q o x y J K b L j M N O P Q R 6 8 < S         \",\n\"          { T U V z H I q o x y J y W X Y Z `  ...o ( +.        \",\n\"          } @.#.$.%.U &.g H v w x *.=.-.;.>.,.'.).T 9 !.        \",\n\"          @ ~.o g T {.$.%.U &.g %.].^./.(.Q _.:.K L 6 <.        \",\n\"          [.+.!.}.2 ] c T #.T U %.|.1.1.2.3.4.o J K e 5.        \",\n\"          6.3 ~ 7.S ! 8.S t L w T T %.V 9.0.a.q w x b t         \",\n\"          6.4 !.3 +.7.S b.c.! a { e U $.%.9.V g H v J 1         \",\n\"          6.<.h 4 !.3 +.~.d.e.0 G f.! } w T $.%.U &.o <         \",\n\"          6.5.}.g.h 4 !.h.i.j.k.b.! G f.3 [ &.@.#.$.I (         \",\n\"          6.2 0 5.u g.l.m.n.o.=.m 7.b.! G f.! 1 w {.V 8         \",\n\"          6.{ l.2 0 5.z p.q.r.Z s.t.u.u.a.l.G f.~ : V 5         \",\n\"          6.} [ J T v.w.x.y.z.z.A.B.C.D.E.*.S b.8.G G F.        \",\n\"          6./ 1.G.H.I.J.K.L.M.N.O.P.Q.R.S.~.~ 7.S b.* T.        \",\n\"          6.d ].U.O V.W.X.Y.Z.`. +.+++@+#+h !.3 ~ 7.$+%+        \",\n\"          6.8 &.&+*+=+-+;+>+,+'+)+!+~+{+2 g.h i !.3 # ]+        \",\n\"          6.f 6 K v.^+/+(+_+:+<+[+}+|+z 1+5.}.g.h i . ]+        \",\n\"          6.e n f m 2+3+N 4+5+6+7+8+9+0+l.2 1+5.}.g.a+b+        \",\n\"          6.c L m e c+d+-.e+f+g+h+_+g+2.} c.l.t 0 1+8.b+        \",\n\"          6.K j c L i+j+k+l+m+n+ .o+p+q+b } 1 c.l.t b.r+        \",\n\"          6.7 J l j s+t+u+v+0+~.*.4._.w+L ] < } | c.G x+        \",\n\"          6.a x y J y+z+A+B+d e 5 L V V 8 / _ ] < [ & C+        \",\n\"            D+[ o x H E+y K b c d e n f 6 8 ^ / _ g.F+          \",\n\"            G+D+4 n o x y k l K b c d m n 5 7 | $ D+6.          \",\n\"                6.6.6.6.6.6.6.6.6.6.6.6.6.6.6.6.6.              \",\n\"                                                                \"};\n"
  },
  {
    "path": "qt/launcher/lin/build.sh",
    "content": "#!/bin/bash\n#\n# This script currently only supports universal builds on x86_64.\n#\n\nset -e\n\n# Add Linux cross-compilation target\nrustup target add aarch64-unknown-linux-gnu\n# Detect host architecture\nHOST_ARCH=$(uname -m)\n\n\n# Define output paths\nOUTPUT_DIR=\"../../../out/launcher\"\nANKI_VERSION=$(cat ../../../.version | tr -d '\\n')\nLAUNCHER_DIR=\"$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux\"\n\n# Clean existing output directory\nrm -rf \"$LAUNCHER_DIR\"\n\n# Build binaries based on host architecture\nif [ \"$HOST_ARCH\" = \"aarch64\" ]; then\n    # On aarch64 host, only build for aarch64\n    cargo build -p launcher --release --target aarch64-unknown-linux-gnu\nelse\n    # On other hosts, build for both architectures\n    cargo build -p launcher --release --target x86_64-unknown-linux-gnu\n    CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n        cargo build -p launcher --release --target aarch64-unknown-linux-gnu\n    # Extract uv_lin_arm for cross-compilation\n    (cd ../../.. && ./ninja extract:uv_lin_arm)\nfi\n\n# Create output directory\nmkdir -p \"$LAUNCHER_DIR\"\n\n# Copy binaries and support files\nTARGET_DIR=${CARGO_TARGET_DIR:-../../../target}\n\n# Copy binaries with architecture suffixes\nif [ \"$HOST_ARCH\" = \"aarch64\" ]; then\n    # On aarch64 host, copy arm64 binary to both locations\n    cp \"$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher\" \"$LAUNCHER_DIR/launcher.amd64\"\n    cp \"$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher\" \"$LAUNCHER_DIR/launcher.arm64\"\n    # Copy uv binary to both locations\n    cp \"../../../out/extracted/uv/uv\" \"$LAUNCHER_DIR/uv.amd64\"\n    cp \"../../../out/extracted/uv/uv\" \"$LAUNCHER_DIR/uv.arm64\"\nelse\n    # On other hosts, copy architecture-specific binaries\n    cp \"$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher\" \"$LAUNCHER_DIR/launcher.amd64\"\n    cp \"$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher\" \"$LAUNCHER_DIR/launcher.arm64\"\n    cp \"../../../out/extracted/uv/uv\" \"$LAUNCHER_DIR/uv.amd64\"\n    cp \"../../../out/extracted/uv_lin_arm/uv\" \"$LAUNCHER_DIR/uv.arm64\"\nfi\n\n# Copy support files from lin directory\nfor file in README.md anki.1 anki.desktop anki.png anki.xml anki.xpm install.sh uninstall.sh anki; do\n    cp \"$file\" \"$LAUNCHER_DIR/\"\ndone\n\n# Copy additional files from parent directory\ncp ../pyproject.toml \"$LAUNCHER_DIR/\"\ncp ../../../.python-version \"$LAUNCHER_DIR/\"\ncp ../versions.py \"$LAUNCHER_DIR/\"\n\n# Set executable permissions\nchmod +x \\\n    \"$LAUNCHER_DIR/anki\" \\\n    \"$LAUNCHER_DIR/launcher.amd64\" \\\n    \"$LAUNCHER_DIR/launcher.arm64\" \\\n    \"$LAUNCHER_DIR/uv.amd64\" \\\n    \"$LAUNCHER_DIR/uv.arm64\" \\\n    \"$LAUNCHER_DIR/install.sh\" \\\n    \"$LAUNCHER_DIR/uninstall.sh\"\n\n# Set proper permissions and create tarball\nchmod -R a+r \"$LAUNCHER_DIR\"\n\nZSTD=\"zstd -c --long -T0 -18\"\nTRANSFORM=\"s%^.%anki-launcher-$ANKI_VERSION-linux%S\"\nTARBALL=\"$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux.tar.zst\"\n\ntar -I \"$ZSTD\" --transform \"$TRANSFORM\" -cf \"$TARBALL\" -C \"$LAUNCHER_DIR\" .\n\necho \"Build complete:\"\necho \"Universal launcher: $LAUNCHER_DIR\"\necho \"Tarball: $TARBALL\"\n"
  },
  {
    "path": "qt/launcher/lin/install.sh",
    "content": "#!/bin/bash\n\nset -e\n\nif [ \"$(dirname \"$(realpath \"$0\")\")\" != \"$(realpath \"$PWD\")\" ]; then\n  echo \"Please run from the folder install.sh is in.\"\n  exit 1\nfi\n\nif [ \"$PREFIX\" = \"\" ]; then\n\tPREFIX=/usr/local\nfi\n\nrm -rf \"$PREFIX\"/share/anki \"$PREFIX\"/bin/anki\nmkdir -p \"$PREFIX\"/share/anki\ncp -av --no-preserve=owner,context -- * .python-version \"$PREFIX\"/share/anki/\nmkdir -p \"$PREFIX\"/bin\nln -sf \"$PREFIX\"/share/anki/anki \"$PREFIX\"/bin/anki\n# fix a previous packaging issue where we created this as a file\n(test -f \"$PREFIX\"/share/applications && rm \"$PREFIX\"/share/applications)||true\nmkdir -p \"$PREFIX\"/share/pixmaps\nmkdir -p \"$PREFIX\"/share/applications\nmkdir -p \"$PREFIX\"/share/man/man1\ncd \"$PREFIX\"/share/anki && (\\\nmv -Z anki.xpm anki.png \"$PREFIX\"/share/pixmaps/;\\\nmv -Z anki.desktop \"$PREFIX\"/share/applications/;\\\nmv -Z anki.1 \"$PREFIX\"/share/man/man1/)\n\nxdg-mime install anki.xml --novendor\nxdg-mime default anki.desktop application/x-colpkg\nxdg-mime default anki.desktop application/x-apkg\nxdg-mime default anki.desktop application/x-ankiaddon\n\nrm install.sh\n\necho \"Install complete. Type 'anki' to run.\"\n"
  },
  {
    "path": "qt/launcher/lin/uninstall.sh",
    "content": "#!/bin/bash\n\nset -e\n\nif [ \"$PREFIX\" = \"\" ]; then\n\tPREFIX=/usr/local\nfi\n\necho \"Uninstalling Anki...\"\nxdg-mime uninstall \"$PREFIX\"/share/anki/anki.xml || true\n\nrm -rf \"$PREFIX\"/share/anki\nrm -rf \"$PREFIX\"/bin/anki\nrm -rf \"$PREFIX\"/share/pixmaps/anki.xpm\nrm -rf \"$PREFIX\"/share/pixmaps/anki.png\nrm -rf \"$PREFIX\"/share/applications/anki.desktop\nrm -rf \"$PREFIX\"/share/man/man1/anki.1\n\necho \"Uninstall complete.\"\n"
  },
  {
    "path": "qt/launcher/mac/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n\t<dict>\n\t\t<key>CFBundleDisplayName</key>\n\t\t<string>Anki</string>\n\t\t<key>CFBundleShortVersionString</key>\n\t\t<string>ANKI_VERSION</string>\n\t\t<key>LSMinimumSystemVersion</key>\n\t\t<string>12</string>\n\t\t<key>LSApplicationCategoryType</key>\n\t\t<string>public.app-category.education</string>\n\t\t<key>CFBundleDocumentTypes</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleTypeExtensions</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>colpkg</string>\n\t\t\t\t\t<string>apkg</string>\n\t\t\t\t\t<string>ankiaddon</string>\n\t\t\t\t</array>\n\t\t\t\t<key>CFBundleTypeIconName</key>\n\t\t\t\t<string>AppIcon</string>\n\t\t\t\t<key>CFBundleTypeName</key>\n\t\t\t\t<string>Anki File</string>\n\t\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t\t<string>Editor</string>\n\t\t\t</dict>\n\t\t</array>\n\t\t<key>CFBundleExecutable</key>\n\t\t<string>launcher</string>\n\t\t<key>CFBundleIconName</key>\n\t\t<string>AppIcon</string>\n\t\t<key>CFBundleIdentifier</key>\n\t\t<string>net.ankiweb.launcher</string>\n\t\t<key>CFBundleInfoDictionaryVersion</key>\n\t\t<string>6.0</string>\n\t\t<key>CFBundleName</key>\n\t\t<string>Anki</string>\n\t\t<key>CFBundlePackageType</key>\n\t\t<string>APPL</string>\n\t\t<key>NSHighResolutionCapable</key>\n\t\t<true/>\n\t\t<key>NSMicrophoneUsageDescription</key>\n\t\t<string>The microphone will only be used when you tap the record button.</string>\n\t\t<key>NSCameraUsageDescription</key>\n\t\t<string>Add-ons may access your camera.</string>\t\t\n\t\t<key>NSRequiresAquaSystemAppearance</key>\n\t\t<true/>\n\t\t<key>NSSupportsAutomaticGraphicsSwitching</key>\n\t\t<true/>\n\t</dict>\n</plist>\n"
  },
  {
    "path": "qt/launcher/mac/build.sh",
    "content": "#!/bin/bash\n\nset -e\n\n# Define output path\nOUTPUT_DIR=\"../../../out/launcher\"\nAPP_LAUNCHER=\"$OUTPUT_DIR/Anki.app\"\nrm -rf \"$APP_LAUNCHER\"\n\n# Build binaries for both architectures\nrustup target add aarch64-apple-darwin x86_64-apple-darwin\ncargo build -p launcher --release --target aarch64-apple-darwin\ncargo build -p launcher --release --target x86_64-apple-darwin\n(cd ../../.. && ./ninja launcher:uv_universal)\n\n# Ensure output directory exists\nmkdir -p \"$OUTPUT_DIR\"\n\n# Remove existing app launcher\nrm -rf \"$APP_LAUNCHER\"\n\n# Create app launcher structure\nmkdir -p \"$APP_LAUNCHER/Contents/MacOS\" \"$APP_LAUNCHER/Contents/Resources\"\n\n# Copy binaries in\nTARGET_DIR=${CARGO_TARGET_DIR:-target}\nlipo -create \\\n    \"$TARGET_DIR/aarch64-apple-darwin/release/launcher\" \\\n    \"$TARGET_DIR/x86_64-apple-darwin/release/launcher\" \\\n    -output \"$APP_LAUNCHER/Contents/MacOS/launcher\"\ncp \"$OUTPUT_DIR/uv\" \"$APP_LAUNCHER/Contents/MacOS/\"\n\n# Build install_name_tool stub\nclang -arch arm64 -o \"$OUTPUT_DIR/stub_arm64\" stub.c\nclang -arch x86_64 -o \"$OUTPUT_DIR/stub_x86_64\" stub.c\nlipo -create \"$OUTPUT_DIR/stub_arm64\" \"$OUTPUT_DIR/stub_x86_64\" -output \"$APP_LAUNCHER/Contents/MacOS/install_name_tool\"\nrm \"$OUTPUT_DIR/stub_arm64\" \"$OUTPUT_DIR/stub_x86_64\"\n\n# Copy support files\nANKI_VERSION=$(cat ../../../.version | tr -d '\\n')\nsed \"s/ANKI_VERSION/$ANKI_VERSION/g\" Info.plist > \"$APP_LAUNCHER/Contents/Info.plist\"\ncp icon/Assets.car \"$APP_LAUNCHER/Contents/Resources/\"\ncp ../pyproject.toml \"$APP_LAUNCHER/Contents/Resources/\"\ncp ../../../.python-version \"$APP_LAUNCHER/Contents/Resources/\"\ncp ../versions.py \"$APP_LAUNCHER/Contents/Resources/\"\n\n# Codesign/bundle\nif [ -z \"$NODMG\" ]; then\n    for i in \"$APP_LAUNCHER/Contents/MacOS/uv\" \"$APP_LAUNCHER/Contents/MacOS/install_name_tool\" \"$APP_LAUNCHER/Contents/MacOS/launcher\" \"$APP_LAUNCHER\"; do\n        codesign --force -vvvv -o runtime -s \"Developer ID Application:\" \\\n        --entitlements entitlements.python.xml \\\n        \"$i\"\n    done\n\n    # Check\n    codesign -vvv \"$APP_LAUNCHER\"\n    spctl -a \"$APP_LAUNCHER\"\n\n    # Notarize and build dmg\n    ./notarize.sh \"$OUTPUT_DIR\"\n    ./dmg/build.sh \"$OUTPUT_DIR\"\nfi"
  },
  {
    "path": "qt/launcher/mac/dmg/build.sh",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nset -e\n\n# base folder with Anki.app in it\noutput=\"$1\"\ndist=\"$1/tmp\"\nANKI_VERSION=$(cat ../../../.version | tr -d '\\n')\ndmg_path=\"$output/anki-launcher-$ANKI_VERSION-mac.dmg\"\n\nif [ -d \"/Volumes/Anki\" ]\nthen\n    echo \"You already have one Anki mounted, unmount it first!\"\n    exit 1\nfi\n\nrm -rf $dist $dmg_path\nmkdir -p $dist\nrsync -av $output/Anki.app $dist/\nscript_folder=$(dirname $0)\n\necho \"bundling...\"\nln -s /Applications $dist/Applications\nmkdir -p $dist/.background\ncp ${script_folder}/anki-logo-bg.png $dist/.background\ncp ${script_folder}/dmg_ds_store $dist/.DS_Store\n\n# create a writable dmg first, and modify its layout with AppleScript\nhdiutil create -attach -ov -format UDRW -fs HFS+ -volname Anki -srcfolder $dist -o /tmp/Anki-rw.dmg\n# announce before making the window appear \nsay \"applescript\"\nopen /tmp/Anki-rw.dmg\nsleep 2\nopen ${script_folder}/set-dmg-settings.app\nsleep 2\nhdiutil detach \"/Volumes/Anki\" || (sleep 3; hdiutil detach /Volumes/Anki)\nsleep 1\nif [ -d \"/Volumes/Anki\" ]\nthen\n    echo \"drive did not detach\"\n    exit 1\nfi\n\n# convert it to a read-only image\nrm -rf $dmg_path\nhdiutil convert /tmp/Anki-rw.dmg -ov -format ULFO -o $dmg_path\nrm -rf /tmp/Anki-rw.dmg\n"
  },
  {
    "path": "qt/launcher/mac/dmg/set-dmg-settings.app/Contents/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleAllowMixedLocalizations</key>\n\t<true/>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>English</string>\n\t<key>CFBundleExecutable</key>\n\t<string>applet</string>\n\t<key>CFBundleIconFile</key>\n\t<string>applet</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>com.apple.ScriptEditor.id.set-dmg-settings</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>set-dmg-settings</string>\n\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n\t<key>CFBundleSignature</key>\n\t<string>aplt</string>\n\t<key>LSMinimumSystemVersionByArchitecture</key>\n\t<dict>\n\t\t<key>x86_64</key>\n\t\t<string>10.6</string>\n\t</dict>\n\t<key>LSRequiresCarbon</key>\n\t<true/>\n\t<key>NSAppleEventsUsageDescription</key>\n\t<string>This script needs to control other applications to run.</string>\n\t<key>NSAppleMusicUsageDescription</key>\n\t<string>This script needs access to your music to run.</string>\n\t<key>NSCalendarsUsageDescription</key>\n\t<string>This script needs access to your calendars to run.</string>\n\t<key>NSCameraUsageDescription</key>\n\t<string>This script needs access to your camera to run.</string>\n\t<key>NSContactsUsageDescription</key>\n\t<string>This script needs access to your contacts to run.</string>\n\t<key>NSHomeKitUsageDescription</key>\n\t<string>This script needs access to your HomeKit Home to run.</string>\n\t<key>NSMicrophoneUsageDescription</key>\n\t<string>This script needs access to your microphone to run.</string>\n\t<key>NSPhotoLibraryUsageDescription</key>\n\t<string>This script needs access to your photos to run.</string>\n\t<key>NSRemindersUsageDescription</key>\n\t<string>This script needs access to your reminders to run.</string>\n\t<key>NSSiriUsageDescription</key>\n\t<string>This script needs access to Siri to run.</string>\n\t<key>NSSystemAdministrationUsageDescription</key>\n\t<string>This script needs access to administer this system to run.</string>\n\t<key>WindowState</key>\n\t<dict>\n\t\t<key>bundleDividerCollapsed</key>\n\t\t<true/>\n\t\t<key>bundlePositionOfDivider</key>\n\t\t<real>0.0</real>\n\t\t<key>dividerCollapsed</key>\n\t\t<false/>\n\t\t<key>eventLogLevel</key>\n\t\t<integer>2</integer>\n\t\t<key>name</key>\n\t\t<string>ScriptWindowState</string>\n\t\t<key>positionOfDivider</key>\n\t\t<real>388</real>\n\t\t<key>savedFrame</key>\n\t\t<string>1308 314 700 672 0 0 2880 1597 </string>\n\t\t<key>selectedTab</key>\n\t\t<string>result</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "qt/launcher/mac/dmg/set-dmg-settings.app/Contents/PkgInfo",
    "content": "APPLaplt"
  },
  {
    "path": "qt/launcher/mac/dmg/set-dmg-settings.app/Contents/Resources/description.rtfd/TXT.rtf",
    "content": "{\\rtf1\\ansi\\ansicpg1252\\cocoartf1671\n{\\fonttbl}\n{\\colortbl;\\red255\\green255\\blue255;}\n{\\*\\expandedcolortbl;;}\n}"
  },
  {
    "path": "qt/launcher/mac/dmg/set-dmg-settings.app/Contents/_CodeSignature/CodeResources",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>files</key>\n\t<dict>\n\t\t<key>Resources/Scripts/main.scpt</key>\n\t\t<data>\n\t\tBbcHsL7M8GleNWeDVHOZVEfpSUQ=\n\t\t</data>\n\t\t<key>Resources/applet.icns</key>\n\t\t<data>\n\t\tsINd6lbiqHD5dL8c6u79cFvVXhw=\n\t\t</data>\n\t\t<key>Resources/applet.rsrc</key>\n\t\t<data>\n\t\t7JOq2AjTwoRdSRoaun87Me8EbB4=\n\t\t</data>\n\t\t<key>Resources/description.rtfd/TXT.rtf</key>\n\t\t<data>\n\t\tHZLGvORC/avx2snxaACit3D0IJY=\n\t\t</data>\n\t</dict>\n\t<key>files2</key>\n\t<dict>\n\t\t<key>Resources/Scripts/main.scpt</key>\n\t\t<dict>\n\t\t\t<key>hash</key>\n\t\t\t<data>\n\t\t\tBbcHsL7M8GleNWeDVHOZVEfpSUQ=\n\t\t\t</data>\n\t\t\t<key>hash2</key>\n\t\t\t<data>\n\t\t\tT6pvOxUGXyc+qwn+hdv1xPzvnYM+qo9uxLLWUkIFq3Q=\n\t\t\t</data>\n\t\t</dict>\n\t\t<key>Resources/applet.icns</key>\n\t\t<dict>\n\t\t\t<key>hash</key>\n\t\t\t<data>\n\t\t\tsINd6lbiqHD5dL8c6u79cFvVXhw=\n\t\t\t</data>\n\t\t\t<key>hash2</key>\n\t\t\t<data>\n\t\t\tJ7weZ6vlnv9r32tS5HFcyuPXl2StdDnfepLxAixlryk=\n\t\t\t</data>\n\t\t</dict>\n\t\t<key>Resources/applet.rsrc</key>\n\t\t<dict>\n\t\t\t<key>hash</key>\n\t\t\t<data>\n\t\t\t7JOq2AjTwoRdSRoaun87Me8EbB4=\n\t\t\t</data>\n\t\t\t<key>hash2</key>\n\t\t\t<data>\n\t\t\tWvL2TvNeKuY64Sp86Cyvcmiood5xzbJmcAH3R0+gIc8=\n\t\t\t</data>\n\t\t</dict>\n\t\t<key>Resources/description.rtfd/TXT.rtf</key>\n\t\t<dict>\n\t\t\t<key>hash</key>\n\t\t\t<data>\n\t\t\tHZLGvORC/avx2snxaACit3D0IJY=\n\t\t\t</data>\n\t\t\t<key>hash2</key>\n\t\t\t<data>\n\t\t\tXuDTd2OPOPGq65NBuXy6WuqU+bODdg+oDmBFhsZTaVU=\n\t\t\t</data>\n\t\t</dict>\n\t</dict>\n\t<key>rules</key>\n\t<dict>\n\t\t<key>^Resources/</key>\n\t\t<true/>\n\t\t<key>^Resources/.*\\.lproj/</key>\n\t\t<dict>\n\t\t\t<key>optional</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>1000</real>\n\t\t</dict>\n\t\t<key>^Resources/.*\\.lproj/locversion.plist$</key>\n\t\t<dict>\n\t\t\t<key>omit</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>1100</real>\n\t\t</dict>\n\t\t<key>^Resources/Base\\.lproj/</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>1010</real>\n\t\t</dict>\n\t\t<key>^version.plist$</key>\n\t\t<true/>\n\t</dict>\n\t<key>rules2</key>\n\t<dict>\n\t\t<key>.*\\.dSYM($|/)</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>11</real>\n\t\t</dict>\n\t\t<key>^(.*/)?\\.DS_Store$</key>\n\t\t<dict>\n\t\t\t<key>omit</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>2000</real>\n\t\t</dict>\n\t\t<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>\n\t\t<dict>\n\t\t\t<key>nested</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>10</real>\n\t\t</dict>\n\t\t<key>^.*</key>\n\t\t<true/>\n\t\t<key>^Info\\.plist$</key>\n\t\t<dict>\n\t\t\t<key>omit</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>20</real>\n\t\t</dict>\n\t\t<key>^PkgInfo$</key>\n\t\t<dict>\n\t\t\t<key>omit</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>20</real>\n\t\t</dict>\n\t\t<key>^Resources/</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>20</real>\n\t\t</dict>\n\t\t<key>^Resources/.*\\.lproj/</key>\n\t\t<dict>\n\t\t\t<key>optional</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>1000</real>\n\t\t</dict>\n\t\t<key>^Resources/.*\\.lproj/locversion.plist$</key>\n\t\t<dict>\n\t\t\t<key>omit</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>1100</real>\n\t\t</dict>\n\t\t<key>^Resources/Base\\.lproj/</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>1010</real>\n\t\t</dict>\n\t\t<key>^[^/]+$</key>\n\t\t<dict>\n\t\t\t<key>nested</key>\n\t\t\t<true/>\n\t\t\t<key>weight</key>\n\t\t\t<real>10</real>\n\t\t</dict>\n\t\t<key>^embedded\\.provisionprofile$</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>20</real>\n\t\t</dict>\n\t\t<key>^version\\.plist$</key>\n\t\t<dict>\n\t\t\t<key>weight</key>\n\t\t\t<real>20</real>\n\t\t</dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "qt/launcher/mac/entitlements.python.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n        <key>com.apple.security.cs.disable-executable-page-protection</key>\n        <true/>\n        <key>com.apple.security.device.audio-input</key>\n        <true/>\n        <key>com.apple.security.device.camera</key>\n        <true/>        \n        <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n        <true/>\n        <key>com.apple.security.cs.disable-library-validation</key>\n        <true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "qt/launcher/mac/icon/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n    \"images\": [\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"1x\",\n            \"size\": \"16x16\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"2x\",\n            \"size\": \"16x16\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"1x\",\n            \"size\": \"32x32\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"2x\",\n            \"size\": \"32x32\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"1x\",\n            \"size\": \"128x128\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"2x\",\n            \"size\": \"128x128\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"1x\",\n            \"size\": \"256x256\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"2x\",\n            \"size\": \"256x256\"\n        },\n        {\n            \"filename\": \"round-1024-512.png\",\n            \"idiom\": \"mac\",\n            \"scale\": \"1x\",\n            \"size\": \"512x512\"\n        },\n        {\n            \"idiom\": \"mac\",\n            \"scale\": \"2x\",\n            \"size\": \"512x512\"\n        }\n    ],\n    \"info\": {\n        \"author\": \"xcode\",\n        \"version\": 1\n    }\n}\n"
  },
  {
    "path": "qt/launcher/mac/icon/Assets.xcassets/Contents.json",
    "content": "{\n    \"info\": {\n        \"author\": \"xcode\",\n        \"version\": 1\n    }\n}\n"
  },
  {
    "path": "qt/launcher/mac/icon/build.sh",
    "content": "#!/bin/bash\n\nset -e\n\nxcrun actool --app-icon AppIcon $(pwd)/Assets.xcassets --compile . --platform macosx --minimum-deployment-target 13.0 --target-device mac --output-partial-info-plist /dev/null\n"
  },
  {
    "path": "qt/launcher/mac/notarize.sh",
    "content": "#!/bin/bash\n\nset -e\n\n# Define output path\nOUTPUT_DIR=\"$1\"\nAPP_LAUNCHER=\"$OUTPUT_DIR/Anki.app\"\nZIP_FILE=\"$OUTPUT_DIR/Anki.zip\"\n\n# Create zip for notarization\n(cd \"$OUTPUT_DIR\" && rm -rf Anki.zip && zip -r Anki.zip Anki.app)\n\n# Upload for notarization\nxcrun notarytool submit \"$ZIP_FILE\" -p default --wait\n\n# Staple the app\nxcrun stapler staple \"$APP_LAUNCHER\""
  },
  {
    "path": "qt/launcher/mac/stub.c",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nint main(void) {\n    return 0;\n}"
  },
  {
    "path": "qt/launcher/pyproject.toml",
    "content": "[project]\nname = \"anki-launcher\"\nversion = \"1.0.0\"\ndescription = \"UV-based launcher for Anki.\"\nrequires-python = \">=3.9\"\ndependencies = [\n  \"anki-release\",\n]\n"
  },
  {
    "path": "qt/launcher/src/bin/anki_console.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![windows_subsystem = \"console\"]\n\nuse std::env;\nuse std::io::stdin;\nuse std::process::Command;\n\nuse anyhow::Context;\nuse anyhow::Result;\n\nfn main() {\n    if let Err(e) = run() {\n        eprintln!(\"Error: {e:#}\");\n        std::process::exit(1);\n    }\n}\n\nfn run() -> Result<()> {\n    let current_exe = env::current_exe().context(\"Failed to get current executable path\")?;\n    let exe_dir = current_exe\n        .parent()\n        .context(\"Failed to get executable directory\")?;\n\n    let anki_exe = exe_dir.join(\"anki.exe\");\n\n    if !anki_exe.exists() {\n        anyhow::bail!(\"anki.exe not found in the same directory\");\n    }\n\n    // Forward all command line arguments to anki.exe\n    let args: Vec<String> = env::args().skip(1).collect();\n\n    let mut cmd = Command::new(&anki_exe);\n    cmd.args(&args);\n\n    if std::env::var(\"ANKI_IMPLICIT_CONSOLE\").is_err() {\n        // if directly invoked by the user, signal the launcher that the\n        // user wants a Python console\n        std::env::set_var(\"ANKI_CONSOLE\", \"1\");\n    }\n\n    // Wait for the process to complete and forward its exit code\n    let status = cmd.status().context(\"Failed to execute anki.exe\")?;\n    if !status.success() {\n        println!(\"\\nPress enter to close.\");\n        let mut input = String::new();\n        let _ = stdin().read_line(&mut input);\n    }\n\n    if let Some(code) = status.code() {\n        std::process::exit(code);\n    } else {\n        // Process was terminated by a signal\n        std::process::exit(1);\n    }\n}\n"
  },
  {
    "path": "qt/launcher/src/bin/build_win.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::path::Path;\nuse std::path::PathBuf;\nuse std::process::Command;\n\nuse anki_io::copy_file;\nuse anki_io::create_dir_all;\nuse anki_io::remove_dir_all;\nuse anki_io::write_file;\nuse anki_process::CommandExt;\nuse anyhow::Result;\n\nconst OUTPUT_DIR: &str = \"../../../out/launcher\";\nconst LAUNCHER_EXE_DIR: &str = \"../../../out/launcher_exe\";\nconst NSIS_DIR: &str = \"../../../out/nsis\";\nconst CARGO_TARGET_DIR: &str = \"../../../out/rust\";\nconst NSIS_PATH: &str = \"C:\\\\Program Files (x86)\\\\NSIS\\\\makensis.exe\";\n\nfn main() -> Result<()> {\n    println!(\"Building Windows launcher...\");\n\n    // Read version early so it can be used throughout the build process\n    let version = std::fs::read_to_string(\"../../../.version\")?\n        .trim()\n        .to_string();\n\n    let output_dir = PathBuf::from(OUTPUT_DIR);\n    let launcher_exe_dir = PathBuf::from(LAUNCHER_EXE_DIR);\n    let nsis_dir = PathBuf::from(NSIS_DIR);\n\n    setup_directories(&output_dir, &launcher_exe_dir, &nsis_dir)?;\n    build_launcher_binary()?;\n    extract_nsis_plugins()?;\n    copy_files(&output_dir)?;\n    sign_binaries(&output_dir)?;\n    copy_nsis_files(&nsis_dir, &version)?;\n    build_uninstaller(&output_dir, &nsis_dir)?;\n    sign_file(&output_dir.join(\"uninstall.exe\"))?;\n    generate_install_manifest(&output_dir)?;\n    build_installer(&output_dir, &nsis_dir)?;\n\n    let installer_filename = format!(\"anki-launcher-{version}-windows.exe\");\n    let installer_path = PathBuf::from(\"../../../out/launcher_exe\").join(&installer_filename);\n\n    sign_file(&installer_path)?;\n\n    println!(\"Build completed successfully!\");\n    println!(\"Output directory: {}\", output_dir.display());\n    println!(\"Installer: ../../../out/launcher_exe/{installer_filename}\");\n\n    Ok(())\n}\n\nfn setup_directories(output_dir: &Path, launcher_exe_dir: &Path, nsis_dir: &Path) -> Result<()> {\n    println!(\"Setting up directories...\");\n\n    // Remove existing output directories\n    if output_dir.exists() {\n        remove_dir_all(output_dir)?;\n    }\n    if launcher_exe_dir.exists() {\n        remove_dir_all(launcher_exe_dir)?;\n    }\n    if nsis_dir.exists() {\n        remove_dir_all(nsis_dir)?;\n    }\n\n    // Create output directories\n    create_dir_all(output_dir)?;\n    create_dir_all(launcher_exe_dir)?;\n    create_dir_all(nsis_dir)?;\n\n    Ok(())\n}\n\nfn build_launcher_binary() -> Result<()> {\n    println!(\"Building launcher binary...\");\n\n    env::set_var(\"CARGO_TARGET_DIR\", CARGO_TARGET_DIR);\n\n    Command::new(\"cargo\")\n        .args([\n            \"build\",\n            \"-p\",\n            \"launcher\",\n            \"--release\",\n            \"--target\",\n            \"x86_64-pc-windows-msvc\",\n        ])\n        .ensure_success()?;\n\n    Ok(())\n}\n\nfn extract_nsis_plugins() -> Result<()> {\n    println!(\"Extracting NSIS plugins...\");\n\n    // Change to the anki root directory and run tools/ninja.bat\n    Command::new(\"cmd\")\n        .args([\n            \"/c\",\n            \"cd\",\n            \"/d\",\n            \"..\\\\..\\\\..\\\\\",\n            \"&&\",\n            \"tools\\\\ninja.bat\",\n            \"extract:nsis_plugins\",\n        ])\n        .ensure_success()?;\n\n    Ok(())\n}\n\nfn copy_files(output_dir: &Path) -> Result<()> {\n    println!(\"Copying binaries...\");\n\n    // Copy launcher binary as anki.exe\n    let launcher_src =\n        PathBuf::from(CARGO_TARGET_DIR).join(\"x86_64-pc-windows-msvc/release/launcher.exe\");\n    let launcher_dst = output_dir.join(\"anki.exe\");\n    copy_file(&launcher_src, &launcher_dst)?;\n\n    // Copy anki-console binary\n    let console_src =\n        PathBuf::from(CARGO_TARGET_DIR).join(\"x86_64-pc-windows-msvc/release/anki-console.exe\");\n    let console_dst = output_dir.join(\"anki-console.exe\");\n    copy_file(&console_src, &console_dst)?;\n\n    // Copy uv.exe and uvw.exe\n    let uv_src = PathBuf::from(\"../../../out/extracted/uv/uv.exe\");\n    let uv_dst = output_dir.join(\"uv.exe\");\n    copy_file(&uv_src, &uv_dst)?;\n    let uv_src = PathBuf::from(\"../../../out/extracted/uv/uvw.exe\");\n    let uv_dst = output_dir.join(\"uvw.exe\");\n    copy_file(&uv_src, &uv_dst)?;\n\n    println!(\"Copying support files...\");\n\n    // Copy pyproject.toml\n    copy_file(\"../pyproject.toml\", output_dir.join(\"pyproject.toml\"))?;\n\n    // Copy .python-version\n    copy_file(\n        \"../../../.python-version\",\n        output_dir.join(\".python-version\"),\n    )?;\n\n    // Copy versions.py\n    copy_file(\"../versions.py\", output_dir.join(\"versions.py\"))?;\n\n    Ok(())\n}\n\nfn sign_binaries(output_dir: &Path) -> Result<()> {\n    sign_file(&output_dir.join(\"anki.exe\"))?;\n    sign_file(&output_dir.join(\"anki-console.exe\"))?;\n    sign_file(&output_dir.join(\"uv.exe\"))?;\n    Ok(())\n}\n\nfn sign_file(file_path: &Path) -> Result<()> {\n    let codesign = env::var(\"CODESIGN\").unwrap_or_default();\n    if codesign != \"1\" {\n        println!(\n            \"Skipping code signing for {} (CODESIGN not set to 1)\",\n            file_path.display()\n        );\n        return Ok(());\n    }\n\n    let signtool_path = find_signtool()?;\n    println!(\"Signing {}...\", file_path.display());\n\n    Command::new(&signtool_path)\n        .args([\n            \"sign\",\n            \"/sha1\",\n            \"dccfc6d312fc0432197bb7be951478e5866eebf8\",\n            \"/fd\",\n            \"sha256\",\n            \"/tr\",\n            \"http://time.certum.pl\",\n            \"/td\",\n            \"sha256\",\n            \"/v\",\n        ])\n        .arg(file_path)\n        .ensure_success()?;\n\n    Ok(())\n}\n\nfn find_signtool() -> Result<PathBuf> {\n    println!(\"Locating signtool.exe...\");\n\n    let output = Command::new(\"where\")\n        .args([\n            \"/r\",\n            \"C:\\\\Program Files (x86)\\\\Windows Kits\",\n            \"signtool.exe\",\n        ])\n        .utf8_output()?;\n\n    // Find signtool.exe with \"arm64\" in the path (as per original batch logic)\n    for line in output.stdout.lines() {\n        if line.contains(\"\\\\arm64\\\\\") {\n            let signtool_path = PathBuf::from(line.trim());\n            println!(\"Using signtool: {}\", signtool_path.display());\n            return Ok(signtool_path);\n        }\n    }\n\n    anyhow::bail!(\"Could not find signtool.exe with arm64 architecture\");\n}\n\nfn generate_install_manifest(output_dir: &Path) -> Result<()> {\n    println!(\"Generating install manifest...\");\n\n    let mut manifest_content = String::new();\n    let entries = anki_io::read_dir_files(output_dir)?;\n\n    for entry in entries {\n        let entry = entry?;\n        let path = entry.path();\n        if let Some(file_name) = path.file_name() {\n            let file_name_str = file_name.to_string_lossy();\n            // Skip manifest file and uninstaller (can't delete itself)\n            if file_name_str != \"anki.install-manifest\" && file_name_str != \"uninstall.exe\" {\n                if let Ok(relative_path) = path.strip_prefix(output_dir) {\n                    // Convert to Windows-style backslashes for NSIS\n                    let windows_path = relative_path.display().to_string().replace('/', \"\\\\\");\n                    // Use Windows line endings (\\r\\n) as expected by NSIS\n                    manifest_content.push_str(&format!(\"{windows_path}\\r\\n\"));\n                }\n            }\n        }\n    }\n\n    write_file(output_dir.join(\"anki.install-manifest\"), manifest_content)?;\n\n    Ok(())\n}\n\nfn copy_nsis_files(nsis_dir: &Path, version: &str) -> Result<()> {\n    println!(\"Copying NSIS support files...\");\n\n    // Copy anki.template.nsi as anki.nsi and substitute version placeholders\n    let template_content = std::fs::read_to_string(\"anki.template.nsi\")?;\n    let substituted_content = template_content.replace(\"ANKI_VERSION\", version);\n    write_file(nsis_dir.join(\"anki.nsi\"), substituted_content)?;\n\n    // Copy fileassoc.nsh\n    copy_file(\"fileassoc.nsh\", nsis_dir.join(\"fileassoc.nsh\"))?;\n\n    // Copy nsProcess.dll\n    copy_file(\n        \"../../../out/extracted/nsis_plugins/nsProcess.dll\",\n        nsis_dir.join(\"nsProcess.dll\"),\n    )?;\n\n    Ok(())\n}\n\nfn build_uninstaller(output_dir: &Path, nsis_dir: &Path) -> Result<()> {\n    println!(\"Building uninstaller...\");\n\n    let mut flags = vec![\"-V3\", \"-DWRITE_UNINSTALLER\"];\n    if env::var(\"NO_COMPRESS\").unwrap_or_default() == \"1\" {\n        println!(\"NO_COMPRESS=1 detected, disabling compression\");\n        flags.push(\"-DNO_COMPRESS\");\n    }\n\n    run_nsis(\n        &PathBuf::from(\"anki.nsi\"),\n        &flags,\n        nsis_dir, // Run from nsis directory\n    )?;\n\n    // Copy uninstaller from nsis directory to output directory\n    copy_file(\n        nsis_dir.join(\"uninstall.exe\"),\n        output_dir.join(\"uninstall.exe\"),\n    )?;\n\n    Ok(())\n}\n\nfn build_installer(_output_dir: &Path, nsis_dir: &Path) -> Result<()> {\n    println!(\"Building installer...\");\n\n    let mut flags = vec![\"-V3\"];\n    if env::var(\"NO_COMPRESS\").unwrap_or_default() == \"1\" {\n        println!(\"NO_COMPRESS=1 detected, disabling compression\");\n        flags.push(\"-DNO_COMPRESS\");\n    }\n\n    run_nsis(\n        &PathBuf::from(\"anki.nsi\"),\n        &flags,\n        nsis_dir, // Run from nsis directory\n    )?;\n\n    Ok(())\n}\n\nfn run_nsis(script_path: &Path, flags: &[&str], working_dir: &Path) -> Result<()> {\n    let mut cmd = Command::new(NSIS_PATH);\n    cmd.args(flags).arg(script_path).current_dir(working_dir);\n\n    cmd.ensure_success()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "qt/launcher/src/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![windows_subsystem = \"windows\"]\n\nuse std::io::stdin;\nuse std::io::stdout;\nuse std::io::Write;\nuse std::process::Command;\nuse std::time::SystemTime;\nuse std::time::UNIX_EPOCH;\n\nuse anki_i18n::I18n;\nuse anki_io::copy_file;\nuse anki_io::create_dir_all;\nuse anki_io::modified_time;\nuse anki_io::read_file;\nuse anki_io::remove_file;\nuse anki_io::write_file;\nuse anki_io::ToUtf8Path;\nuse anki_process::CommandExt as AnkiCommandExt;\nuse anyhow::Context;\nuse anyhow::Result;\n\nuse crate::platform::ensure_os_supported;\nuse crate::platform::ensure_terminal_shown;\nuse crate::platform::get_exe_and_resources_dirs;\nuse crate::platform::get_uv_binary_name;\nuse crate::platform::launch_anki_normally;\nuse crate::platform::respawn_launcher;\n\nmod platform;\n\nstruct State {\n    tr: I18n<anki_i18n::Launcher>,\n    current_version: Option<String>,\n    prerelease_marker: std::path::PathBuf,\n    uv_install_root: std::path::PathBuf,\n    uv_cache_dir: std::path::PathBuf,\n    no_cache_marker: std::path::PathBuf,\n    anki_base_folder: std::path::PathBuf,\n    uv_path: std::path::PathBuf,\n    uv_python_install_dir: std::path::PathBuf,\n    user_pyproject_path: std::path::PathBuf,\n    user_python_version_path: std::path::PathBuf,\n    dist_pyproject_path: std::path::PathBuf,\n    dist_python_version_path: std::path::PathBuf,\n    uv_lock_path: std::path::PathBuf,\n    sync_complete_marker: std::path::PathBuf,\n    launcher_trigger_file: std::path::PathBuf,\n    mirror_path: std::path::PathBuf,\n    pyproject_modified_by_user: bool,\n    previous_version: Option<String>,\n    resources_dir: std::path::PathBuf,\n    venv_folder: std::path::PathBuf,\n    /// system Python + PyQt6 library mode\n    system_qt: bool,\n}\n\n#[derive(Debug, Clone)]\npub enum VersionKind {\n    PyOxidizer(String),\n    Uv(String),\n}\n\n#[derive(Debug)]\npub struct Releases {\n    pub latest: Vec<String>,\n    pub all: Vec<String>,\n}\n\n#[derive(Debug, Clone)]\npub enum MainMenuChoice {\n    Latest,\n    KeepExisting,\n    Version(VersionKind),\n    ToggleBetas,\n    ToggleCache,\n    DownloadMirror,\n    Uninstall,\n}\n\nfn main() {\n    if let Err(e) = run() {\n        eprintln!(\"Error: {e:#}\");\n        eprintln!(\"Press enter to close...\");\n        let mut input = String::new();\n        let _ = stdin().read_line(&mut input);\n\n        std::process::exit(1);\n    }\n}\n\nfn run() -> Result<()> {\n    let uv_install_root = if let Ok(custom_root) = std::env::var(\"ANKI_LAUNCHER_VENV_ROOT\") {\n        std::path::PathBuf::from(custom_root)\n    } else {\n        dirs::data_local_dir()\n            .context(\"Unable to determine data_dir\")?\n            .join(\"AnkiProgramFiles\")\n    };\n\n    let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;\n\n    let locale = locale_config::Locale::user_default().to_string();\n\n    let mut state = State {\n        tr: I18n::new(&[if !locale.is_empty() {\n            locale\n        } else {\n            \"en\".to_owned()\n        }]),\n        current_version: None,\n        prerelease_marker: uv_install_root.join(\"prerelease\"),\n        uv_install_root: uv_install_root.clone(),\n        uv_cache_dir: uv_install_root.join(\"cache\"),\n        no_cache_marker: uv_install_root.join(\"nocache\"),\n        anki_base_folder: get_anki_base_path()?,\n        uv_path: exe_dir.join(get_uv_binary_name()),\n        uv_python_install_dir: uv_install_root.join(\"python\"),\n        user_pyproject_path: uv_install_root.join(\"pyproject.toml\"),\n        user_python_version_path: uv_install_root.join(\".python-version\"),\n        dist_pyproject_path: resources_dir.join(\"pyproject.toml\"),\n        dist_python_version_path: resources_dir.join(\".python-version\"),\n        uv_lock_path: uv_install_root.join(\"uv.lock\"),\n        sync_complete_marker: uv_install_root.join(\".sync_complete\"),\n        launcher_trigger_file: uv_install_root.join(\".want-launcher\"),\n        mirror_path: uv_install_root.join(\"mirror\"),\n        pyproject_modified_by_user: false, // calculated later\n        previous_version: None,\n        system_qt: (cfg!(unix) && !cfg!(target_os = \"macos\"))\n            && resources_dir.join(\"system_qt\").exists(),\n        resources_dir,\n        venv_folder: uv_install_root.join(\".venv\"),\n    };\n\n    // Check for uninstall request from Windows uninstaller\n    if std::env::var(\"ANKI_LAUNCHER_UNINSTALL\").is_ok() {\n        ensure_terminal_shown()?;\n        handle_uninstall(&state)?;\n        return Ok(());\n    }\n\n    // Create install directory\n    create_dir_all(&state.uv_install_root)?;\n\n    let launcher_requested =\n        state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists();\n\n    // Calculate whether user has custom edits that need syncing\n    let pyproject_time = file_timestamp_secs(&state.user_pyproject_path);\n    let sync_time = file_timestamp_secs(&state.sync_complete_marker);\n    state.pyproject_modified_by_user = pyproject_time > sync_time;\n    let pyproject_has_changed = state.pyproject_modified_by_user;\n    let different_launcher = diff_launcher_was_installed(&state)?;\n\n    if !launcher_requested && !pyproject_has_changed && !different_launcher {\n        // If no launcher request and venv is already up to date, launch Anki normally\n        let args: Vec<String> = std::env::args().skip(1).collect();\n        let cmd = build_python_command(&state, &args)?;\n        launch_anki_normally(cmd)?;\n        return Ok(());\n    }\n\n    // If we weren't in a terminal, respawn ourselves in one\n    ensure_terminal_shown()?;\n\n    if launcher_requested {\n        // Remove the trigger file to make request ephemeral\n        let _ = remove_file(&state.launcher_trigger_file);\n    }\n\n    print!(\"\\x1B[2J\\x1B[H\"); // Clear screen and move cursor to top\n    println!(\"\\x1B[1m{}\\x1B[0m\\n\", state.tr.launcher_title());\n\n    ensure_os_supported()?;\n\n    println!(\"{}\\n\", state.tr.launcher_press_enter_to_install());\n\n    check_versions(&mut state);\n\n    main_menu_loop(&state)?;\n\n    // Write marker file to indicate we've completed the sync process\n    write_sync_marker(&state)?;\n\n    #[cfg(target_os = \"macos\")]\n    {\n        let cmd = build_python_command(&state, &[])?;\n        platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?;\n    }\n\n    if cfg!(unix) && !cfg!(target_os = \"macos\") {\n        println!(\"\\n{}\", state.tr.launcher_press_enter_to_start());\n        let mut input = String::new();\n        let _ = stdin().read_line(&mut input);\n    } else {\n        // on Windows/macOS, the user needs to close the terminal/console\n        // currently, but ideas on how we can avoid this would be good!\n        println!();\n        println!(\"{}\", state.tr.launcher_anki_will_start_shortly());\n        println!(\n            \"\\x1B[1m{}\\x1B[0m\\n\",\n            state.tr.launcher_you_can_close_this_window()\n        );\n    }\n\n    // respawn the launcher as a disconnected subprocess for normal startup\n    respawn_launcher()?;\n\n    Ok(())\n}\n\nfn extract_aqt_version(state: &State) -> Option<String> {\n    // Check if .venv exists first\n    if !state.venv_folder.exists() {\n        return None;\n    }\n\n    let output = uv_command(state)\n        .ok()?\n        .env(\"VIRTUAL_ENV\", &state.venv_folder)\n        .args([\"pip\", \"show\", \"aqt\"])\n        .output()\n        .ok()?;\n\n    if !output.status.success() {\n        return None;\n    }\n\n    let stdout = String::from_utf8(output.stdout).ok()?;\n    for line in stdout.lines() {\n        if let Some(version) = line.strip_prefix(\"Version: \") {\n            return Some(version.trim().to_string());\n        }\n    }\n    None\n}\n\nfn check_versions(state: &mut State) {\n    // If sync_complete_marker is missing, do nothing\n    if !state.sync_complete_marker.exists() {\n        return;\n    }\n\n    // Determine current version by invoking uv pip show aqt\n    match extract_aqt_version(state) {\n        Some(version) => {\n            state.current_version = Some(version);\n        }\n        None => {\n            println!(\"Warning: Could not determine current Anki version\");\n        }\n    }\n\n    // Read previous version from \"previous-version\" file\n    let previous_version_path = state.uv_install_root.join(\"previous-version\");\n    if let Ok(content) = read_file(&previous_version_path) {\n        if let Ok(version_str) = String::from_utf8(content) {\n            let version = version_str.trim().to_string();\n            if !version.is_empty() {\n                state.previous_version = Some(version);\n            }\n        }\n    }\n}\n\nfn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Result<()> {\n    update_pyproject_for_version(choice.clone(), state)?;\n\n    // Extract current version before syncing (but don't write to file yet)\n    let previous_version_to_save = extract_aqt_version(state);\n\n    // Remove sync marker before attempting sync\n    let _ = remove_file(&state.sync_complete_marker);\n\n    println!(\"{}\\n\", state.tr.launcher_updating_anki());\n\n    let python_version_trimmed = if state.user_python_version_path.exists() {\n        let python_version = read_file(&state.user_python_version_path)?;\n        let python_version_str =\n            String::from_utf8(python_version).context(\"Invalid UTF-8 in .python-version\")?;\n        Some(python_version_str.trim().to_string())\n    } else {\n        None\n    };\n\n    // Prepare to sync the venv\n    let mut command = uv_command(state)?;\n\n    if cfg!(target_os = \"macos\") {\n        // remove CONDA_PREFIX/bin from PATH to avoid conda interference\n        if let Ok(conda_prefix) = std::env::var(\"CONDA_PREFIX\") {\n            if let Ok(current_path) = std::env::var(\"PATH\") {\n                let conda_bin = format!(\"{conda_prefix}/bin\");\n                let filtered_paths: Vec<&str> = current_path\n                    .split(':')\n                    .filter(|&path| path != conda_bin)\n                    .collect();\n                let new_path = filtered_paths.join(\":\");\n                command.env(\"PATH\", new_path);\n            }\n        }\n        // put our fake install_name_tool at the top of the path to override\n        // potential conflicts\n        if let Ok(current_path) = std::env::var(\"PATH\") {\n            let exe_dir = std::env::current_exe()\n                .ok()\n                .and_then(|exe| exe.parent().map(|p| p.to_path_buf()));\n            if let Some(exe_dir) = exe_dir {\n                let new_path = format!(\"{}:{}\", exe_dir.display(), current_path);\n                command.env(\"PATH\", new_path);\n            }\n        }\n    }\n\n    // Create venv with system site packages if system Qt is enabled\n    if state.system_qt {\n        let mut venv_command = uv_command(state)?;\n        venv_command.args([\n            \"venv\",\n            \"--no-managed-python\",\n            \"--system-site-packages\",\n            \"--no-config\",\n        ]);\n        venv_command.ensure_success()?;\n    }\n\n    command\n        .env(\"UV_PYTHON_INSTALL_DIR\", &state.uv_python_install_dir)\n        .env(\n            \"UV_HTTP_TIMEOUT\",\n            std::env::var(\"UV_HTTP_TIMEOUT\").unwrap_or_else(|_| \"180\".to_string()),\n        );\n\n    command.args([\"sync\", \"--upgrade\", \"--no-config\"]);\n    if !state.system_qt {\n        command.arg(\"--managed-python\");\n    }\n\n    // Add python version if .python-version file exists (but not for system Qt)\n    if let Some(version) = &python_version_trimmed {\n        if !state.system_qt {\n            command.args([\"--python\", version]);\n        }\n    }\n\n    match command.ensure_success() {\n        Ok(_) => {\n            // Sync succeeded\n            if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) {\n                inject_helper_addon()?;\n            }\n\n            // Now that sync succeeded, save the previous version\n            if let Some(current_version) = previous_version_to_save {\n                let previous_version_path = state.uv_install_root.join(\"previous-version\");\n                if let Err(e) = write_file(&previous_version_path, &current_version) {\n                    println!(\"Warning: Could not save previous version: {e}\");\n                }\n            }\n\n            Ok(())\n        }\n        Err(e) => {\n            // If sync fails due to things like a missing wheel on pypi,\n            // we need to remove the lockfile or uv will cache the bad result.\n            let _ = remove_file(&state.uv_lock_path);\n            println!(\"Install failed: {e:#}\");\n            println!();\n            Err(e.into())\n        }\n    }\n}\n\nfn main_menu_loop(state: &State) -> Result<()> {\n    loop {\n        let menu_choice = get_main_menu_choice(state)?;\n\n        match menu_choice {\n            MainMenuChoice::KeepExisting => {\n                if state.pyproject_modified_by_user {\n                    // User has custom edits, sync them\n                    handle_version_install_or_update(state, MainMenuChoice::KeepExisting)?;\n                }\n                break;\n            }\n            MainMenuChoice::ToggleBetas => {\n                // Toggle beta prerelease file\n                if state.prerelease_marker.exists() {\n                    let _ = remove_file(&state.prerelease_marker);\n                    println!(\"{}\", state.tr.launcher_beta_releases_disabled());\n                } else {\n                    write_file(&state.prerelease_marker, \"\")?;\n                    println!(\"{}\", state.tr.launcher_beta_releases_enabled());\n                }\n                println!();\n                continue;\n            }\n            MainMenuChoice::ToggleCache => {\n                // Toggle cache disable file\n                if state.no_cache_marker.exists() {\n                    let _ = remove_file(&state.no_cache_marker);\n                    println!(\"{}\", state.tr.launcher_download_caching_enabled());\n                } else {\n                    write_file(&state.no_cache_marker, \"\")?;\n                    // Delete the cache directory and everything in it\n                    if state.uv_cache_dir.exists() {\n                        let _ = anki_io::remove_dir_all(&state.uv_cache_dir);\n                    }\n                    println!(\"{}\", state.tr.launcher_download_caching_disabled());\n                }\n                println!();\n                continue;\n            }\n            MainMenuChoice::DownloadMirror => {\n                show_mirror_submenu(state)?;\n                println!();\n                continue;\n            }\n            MainMenuChoice::Uninstall => {\n                if handle_uninstall(state)? {\n                    std::process::exit(0);\n                }\n                continue;\n            }\n            choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {\n                handle_version_install_or_update(state, choice.clone())?;\n                break;\n            }\n        }\n    }\n    Ok(())\n}\n\nfn write_sync_marker(state: &State) -> Result<()> {\n    let timestamp = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .context(\"Failed to get system time\")?\n        .as_secs();\n    write_file(&state.sync_complete_marker, timestamp.to_string())?;\n    Ok(())\n}\n\n/// Get mtime of provided file, or 0 if unavailable\nfn file_timestamp_secs(path: &std::path::Path) -> i64 {\n    modified_time(path)\n        .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64)\n        .unwrap_or_default()\n}\n\nfn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {\n    loop {\n        println!(\"1) {}\", state.tr.launcher_latest_anki());\n        println!(\"2) {}\", state.tr.launcher_choose_a_version());\n\n        if let Some(current_version) = &state.current_version {\n            let normalized_current = normalize_version(current_version);\n\n            if state.pyproject_modified_by_user {\n                println!(\"3) {}\", state.tr.launcher_sync_project_changes());\n            } else {\n                println!(\n                    \"3) {}\",\n                    state.tr.launcher_keep_existing_version(normalized_current)\n                );\n            }\n        }\n\n        if let Some(prev_version) = &state.previous_version {\n            if state.current_version.as_ref() != Some(prev_version) {\n                let normalized_prev = normalize_version(prev_version);\n                println!(\n                    \"4) {}\",\n                    state.tr.launcher_revert_to_previous(normalized_prev)\n                );\n            }\n        }\n        println!();\n\n        let betas_enabled = state.prerelease_marker.exists();\n        println!(\n            \"5) {}\",\n            state.tr.launcher_allow_betas(if betas_enabled {\n                state.tr.launcher_on()\n            } else {\n                state.tr.launcher_off()\n            })\n        );\n        let cache_enabled = !state.no_cache_marker.exists();\n        println!(\n            \"6) {}\",\n            state.tr.launcher_cache_downloads(if cache_enabled {\n                state.tr.launcher_on()\n            } else {\n                state.tr.launcher_off()\n            })\n        );\n        let mirror_enabled = is_mirror_enabled(state);\n        println!(\n            \"7) {}\",\n            state.tr.launcher_download_mirror(if mirror_enabled {\n                state.tr.launcher_on()\n            } else {\n                state.tr.launcher_off()\n            })\n        );\n        println!();\n        println!(\"8) {}\", state.tr.launcher_uninstall());\n        print!(\"> \");\n        let _ = stdout().flush();\n\n        let mut input = String::new();\n        let _ = stdin().read_line(&mut input);\n        let input = input.trim();\n\n        println!();\n\n        return Ok(match input {\n            \"\" | \"1\" => MainMenuChoice::Latest,\n            \"2\" => {\n                match get_version_kind(state)? {\n                    Some(version_kind) => MainMenuChoice::Version(version_kind),\n                    None => continue, // Return to main menu\n                }\n            }\n            \"3\" => {\n                if state.current_version.is_some() {\n                    MainMenuChoice::KeepExisting\n                } else {\n                    println!(\"{}\\n\", state.tr.launcher_invalid_input());\n                    continue;\n                }\n            }\n            \"4\" => {\n                if let Some(prev_version) = &state.previous_version {\n                    if state.current_version.as_ref() != Some(prev_version) {\n                        if let Some(version_kind) = parse_version_kind(prev_version) {\n                            return Ok(MainMenuChoice::Version(version_kind));\n                        }\n                    }\n                }\n                println!(\"{}\\n\", state.tr.launcher_invalid_input());\n                continue;\n            }\n            \"5\" => MainMenuChoice::ToggleBetas,\n            \"6\" => MainMenuChoice::ToggleCache,\n            \"7\" => MainMenuChoice::DownloadMirror,\n            \"8\" => MainMenuChoice::Uninstall,\n            _ => {\n                println!(\"{}\\n\", state.tr.launcher_invalid_input());\n                continue;\n            }\n        });\n    }\n}\n\nfn get_version_kind(state: &State) -> Result<Option<VersionKind>> {\n    let releases = get_releases(state)?;\n    let releases_str = releases\n        .latest\n        .iter()\n        .map(|v| v.as_str())\n        .collect::<Vec<_>>()\n        .join(\", \");\n    println!(\"{}\", state.tr.launcher_latest_releases(releases_str));\n\n    println!(\"{}\", state.tr.launcher_enter_the_version_you_want());\n    print!(\"> \");\n    let _ = stdout().flush();\n\n    let mut input = String::new();\n    let _ = stdin().read_line(&mut input);\n    let input = input.trim();\n\n    if input.is_empty() {\n        return Ok(None);\n    }\n\n    // Normalize the input version for comparison\n    let normalized_input = normalize_version(input);\n\n    // Check if the version exists in the available versions\n    let version_exists = releases.all.iter().any(|v| v == &normalized_input);\n\n    match (parse_version_kind(input), version_exists) {\n        (Some(version_kind), true) => {\n            println!();\n            Ok(Some(version_kind))\n        }\n        (None, true) => {\n            println!(\"{}\", state.tr.launcher_versions_before_cant_be_installed());\n            Ok(None)\n        }\n        _ => {\n            println!(\"{}\\n\", state.tr.launcher_invalid_version());\n            Ok(None)\n        }\n    }\n}\n\nfn with_only_latest_patch(versions: &[String]) -> Vec<String> {\n    // Assumes versions are sorted in descending order (newest first)\n    // Only show the latest patch release for a given (major, minor),\n    // and exclude pre-releases if a newer major_minor exists\n    let mut seen_major_minor = std::collections::HashSet::new();\n    versions\n        .iter()\n        .filter(|v| {\n            let (major, minor, _, is_prerelease) = parse_version_for_filtering(v);\n            if major == 2 {\n                return true;\n            }\n            let major_minor = (major, minor);\n            if seen_major_minor.contains(&major_minor) {\n                false\n            } else if is_prerelease\n                && seen_major_minor\n                    .iter()\n                    .any(|&(seen_major, seen_minor)| (seen_major, seen_minor) > (major, minor))\n            {\n                // Exclude pre-release if a newer major_minor exists\n                false\n            } else {\n                seen_major_minor.insert(major_minor);\n                true\n            }\n        })\n        .cloned()\n        .collect()\n}\n\nfn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) {\n    // Remove any build metadata after +\n    let version_str = version_str.split('+').next().unwrap_or(version_str);\n\n    // Check for prerelease markers\n    let is_prerelease = [\"a\", \"b\", \"rc\", \"alpha\", \"beta\"]\n        .iter()\n        .any(|marker| version_str.to_lowercase().contains(marker));\n\n    // Extract numeric parts (stop at first non-digit/non-dot character)\n    let numeric_end = version_str\n        .find(|c: char| !c.is_ascii_digit() && c != '.')\n        .unwrap_or(version_str.len());\n    let numeric_part = &version_str[..numeric_end];\n\n    let parts: Vec<&str> = numeric_part.split('.').collect();\n\n    let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);\n    let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);\n    let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);\n\n    (major, minor, patch, is_prerelease)\n}\n\nfn normalize_version(version: &str) -> String {\n    let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version);\n\n    if major <= 2 {\n        // Don't transform versions <= 2.x\n        return version.to_string();\n    }\n\n    // For versions > 2, pad the minor version with leading zero if < 10\n    let normalized_minor = if minor < 10 {\n        format!(\"0{minor}\")\n    } else {\n        minor.to_string()\n    };\n\n    // Find any prerelease suffix\n    let mut prerelease_suffix = \"\";\n\n    // Look for prerelease markers after the numeric part\n    let numeric_end = version\n        .find(|c: char| !c.is_ascii_digit() && c != '.')\n        .unwrap_or(version.len());\n    if numeric_end < version.len() {\n        let suffix_part = &version[numeric_end..];\n        let suffix_lower = suffix_part.to_lowercase();\n\n        for marker in [\"alpha\", \"beta\", \"rc\", \"a\", \"b\"] {\n            if suffix_lower.starts_with(marker) {\n                prerelease_suffix = &version[numeric_end..];\n                break;\n            }\n        }\n    }\n\n    // Reconstruct the version\n    if version.matches('.').count() >= 2 {\n        format!(\"{major}.{normalized_minor}.{patch}{prerelease_suffix}\")\n    } else {\n        format!(\"{major}.{normalized_minor}{prerelease_suffix}\")\n    }\n}\n\nfn filter_and_normalize_versions(\n    all_versions: Vec<String>,\n    include_prereleases: bool,\n) -> Vec<String> {\n    let mut valid_versions: Vec<String> = all_versions\n        .into_iter()\n        .map(|v| normalize_version(&v))\n        .collect();\n\n    // Reverse to get chronological order (newest first)\n    valid_versions.reverse();\n\n    if !include_prereleases {\n        valid_versions.retain(|v| {\n            let (_, _, _, is_prerelease) = parse_version_for_filtering(v);\n            !is_prerelease\n        });\n    }\n\n    valid_versions\n}\n\nfn fetch_versions(state: &State) -> Result<Vec<String>> {\n    let versions_script = state.resources_dir.join(\"versions.py\");\n\n    let mut cmd = uv_command(state)?;\n    cmd.args([\"run\", \"--no-project\", \"--no-config\", \"--managed-python\"])\n        .args([\"--with\", \"pip-system-certs,requests[socks]\"]);\n\n    let python_version = read_file(&state.dist_python_version_path)?;\n    let python_version_str =\n        String::from_utf8(python_version).context(\"Invalid UTF-8 in .python-version\")?;\n    let version_trimmed = python_version_str.trim();\n    if !version_trimmed.is_empty() {\n        cmd.args([\"--python\", version_trimmed]);\n    }\n\n    cmd.arg(&versions_script);\n\n    let output = match cmd.utf8_output() {\n        Ok(output) => output,\n        Err(e) => {\n            print!(\"{}\\n\\n\", state.tr.launcher_unable_to_check_for_versions());\n            return Err(e.into());\n        }\n    };\n    let versions = serde_json::from_str(&output.stdout).context(\"Failed to parse versions JSON\")?;\n    Ok(versions)\n}\n\nfn get_releases(state: &State) -> Result<Releases> {\n    println!(\"{}\", state.tr.launcher_checking_for_updates());\n    let include_prereleases = state.prerelease_marker.exists();\n    let all_versions = fetch_versions(state)?;\n    let all_versions = filter_and_normalize_versions(all_versions, include_prereleases);\n\n    let latest_patches = with_only_latest_patch(&all_versions);\n    let latest_releases: Vec<String> = latest_patches.into_iter().take(5).collect();\n    Ok(Releases {\n        latest: latest_releases,\n        all: all_versions,\n    })\n}\n\nfn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> {\n    let content = read_file(&state.dist_pyproject_path)?;\n    let content_str = String::from_utf8(content).context(\"Invalid UTF-8 in pyproject.toml\")?;\n    let updated_content = match version_kind {\n        VersionKind::PyOxidizer(version) => {\n            // Replace package name and add PyQt6 dependencies\n            content_str.replace(\n                \"anki-release\",\n                &format!(\n                    concat!(\n                        \"aqt[qt6]=={}\\\",\\n\",\n                        \"  \\\"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\\\",\\n\",\n                        \"  \\\"pyqt6==6.6.1\\\",\\n\",\n                        \"  \\\"pyqt6-qt6==6.6.2\\\",\\n\",\n                        \"  \\\"pyqt6-webengine==6.6.0\\\",\\n\",\n                        \"  \\\"pyqt6-webengine-qt6==6.6.2\\\",\\n\",\n                        \"  \\\"pyqt6_sip==13.6.0\"\n                    ),\n                    version\n                ),\n            )\n        }\n        VersionKind::Uv(version) => content_str.replace(\n            \"anki-release\",\n            &format!(\"anki-release=={version}\\\",\\n  \\\"anki=={version}\\\",\\n  \\\"aqt=={version}\"),\n        ),\n    };\n\n    let final_content = if state.system_qt {\n        format!(\n            concat!(\n                \"{}\\n\\n[tool.uv]\\n\",\n                \"override-dependencies = [\\n\",\n                \"  \\\"pyqt6; sys_platform=='never'\\\",\\n\",\n                \"  \\\"pyqt6-qt6; sys_platform=='never'\\\",\\n\",\n                \"  \\\"pyqt6-webengine; sys_platform=='never'\\\",\\n\",\n                \"  \\\"pyqt6-webengine-qt6; sys_platform=='never'\\\",\\n\",\n                \"  \\\"pyqt6_sip; sys_platform=='never'\\\"\\n\",\n                \"]\\n\"\n            ),\n            updated_content\n        )\n    } else {\n        updated_content\n    };\n\n    write_file(&state.user_pyproject_path, &final_content)?;\n\n    // Update .python-version based on version kind\n    match version_kind {\n        VersionKind::PyOxidizer(_) => {\n            write_file(&state.user_python_version_path, \"3.9\")?;\n        }\n        VersionKind::Uv(_) => {\n            copy_file(\n                &state.dist_python_version_path,\n                &state.user_python_version_path,\n            )?;\n        }\n    }\n    Ok(())\n}\n\nfn update_pyproject_for_version(menu_choice: MainMenuChoice, state: &State) -> Result<()> {\n    match menu_choice {\n        MainMenuChoice::Latest => {\n            // Get the latest release version and create a VersionKind for it\n            let releases = get_releases(state)?;\n            let latest_version = releases.latest.first().context(\"No latest version found\")?;\n            apply_version_kind(&VersionKind::Uv(latest_version.clone()), state)?;\n        }\n        MainMenuChoice::KeepExisting => {\n            // Do nothing - keep existing pyproject.toml and .python-version\n        }\n        MainMenuChoice::ToggleBetas => {\n            unreachable!();\n        }\n        MainMenuChoice::ToggleCache => {\n            unreachable!();\n        }\n        MainMenuChoice::DownloadMirror => {\n            unreachable!();\n        }\n        MainMenuChoice::Uninstall => {\n            unreachable!();\n        }\n        MainMenuChoice::Version(version_kind) => {\n            apply_version_kind(&version_kind, state)?;\n        }\n    }\n    Ok(())\n}\n\nfn parse_version_kind(version: &str) -> Option<VersionKind> {\n    let numeric_chars: String = version\n        .chars()\n        .filter(|c| c.is_ascii_digit() || *c == '.')\n        .collect();\n\n    let parts: Vec<&str> = numeric_chars.split('.').collect();\n\n    if parts.len() < 2 {\n        return None;\n    }\n\n    let major: u32 = match parts[0].parse() {\n        Ok(val) => val,\n        Err(_) => return None,\n    };\n\n    let minor: u32 = match parts[1].parse() {\n        Ok(val) => val,\n        Err(_) => return None,\n    };\n\n    let patch: u32 = if parts.len() >= 3 {\n        match parts[2].parse() {\n            Ok(val) => val,\n            Err(_) => return None,\n        }\n    } else {\n        0 // Default patch to 0 if not provided\n    };\n\n    // Reject versions < 2.1.50\n    if major == 2 && (minor != 1 || patch < 50) {\n        return None;\n    }\n\n    if major < 25 || (major == 25 && minor < 6) {\n        Some(VersionKind::PyOxidizer(version.to_string()))\n    } else {\n        Some(VersionKind::Uv(version.to_string()))\n    }\n}\n\nfn inject_helper_addon() -> Result<()> {\n    let addons21_path = get_anki_addons21_path()?;\n\n    if !addons21_path.exists() {\n        return Ok(());\n    }\n\n    let addon_folder = addons21_path.join(\"anki-launcher\");\n\n    // Remove existing anki-launcher folder if it exists\n    if addon_folder.exists() {\n        anki_io::remove_dir_all(&addon_folder)?;\n    }\n\n    // Create the anki-launcher folder\n    create_dir_all(&addon_folder)?;\n\n    // Write the embedded files\n    let init_py_content = include_str!(\"../addon/__init__.py\");\n    let manifest_json_content = include_str!(\"../addon/manifest.json\");\n\n    write_file(addon_folder.join(\"__init__.py\"), init_py_content)?;\n    write_file(addon_folder.join(\"manifest.json\"), manifest_json_content)?;\n\n    Ok(())\n}\n\nfn get_anki_base_path() -> Result<std::path::PathBuf> {\n    let anki_base_path = if cfg!(target_os = \"windows\") {\n        // Windows: %APPDATA%\\Anki2\n        dirs::config_dir()\n            .context(\"Unable to determine config directory\")?\n            .join(\"Anki2\")\n    } else if cfg!(target_os = \"macos\") {\n        // macOS: ~/Library/Application Support/Anki2\n        dirs::data_dir()\n            .context(\"Unable to determine data directory\")?\n            .join(\"Anki2\")\n    } else {\n        // Linux: ~/.local/share/Anki2\n        dirs::data_dir()\n            .context(\"Unable to determine data directory\")?\n            .join(\"Anki2\")\n    };\n\n    Ok(anki_base_path)\n}\n\nfn get_anki_addons21_path() -> Result<std::path::PathBuf> {\n    Ok(get_anki_base_path()?.join(\"addons21\"))\n}\n\nfn handle_uninstall(state: &State) -> Result<bool> {\n    println!(\"{}\", state.tr.launcher_uninstall_confirm());\n    print!(\"> \");\n    let _ = stdout().flush();\n\n    let mut input = String::new();\n    let _ = stdin().read_line(&mut input);\n    let input = input.trim().to_lowercase();\n\n    if input != \"y\" {\n        println!(\"{}\", state.tr.launcher_uninstall_cancelled());\n        println!();\n        return Ok(false);\n    }\n\n    // Remove program files\n    if state.uv_install_root.exists() {\n        anki_io::remove_dir_all(&state.uv_install_root)?;\n        println!(\"{}\", state.tr.launcher_program_files_removed());\n    }\n\n    println!();\n    println!(\"{}\", state.tr.launcher_remove_all_profiles_confirm());\n    print!(\"> \");\n    let _ = stdout().flush();\n\n    let mut input = String::new();\n    let _ = stdin().read_line(&mut input);\n    let input = input.trim().to_lowercase();\n\n    if input == \"y\" && state.anki_base_folder.exists() {\n        anki_io::remove_dir_all(&state.anki_base_folder)?;\n        println!(\"{}\", state.tr.launcher_user_data_removed());\n    }\n\n    println!();\n\n    // Platform-specific messages\n    #[cfg(target_os = \"macos\")]\n    platform::mac::finalize_uninstall();\n\n    #[cfg(target_os = \"windows\")]\n    platform::windows::finalize_uninstall();\n\n    #[cfg(all(unix, not(target_os = \"macos\")))]\n    platform::unix::finalize_uninstall();\n\n    Ok(true)\n}\n\nfn uv_command(state: &State) -> Result<Command> {\n    let mut command = Command::new(&state.uv_path);\n    command.current_dir(&state.uv_install_root);\n\n    // remove UV_* environment variables to avoid interference\n    for (key, _) in std::env::vars() {\n        if key.starts_with(\"UV_\") {\n            command.env_remove(key);\n        }\n    }\n    command\n        .env_remove(\"VIRTUAL_ENV\")\n        .env_remove(\"SSLKEYLOGFILE\");\n\n    // Add mirror environment variable if enabled\n    if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? {\n        command\n            .env(\"UV_PYTHON_INSTALL_MIRROR\", &python_mirror)\n            .env(\"UV_DEFAULT_INDEX\", &pypi_mirror);\n    }\n\n    if state.no_cache_marker.exists() {\n        command.env(\"UV_NO_CACHE\", \"1\");\n    } else {\n        command.env(\"UV_CACHE_DIR\", &state.uv_cache_dir);\n    }\n\n    // have uv use the system certstore instead of webpki-roots'\n    command.env(\"UV_NATIVE_TLS\", \"1\");\n\n    Ok(command)\n}\n\nfn build_python_command(state: &State, args: &[String]) -> Result<Command> {\n    let python_exe = if cfg!(target_os = \"windows\") {\n        let show_console = std::env::var(\"ANKI_CONSOLE\").is_ok();\n        if show_console {\n            state.venv_folder.join(\"Scripts/python.exe\")\n        } else {\n            state.venv_folder.join(\"Scripts/pythonw.exe\")\n        }\n    } else {\n        state.venv_folder.join(\"bin/python\")\n    };\n\n    let mut cmd = Command::new(&python_exe);\n    cmd.args([\"-c\", \"import aqt, sys; sys.argv[0] = 'Anki'; aqt.run()\"]);\n    cmd.args(args);\n    // tell the Python code it was invoked by the launcher, and updating is\n    // available\n    cmd.env(\"ANKI_LAUNCHER\", std::env::current_exe()?.utf8()?.as_str());\n\n    // Set UV and Python paths for the Python code\n    cmd.env(\"ANKI_LAUNCHER_UV\", state.uv_path.utf8()?.as_str());\n    cmd.env(\"UV_PROJECT\", state.uv_install_root.utf8()?.as_str());\n    cmd.env_remove(\"SSLKEYLOGFILE\");\n\n    Ok(cmd)\n}\n\nfn is_mirror_enabled(state: &State) -> bool {\n    state.mirror_path.exists()\n}\n\nfn get_mirror_urls(state: &State) -> Result<Option<(String, String)>> {\n    if !state.mirror_path.exists() {\n        return Ok(None);\n    }\n\n    let content = read_file(&state.mirror_path)?;\n    let content_str = String::from_utf8(content).context(\"Invalid UTF-8 in mirror file\")?;\n\n    let lines: Vec<&str> = content_str.lines().collect();\n    if lines.len() >= 2 {\n        Ok(Some((\n            lines[0].trim().to_string(),\n            lines[1].trim().to_string(),\n        )))\n    } else {\n        Ok(None)\n    }\n}\n\nfn show_mirror_submenu(state: &State) -> Result<()> {\n    loop {\n        println!(\"{}\", state.tr.launcher_download_mirror_options());\n        println!(\"1) {}\", state.tr.launcher_mirror_no_mirror());\n        println!(\"2) {}\", state.tr.launcher_mirror_china());\n        print!(\"> \");\n        let _ = stdout().flush();\n\n        let mut input = String::new();\n        let _ = stdin().read_line(&mut input);\n        let input = input.trim();\n\n        match input {\n            \"1\" => {\n                // Remove mirror file\n                if state.mirror_path.exists() {\n                    let _ = remove_file(&state.mirror_path);\n                }\n                println!(\"{}\", state.tr.launcher_mirror_disabled());\n                break;\n            }\n            \"2\" => {\n                // Write China mirror URLs\n                let china_mirrors = \"https://registry.npmmirror.com/-/binary/python-build-standalone/\\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/\";\n                write_file(&state.mirror_path, china_mirrors)?;\n                println!(\"{}\", state.tr.launcher_mirror_china_enabled());\n                break;\n            }\n            \"\" => {\n                // Empty input - return to main menu\n                break;\n            }\n            _ => {\n                println!(\"{}\", state.tr.launcher_invalid_input());\n                continue;\n            }\n        }\n    }\n    Ok(())\n}\n\nfn diff_launcher_was_installed(state: &State) -> Result<bool> {\n    let launcher_version = option_env!(\"BUILDHASH\").unwrap_or(\"dev\").trim();\n    let launcher_version_path = state.uv_install_root.join(\"launcher-version\");\n    if let Ok(content) = read_file(&launcher_version_path) {\n        if let Ok(version_str) = String::from_utf8(content) {\n            if version_str.trim() == launcher_version {\n                return Ok(false);\n            }\n        }\n    }\n    write_file(launcher_version_path, launcher_version)?;\n    Ok(true)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_normalize_version() {\n        // Test versions <= 2.x (should not be transformed)\n        assert_eq!(normalize_version(\"2.1.50\"), \"2.1.50\");\n\n        // Test basic versions > 2 with zero-padding\n        assert_eq!(normalize_version(\"25.7\"), \"25.07\");\n        assert_eq!(normalize_version(\"25.07\"), \"25.07\");\n        assert_eq!(normalize_version(\"25.10\"), \"25.10\");\n        assert_eq!(normalize_version(\"24.6.1\"), \"24.06.1\");\n        assert_eq!(normalize_version(\"24.06.1\"), \"24.06.1\");\n\n        // Test prerelease versions\n        assert_eq!(normalize_version(\"25.7a1\"), \"25.07a1\");\n        assert_eq!(normalize_version(\"25.7.1a1\"), \"25.07.1a1\");\n\n        // Test versions with patch = 0\n        assert_eq!(normalize_version(\"25.7.0\"), \"25.07.0\");\n        assert_eq!(normalize_version(\"25.7.0a1\"), \"25.07.0a1\");\n    }\n}\n"
  },
  {
    "path": "qt/launcher/src/platform/mac.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::io;\nuse std::io::Write;\nuse std::path::Path;\nuse std::process::Command;\nuse std::sync::atomic::AtomicBool;\nuse std::sync::atomic::Ordering;\nuse std::sync::Arc;\nuse std::thread;\nuse std::time::Duration;\n\nuse anki_process::CommandExt as AnkiCommandExt;\nuse anyhow::Context;\nuse anyhow::Result;\n\npub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<()> {\n    // Pre-validate by running --version to trigger any Gatekeeper checks\n    print!(\"\\n\\x1B[1mThis may take a few minutes. Please wait\\x1B[0m\");\n    io::stdout().flush().unwrap();\n\n    // Start progress indicator\n    let running = Arc::new(AtomicBool::new(true));\n    let running_clone = running.clone();\n    let progress_thread = thread::spawn(move || {\n        while running_clone.load(Ordering::Relaxed) {\n            print!(\".\");\n            io::stdout().flush().unwrap();\n            thread::sleep(Duration::from_secs(1));\n        }\n    });\n\n    let _ = cmd\n        .env(\"ANKI_FIRST_RUN\", \"1\")\n        .arg(\"--version\")\n        .stdout(std::process::Stdio::null())\n        .stderr(std::process::Stdio::null())\n        .ensure_success();\n\n    if cfg!(target_os = \"macos\") {\n        // older Anki versions had a short mpv timeout and didn't support\n        // ANKI_FIRST_RUN, so we need to ensure mpv passes Gatekeeper\n        // validation prior to launch\n        let mpv_path = root.join(\".venv/lib/python3.9/site-packages/anki_audio/mpv\");\n        if mpv_path.exists() {\n            let _ = Command::new(&mpv_path)\n                .arg(\"--version\")\n                .stdout(std::process::Stdio::null())\n                .stderr(std::process::Stdio::null())\n                .ensure_success();\n        }\n    }\n\n    // Stop progress indicator\n    running.store(false, Ordering::Relaxed);\n    progress_thread.join().unwrap();\n    println!(); // New line after dots\n    Ok(())\n}\n\npub fn relaunch_in_terminal() -> Result<()> {\n    let current_exe = std::env::current_exe().context(\"Failed to get current executable path\")?;\n    Command::new(\"open\")\n        .args([\"-na\", \"Terminal\"])\n        .arg(current_exe)\n        .env_remove(\"ANKI_LAUNCHER_WANT_TERMINAL\")\n        .ensure_spawn()?;\n    std::process::exit(0);\n}\n\npub fn finalize_uninstall() {\n    if let Ok(exe_path) = std::env::current_exe() {\n        // Find the .app bundle by walking up the directory tree\n        let mut app_bundle_path = exe_path.as_path();\n        while let Some(parent) = app_bundle_path.parent() {\n            if let Some(name) = parent.file_name() {\n                if name.to_string_lossy().ends_with(\".app\") {\n                    let result = Command::new(\"trash\").arg(parent).output();\n\n                    match result {\n                        Ok(output) if output.status.success() => {\n                            println!(\"Anki has been uninstalled.\");\n                            return;\n                        }\n                        _ => {\n                            // Fall back to manual instructions\n                            println!(\n                                \"Please manually drag Anki.app to the trash to complete uninstall.\"\n                            );\n                        }\n                    }\n                    return;\n                }\n            }\n            app_bundle_path = parent;\n        }\n    }\n}\n"
  },
  {
    "path": "qt/launcher/src/platform/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#[cfg(all(unix, not(target_os = \"macos\")))]\npub mod unix;\n\n#[cfg(target_os = \"macos\")]\npub mod mac;\n\n#[cfg(target_os = \"windows\")]\npub mod windows;\n\nuse std::path::PathBuf;\n\nuse anki_process::CommandExt;\nuse anyhow::Context;\nuse anyhow::Result;\n\npub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {\n    let exe_dir = std::env::current_exe()\n        .context(\"Failed to get current executable path\")?\n        .parent()\n        .context(\"Failed to get executable directory\")?\n        .to_owned();\n\n    let resources_dir = if cfg!(target_os = \"macos\") {\n        // On macOS, resources are in ../Resources relative to the executable\n        exe_dir\n            .parent()\n            .context(\"Failed to get parent directory\")?\n            .join(\"Resources\")\n    } else {\n        // On other platforms, resources are in the same directory as executable\n        exe_dir.clone()\n    };\n\n    Ok((exe_dir, resources_dir))\n}\n\npub fn get_uv_binary_name() -> &'static str {\n    if cfg!(target_os = \"windows\") {\n        \"uv.exe\"\n    } else if cfg!(target_os = \"macos\") {\n        \"uv\"\n    } else if cfg!(target_arch = \"x86_64\") {\n        \"uv.amd64\"\n    } else {\n        \"uv.arm64\"\n    }\n}\n\npub fn respawn_launcher() -> Result<()> {\n    use std::process::Stdio;\n\n    let mut launcher_cmd = if cfg!(target_os = \"macos\") {\n        // On macOS, we need to launch the .app bundle, not the executable directly\n        let current_exe =\n            std::env::current_exe().context(\"Failed to get current executable path\")?;\n\n        // Navigate from Contents/MacOS/launcher to the .app bundle\n        let app_bundle = current_exe\n            .parent() // MacOS\n            .and_then(|p| p.parent()) // Contents\n            .and_then(|p| p.parent()) // .app\n            .context(\"Failed to find .app bundle\")?;\n\n        let mut cmd = std::process::Command::new(\"open\");\n        cmd.arg(app_bundle);\n        cmd\n    } else {\n        let current_exe =\n            std::env::current_exe().context(\"Failed to get current executable path\")?;\n        std::process::Command::new(current_exe)\n    };\n\n    launcher_cmd\n        .stdin(Stdio::null())\n        .stdout(Stdio::null())\n        .stderr(Stdio::null());\n\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::CommandExt;\n        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;\n        const DETACHED_PROCESS: u32 = 0x00000008;\n        launcher_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);\n    }\n\n    #[cfg(all(unix, not(target_os = \"macos\")))]\n    {\n        use std::os::unix::process::CommandExt;\n        launcher_cmd.process_group(0);\n    }\n\n    let child = launcher_cmd.ensure_spawn()?;\n    std::mem::forget(child);\n\n    Ok(())\n}\n\npub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> {\n    #[cfg(windows)]\n    {\n        crate::platform::windows::prepare_to_launch_normally();\n        cmd.ensure_success()?;\n    }\n    #[cfg(unix)]\n    cmd.ensure_exec()?;\n    Ok(())\n}\n\n#[cfg(windows)]\npub use windows::ensure_terminal_shown;\n\n#[cfg(unix)]\npub fn ensure_terminal_shown() -> Result<()> {\n    use std::io::IsTerminal;\n\n    let want_terminal = std::env::var(\"ANKI_LAUNCHER_WANT_TERMINAL\").is_ok();\n    let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout());\n    if want_terminal || !stdout_is_terminal {\n        #[cfg(target_os = \"macos\")]\n        mac::relaunch_in_terminal()?;\n        #[cfg(not(target_os = \"macos\"))]\n        unix::relaunch_in_terminal()?;\n    }\n\n    // Set terminal title to \"Anki Launcher\"\n    print!(\"\\x1b]2;Anki Launcher\\x07\");\n    Ok(())\n}\n\npub fn ensure_os_supported() -> Result<()> {\n    #[cfg(all(unix, not(target_os = \"macos\")))]\n    unix::ensure_glibc_supported()?;\n\n    #[cfg(target_os = \"windows\")]\n    windows::ensure_windows_version_supported()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "qt/launcher/src/platform/unix.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::process::Command;\n\nuse anyhow::Context;\nuse anyhow::Result;\n\npub fn relaunch_in_terminal() -> Result<()> {\n    let current_exe = std::env::current_exe().context(\"Failed to get current executable path\")?;\n\n    // Try terminals in roughly most specific to least specific.\n    // First, try commonly used terminals for riced systems.\n    // Second, try common defaults.\n    // Finally, try x11 compatibility terminals.\n    let terminals = [\n        // commonly used for riced systems\n        (\"alacritty\", vec![\"-e\"]),\n        (\"kitty\", vec![]),\n        (\"foot\", vec![]),\n        // the user's default terminal in Debian/Ubuntu\n        (\"x-terminal-emulator\", vec![\"-e\"]),\n        // default installs for the most common distros\n        (\"xfce4-terminal\", vec![\"-e\"]),\n        (\"gnome-terminal\", vec![\"-e\"]),\n        (\"konsole\", vec![\"-e\"]),\n        // x11-compatibility terminals\n        (\"urxvt\", vec![\"-e\"]),\n        (\"xterm\", vec![\"-e\"]),\n    ];\n\n    for (terminal_cmd, args) in &terminals {\n        // Check if terminal exists\n        if Command::new(\"which\")\n            .arg(terminal_cmd)\n            .output()\n            .map(|o| o.status.success())\n            .unwrap_or(false)\n        {\n            // Try to launch the terminal\n            let mut cmd = Command::new(terminal_cmd);\n            if args.is_empty() {\n                cmd.arg(&current_exe);\n            } else {\n                cmd.args(args).arg(&current_exe);\n            }\n\n            if cmd.spawn().is_ok() {\n                std::process::exit(0);\n            }\n        }\n    }\n\n    // If no terminal worked, continue without relaunching\n    Ok(())\n}\n\npub fn finalize_uninstall() {\n    use std::io::stdin;\n    use std::io::stdout;\n    use std::io::Write;\n\n    let uninstall_script = std::path::Path::new(\"/usr/local/share/anki/uninstall.sh\");\n\n    if uninstall_script.exists() {\n        println!(\"To finish uninstalling, run 'sudo /usr/local/share/anki/uninstall.sh'\");\n    } else {\n        println!(\"Anki has been uninstalled.\");\n    }\n    println!(\"Press enter to quit.\");\n    let _ = stdout().flush();\n    let mut input = String::new();\n    let _ = stdin().read_line(&mut input);\n}\n\npub fn ensure_glibc_supported() -> Result<()> {\n    use std::ffi::CStr;\n    let get_glibc_version = || -> Option<(u32, u32)> {\n        let version_ptr = unsafe { libc::gnu_get_libc_version() };\n        if version_ptr.is_null() {\n            return None;\n        }\n\n        let version_cstr = unsafe { CStr::from_ptr(version_ptr) };\n        let version_str = version_cstr.to_str().ok()?;\n\n        // Parse version string (format: \"2.36\" or \"2.36.1\")\n        let version_parts: Vec<&str> = version_str.split('.').collect();\n        if version_parts.len() < 2 {\n            return None;\n        }\n\n        let major: u32 = version_parts[0].parse().ok()?;\n        let minor: u32 = version_parts[1].parse().ok()?;\n\n        Some((major, minor))\n    };\n\n    let (major, minor) = get_glibc_version().unwrap_or_default();\n    if major < 2 || (major == 2 && minor < 36) {\n        anyhow::bail!(\"Anki requires a modern Linux distro with glibc 2.36 or later.\");\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "qt/launcher/src/platform/windows.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::io::stdin;\nuse std::process::Command;\n\nuse anyhow::Context;\nuse anyhow::Result;\nuse widestring::u16cstr;\nuse windows::core::PCWSTR;\nuse windows::Wdk::System::SystemServices::RtlGetVersion;\nuse windows::Win32::System::Console::AttachConsole;\nuse windows::Win32::System::Console::GetConsoleWindow;\nuse windows::Win32::System::Console::ATTACH_PARENT_PROCESS;\nuse windows::Win32::System::Registry::RegCloseKey;\nuse windows::Win32::System::Registry::RegOpenKeyExW;\nuse windows::Win32::System::Registry::RegQueryValueExW;\nuse windows::Win32::System::Registry::HKEY;\nuse windows::Win32::System::Registry::HKEY_CURRENT_USER;\nuse windows::Win32::System::Registry::KEY_READ;\nuse windows::Win32::System::Registry::REG_SZ;\nuse windows::Win32::System::SystemInformation::OSVERSIONINFOW;\nuse windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;\n\n/// Returns true if running on Windows 10 (not Windows 11)\nfn is_windows_10() -> bool {\n    unsafe {\n        let mut info = OSVERSIONINFOW {\n            dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,\n            ..Default::default()\n        };\n        if RtlGetVersion(&mut info).is_ok() {\n            // Windows 10 has build numbers < 22000, Windows 11 >= 22000\n            info.dwBuildNumber < 22000 && info.dwMajorVersion == 10\n        } else {\n            false\n        }\n    }\n}\n\n/// Ensures Windows 10 version 1809 or later\npub fn ensure_windows_version_supported() -> Result<()> {\n    unsafe {\n        let mut info = OSVERSIONINFOW {\n            dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,\n            ..Default::default()\n        };\n\n        if RtlGetVersion(&mut info).is_err() {\n            anyhow::bail!(\"Failed to get Windows version information\");\n        }\n\n        if info.dwBuildNumber >= 17763 {\n            return Ok(());\n        }\n\n        anyhow::bail!(\"Windows 10 version 1809 or later is required.\")\n    }\n}\n\npub fn ensure_terminal_shown() -> Result<()> {\n    unsafe {\n        if !GetConsoleWindow().is_invalid() {\n            // We already have a console, no need to spawn anki-console.exe\n            return Ok(());\n        }\n    }\n\n    if std::env::var(\"ANKI_IMPLICIT_CONSOLE\").is_ok() && attach_to_parent_console() {\n        // This black magic triggers Windows to switch to the new\n        // ANSI-supporting console host, which is usually only available\n        // when the app is built with the console subsystem.\n        // Only needed on Windows 10, not Windows 11.\n        if is_windows_10() {\n            let _ = Command::new(\"cmd\").args([\"/C\", \"\"]).status();\n        }\n\n        // Successfully attached to parent console\n        reconnect_stdio_to_console();\n        return Ok(());\n    }\n\n    // No console available, spawn anki-console.exe and exit\n    let current_exe = std::env::current_exe().context(\"Failed to get current executable path\")?;\n    let exe_dir = current_exe\n        .parent()\n        .context(\"Failed to get executable directory\")?;\n\n    let console_exe = exe_dir.join(\"anki-console.exe\");\n\n    if !console_exe.exists() {\n        anyhow::bail!(\"anki-console.exe not found in the same directory\");\n    }\n\n    // Spawn anki-console.exe without waiting\n    Command::new(&console_exe)\n        .env(\"ANKI_IMPLICIT_CONSOLE\", \"1\")\n        .spawn()\n        .context(\"Failed to spawn anki-console.exe\")?;\n\n    // Exit immediately after spawning\n    std::process::exit(0);\n}\n\npub fn attach_to_parent_console() -> bool {\n    unsafe {\n        if !GetConsoleWindow().is_invalid() {\n            // we have a console already\n            return false;\n        }\n\n        if AttachConsole(ATTACH_PARENT_PROCESS).is_ok() {\n            // successfully attached to parent\n            reconnect_stdio_to_console();\n            true\n        } else {\n            false\n        }\n    }\n}\n\n/// Reconnect stdin/stdout/stderr to the console.\nfn reconnect_stdio_to_console() {\n    use std::ffi::CString;\n\n    use libc_stdhandle::*;\n\n    // we launched without a console, so we'll need to open stdin/out/err\n    let conin = CString::new(\"CONIN$\").unwrap();\n    let conout = CString::new(\"CONOUT$\").unwrap();\n    let r = CString::new(\"r\").unwrap();\n    let w = CString::new(\"w\").unwrap();\n\n    // Python uses the CRT for I/O, and it requires the descriptors are reopened.\n    unsafe {\n        libc::freopen(conin.as_ptr(), r.as_ptr(), stdin());\n        libc::freopen(conout.as_ptr(), w.as_ptr(), stdout());\n        libc::freopen(conout.as_ptr(), w.as_ptr(), stderr());\n    }\n}\n\npub fn finalize_uninstall() {\n    let uninstaller_path = get_uninstaller_path();\n\n    match uninstaller_path {\n        Some(path) => {\n            println!(\"Launching Windows uninstaller...\");\n            let result = Command::new(&path).env(\"ANKI_LAUNCHER\", \"1\").spawn();\n\n            match result {\n                Ok(_) => {\n                    println!(\"Uninstaller launched successfully.\");\n                    return;\n                }\n                Err(e) => {\n                    println!(\"Failed to launch uninstaller: {e}\");\n                    println!(\"You can manually run: {}\", path.display());\n                }\n            }\n        }\n        None => {\n            println!(\"Windows uninstaller not found.\");\n            println!(\"You may need to uninstall via Windows Settings > Apps.\");\n        }\n    }\n    println!(\"Press enter to close...\");\n    let mut input = String::new();\n    let _ = stdin().read_line(&mut input);\n}\n\nfn get_uninstaller_path() -> Option<std::path::PathBuf> {\n    // Try to read install directory from registry\n    if let Some(install_dir) = read_registry_install_dir() {\n        let uninstaller = install_dir.join(\"uninstall.exe\");\n        if uninstaller.exists() {\n            return Some(uninstaller);\n        }\n    }\n\n    // Fall back to default location\n    let default_dir = dirs::data_local_dir()?.join(\"Programs\").join(\"Anki\");\n    let uninstaller = default_dir.join(\"uninstall.exe\");\n    if uninstaller.exists() {\n        return Some(uninstaller);\n    }\n\n    None\n}\n\nfn read_registry_install_dir() -> Option<std::path::PathBuf> {\n    unsafe {\n        let mut hkey = HKEY::default();\n\n        // Convert the registry path to wide string\n        let subkey = u16cstr!(\"SOFTWARE\\\\Anki\");\n\n        // Open the registry key\n        let result = RegOpenKeyExW(\n            HKEY_CURRENT_USER,\n            PCWSTR(subkey.as_ptr()),\n            Some(0),\n            KEY_READ,\n            &mut hkey,\n        );\n\n        if result.is_err() {\n            return None;\n        }\n\n        // Query the Install_Dir64 value\n        let value_name = u16cstr!(\"Install_Dir64\");\n\n        let mut value_type = REG_SZ;\n        let mut data_size = 0u32;\n\n        // First call to get the size\n        let result = RegQueryValueExW(\n            hkey,\n            PCWSTR(value_name.as_ptr()),\n            None,\n            Some(&mut value_type),\n            None,\n            Some(&mut data_size),\n        );\n\n        if result.is_err() || data_size == 0 {\n            let _ = RegCloseKey(hkey);\n            return None;\n        }\n\n        // Allocate buffer and read the value\n        let mut buffer: Vec<u16> = vec![0; (data_size / 2) as usize];\n        let result = RegQueryValueExW(\n            hkey,\n            PCWSTR(value_name.as_ptr()),\n            None,\n            Some(&mut value_type),\n            Some(buffer.as_mut_ptr() as *mut u8),\n            Some(&mut data_size),\n        );\n\n        let _ = RegCloseKey(hkey);\n\n        if result.is_ok() {\n            // Convert wide string back to PathBuf\n            let len = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len());\n            let path_str = String::from_utf16_lossy(&buffer[..len]);\n            Some(std::path::PathBuf::from(path_str))\n        } else {\n            None\n        }\n    }\n}\n\npub fn prepare_to_launch_normally() {\n    // Set the App User Model ID for Windows taskbar grouping\n    unsafe {\n        let _ =\n            SetCurrentProcessExplicitAppUserModelID(PCWSTR(u16cstr!(\"Ankitects.Anki\").as_ptr()));\n    }\n\n    attach_to_parent_console();\n}\n"
  },
  {
    "path": "qt/launcher/versions.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport json\nimport sys\n\nimport pip_system_certs.wrapt_requests\nimport requests\n\npip_system_certs.wrapt_requests.inject_truststore()\n\n\ndef main():\n    \"\"\"Fetch and return all versions from PyPI, sorted by upload time.\"\"\"\n    url = \"https://pypi.org/pypi/aqt/json\"\n\n    try:\n        response = requests.get(url, timeout=30)\n        response.raise_for_status()\n        data = response.json()\n        releases = data.get(\"releases\", {})\n\n        # Create list of (version, upload_time) tuples\n        version_times = []\n        for version, files in releases.items():\n            if files:  # Only include versions that have files\n                # Use the upload time of the first file for each version\n                upload_time = files[0].get(\"upload_time_iso_8601\")\n                if upload_time:\n                    version_times.append((version, upload_time))\n\n        # Sort by upload time\n        version_times.sort(key=lambda x: x[1])\n\n        # Extract just the version names\n        versions = [version for version, _ in version_times]\n        print(json.dumps(versions))\n    except Exception as e:\n        print(f\"Error fetching versions: {e}\", file=sys.stderr)\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "qt/launcher/win/anki-manifest.rc",
    "content": "#define RT_MANIFEST 24\n1 RT_MANIFEST \"anki.exe.manifest\"\nIDI_ICON1 ICON DISCARDABLE \"anki-icon.ico\"\n"
  },
  {
    "path": "qt/launcher/win/anki.exe.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>\n<assembly xmlns=\"urn:schemas-microsoft-com:asm.v1\" manifestVersion=\"1.0\">\n    <assemblyIdentity type=\"win32\" name=\"Anki\" version=\"1.0.0.0\" />\n    <trustInfo xmlns=\"urn:schemas-microsoft-com:asm.v2\">\n        <security>\n            <requestedPrivileges xmlns=\"urn:schemas-microsoft-com:asm.v3\">\n                <requestedExecutionLevel level=\"asInvoker\" uiAccess=\"false\" />\n            </requestedPrivileges>\n        </security>\n    </trustInfo>\n    <application xmlns=\"urn:schemas-microsoft-com:asm.v3\">\n        <windowsSettings>\n            <dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true</dpiAware>\n            <dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">PerMonitorV2</dpiAwareness>\n            <longPathAware xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">true</longPathAware>\n            <activeCodePage xmlns=\"http://schemas.microsoft.com/SMI/2019/WindowsSettings\">UTF-8</activeCodePage>\n        </windowsSettings>\n    </application>\n    <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\">\n        <application>\n            <supportedOS Id=\"{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}\" />\n            <supportedOS Id=\"{1f676c76-80e1-4239-95bb-83d0f6d0da78}\" />\n        </application>\n    </compatibility>\n</assembly>\n"
  },
  {
    "path": "qt/launcher/win/anki.template.nsi",
    "content": ";; This installer was written many years ago, and it is probably worth investigating modern\n;; installer alternatives at one point.\n\n!addplugindir .\n\n!include \"fileassoc.nsh\"\n!include WinVer.nsh\n!include x64.nsh\n\n!define nsProcess::FindProcess `!insertmacro nsProcess::FindProcess`\n\n!macro nsProcess::FindProcess _FILE _ERR\n\tnsProcess::_FindProcess /NOUNLOAD `${_FILE}`\n\tPop ${_ERR}\n!macroend\n\n;--------------------------------\n\n!pragma warning disable 6020 ; don't complain about missing installer in second invocation\n\n; The name of the installer\nName \"Anki\"\n\nUnicode true\n\n; The file to write (relative to nsis directory)\nOutFile \"..\\launcher_exe\\anki-launcher-ANKI_VERSION-windows.exe\"\n\n; Non elevated\nRequestExecutionLevel user\n\n; The default installation directory\nInstallDir \"$LOCALAPPDATA\\Programs\\Anki\"\n\n; Remember the install location\nInstallDirRegKey HKCU \"Software\\Anki\" \"Install_Dir64\"\n\nAllowSkipFiles off\n\n!ifdef NO_COMPRESS\nSetCompress off\n!else\nSetCompressor /solid lzma\n!endif\n\nFunction .onInit\n  ${IfNot} ${AtLeastWin10}\n    MessageBox MB_OK \"Windows 10 or later required.\"\n    Quit\n  ${EndIf}\n\n  ${IfNot} ${RunningX64}\n    MessageBox MB_OK \"64bit Windows is required.\"\n    Quit\n  ${EndIf}\n\n  ${nsProcess::FindProcess} \"anki.exe\" $R0\n  StrCmp $R0 0 0 notRunning\n      MessageBox MB_OK|MB_ICONEXCLAMATION \"Anki.exe is already running. Please close it, then run the installer again.\" /SD IDOK\n      Abort\n  notRunning:\nFunctionEnd\n\n!ifdef WRITE_UNINSTALLER\n!uninstfinalize 'copy \"%1\" \"uninstall.exe\"'\n!endif\n\n;--------------------------------\n\n; Pages\n\nPage directory\nPage instfiles\n\n\n;; manifest removal script shared by installer and uninstaller\n;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;\n\n!define UninstLog \"anki.install-manifest\"\nVar UninstLog\n\n!macro removeManifestFiles un\nFunction ${un}removeManifestFiles\n  IfFileExists \"$INSTDIR\\${UninstLog}\" proceed\n    DetailPrint \"No previous install manifest found, skipping cleanup.\"\n    return\n\n;; this code was based on an example found on the net, which I can no longer find\nproceed:\n  Push $R0\n  Push $R1\n  Push $R2\n  SetFileAttributes \"$INSTDIR\\${UninstLog}\" NORMAL\n  FileOpen $UninstLog \"$INSTDIR\\${UninstLog}\" r\n  StrCpy $R1 -1\n\n  GetLineCount:\n    ClearErrors\n    FileRead $UninstLog $R0\n    IntOp $R1 $R1 + 1\n    StrCpy $R0 $R0 -2\n    Push $R0\n    IfErrors 0 GetLineCount\n\n  Pop $R0\n\n  LoopRead:\n    StrCmp $R1 0 LoopDone\n    Pop $R0\n    ;; manifest is relative to instdir\n    StrCpy $R0 \"$INSTDIR\\$R0\"\n\n    IfFileExists \"$R0\\*.*\" 0 +3\n      RMDir $R0  #is dir\n    Goto processed\n    IfFileExists $R0 0 +3\n      Delete $R0 #is file\n    Goto processed\n\nprocessed:\n\n    IntOp $R1 $R1 - 1\n    Goto LoopRead\n  LoopDone:\n  FileClose $UninstLog\n  Delete \"$INSTDIR\\${UninstLog}\"\n  RMDir \"$INSTDIR\"\n  Pop $R2\n  Pop $R1\n  Pop $R0\nFunctionEnd\n!macroend\n\n!insertmacro removeManifestFiles \"\"\n!insertmacro removeManifestFiles \"un.\"\n\n;--------------------------------\n\n; Macro from fileassoc changed to work non elevated\n!macro APP_ASSOCIATE_HKCU EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND\n  ; Backup the previously associated file class\n  ReadRegStr $R0 HKCU \"Software\\Classes\\.${EXT}\" \"\"\n  WriteRegStr HKCU \"Software\\Classes\\.${EXT}\" \"${FILECLASS}_backup\" \"$R0\"\n \n  WriteRegStr HKCU \"Software\\Classes\\.${EXT}\" \"\" \"${FILECLASS}\"\n \n  WriteRegStr HKCU \"Software\\Classes\\${FILECLASS}\" \"\" `${DESCRIPTION}`\n  WriteRegStr HKCU \"Software\\Classes\\${FILECLASS}\\DefaultIcon\" \"\" `${ICON}`\n  WriteRegStr HKCU \"Software\\Classes\\${FILECLASS}\\shell\" \"\" \"open\"\n  WriteRegStr HKCU \"Software\\Classes\\${FILECLASS}\\shell\\open\" \"\" `${COMMANDTEXT}`\n  WriteRegStr HKCU \"Software\\Classes\\${FILECLASS}\\shell\\open\\command\" \"\" `${COMMAND}`\n!macroend\n\n; Macro from fileassoc changed to work non elevated\n!macro APP_UNASSOCIATE_HKCU EXT FILECLASS\n  ; Backup the previously associated file class\n  ReadRegStr $R0 HKCU \"Software\\Classes\\.${EXT}\" `${FILECLASS}_backup`\n  WriteRegStr HKCU \"Software\\Classes\\.${EXT}\" \"\" \"$R0\"\n \n  DeleteRegKey HKCU `Software\\Classes\\${FILECLASS}`\n!macroend\n\n; The stuff to install\nSection \"\"\n\n  SetShellVarContext current\n\n  ; \"Upgrade\" from elevated anki\n  ReadRegStr $0 HKLM \"Software\\WOW6432Node\\Anki\" \"Install_Dir64\"\n  ${IF} $0 != \"\"\n      ; old value exists, we want to inform the user that a manual uninstall is required first and then start the uninstall.exe\n      MessageBox MB_ICONEXCLAMATION|MB_OK \"A previous Anki version needs to be uninstalled first. After uninstallation completes, please run this installer again.\"\n      ClearErrors\n      ExecShell \"open\" \"$0\\uninstall.exe\"\n      IfErrors shellError\n      Quit\n  ${ELSE}\n      goto notOldUpgrade\n  ${ENDIF}\n\n  shellError:\n    MessageBox MB_OK|MB_ICONEXCLAMATION \"Failed to uninstall the old version of Anki. Proceeding with installation.\"\n  notOldUpgrade:\n\n  Call removeManifestFiles\n\n  ; Set output path to the installation directory.\n  SetOutPath $INSTDIR\n  CreateShortCut \"$DESKTOP\\Anki.lnk\" \"$INSTDIR\\anki.exe\" \"\"\n  CreateShortCut \"$SMPROGRAMS\\Anki.lnk\" \"$INSTDIR\\anki.exe\" \"\"\n\n  ; Add files to installer\n  !ifndef WRITE_UNINSTALLER\n  File /r ..\\launcher\\*.*\n  !endif\n\n  !insertmacro APP_ASSOCIATE_HKCU \"apkg\" \"anki.apkg\" \\\n    \"Anki deck package\" \"$INSTDIR\\anki.exe,0\" \\\n    \"Open with Anki\" \"$INSTDIR\\anki.exe $\\\"%L$\\\"\"\n  \n  !insertmacro APP_ASSOCIATE_HKCU \"colpkg\" \"anki.colpkg\" \\\n    \"Anki collection package\" \"$INSTDIR\\anki.exe,0\" \\\n    \"Open with Anki\" \"$INSTDIR\\anki.exe $\\\"%L$\\\"\"\n\n  !insertmacro APP_ASSOCIATE_HKCU \"ankiaddon\" \"anki.ankiaddon\" \\\n    \"Anki add-on\" \"$INSTDIR\\anki.exe,0\" \\\n    \"Open with Anki\" \"$INSTDIR\\anki.exe $\\\"%L$\\\"\"\n\n  !insertmacro UPDATEFILEASSOC\n\n  ; Write the installation path into the registry\n  ; WriteRegStr HKLM Software\\Anki \"Install_Dir64\" \"$INSTDIR\"\n  WriteRegStr HKCU Software\\Anki \"Install_Dir64\" \"$INSTDIR\"\n\n  ; Write the uninstall keys for Windows\n  WriteRegStr HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"DisplayName\" \"Anki Launcher\"\n  WriteRegStr HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"DisplayVersion\" \"ANKI_VERSION\"\n  WriteRegStr HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"UninstallString\" '\"$INSTDIR\\uninstall.exe\"'\n  WriteRegStr HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"QuietUninstallString\" '\"$INSTDIR\\uninstall.exe\" /S'\n  WriteRegDWORD HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"NoModify\" 1\n  WriteRegDWORD HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\" \"NoRepair\" 1\n\n  !ifdef WRITE_UNINSTALLER\n  WriteUninstaller \"uninstall.exe\"\n  !endif\n\n  ; Ensure uv gets re-run\n  Push \"$INSTDIR\\pyproject.toml\"\n  Call TouchFile\n\n  ; Launch Anki after installation\n  Exec \"$INSTDIR\\anki.exe\"\n  Quit\n\nSectionEnd ; end the section\n\n;--------------------------------\n\n; Touch file function to update mtime using copy trick\nFunction TouchFile\n  Exch $R0 ; file path\n  \n  nsExec::Exec 'cmd /c \"copy /B \"$R0\" +,,\"'\n  \n  Pop $R0\nFunctionEnd\n\n;--------------------------------\n\n; Uninstaller\n\nfunction un.onInit\n   ; Check for ANKI_LAUNCHER environment variable\n   ReadEnvStr $R0 \"ANKI_LAUNCHER\"\n   ${If} $R0 != \"\"\n     ; Wait for launcher to exit\n     Sleep 2000\n     Goto next\n   ${Else}\n     ; Try to launch anki.exe with ANKI_LAUNCHER_UNINSTALL=1\n     IfFileExists \"$INSTDIR\\anki.exe\" 0 next\n       nsExec::Exec 'cmd /c \"set ANKI_LAUNCHER_UNINSTALL=1 && start /b \"\" \"$INSTDIR\\anki.exe\"\"'\n       Quit\n   ${EndIf}\n  next:\nfunctionEnd\n\nSection \"Uninstall\"\n\n  SetShellVarContext current\n\n  Call un.removeManifestFiles\n\n  ; Remove other shortcuts\n  Delete \"$DESKTOP\\Anki.lnk\"\n  Delete \"$SMPROGRAMS\\Anki.lnk\"\n\n  ; associations\n  !insertmacro APP_UNASSOCIATE_HKCU \"apkg\" \"anki.apkg\"\n  !insertmacro APP_UNASSOCIATE_HKCU \"colpkg\" \"anki.colpkg\"\n  !insertmacro APP_UNASSOCIATE_HKCU \"ankiaddon\" \"anki.ankiaddon\"\n  !insertmacro UPDATEFILEASSOC\n\n  ; Schedule uninstaller for deletion on reboot\n  Delete /REBOOTOK \"$INSTDIR\\uninstall.exe\"\n  \n  ; try to remove top level folder if empty\n  RMDir \"$INSTDIR\"\n\n  ; Remove AnkiProgramData folder created during runtime\n  RMDir /r \"$LOCALAPPDATA\\AnkiProgramFiles\"\n\n  ; Remove registry keys\n  DeleteRegKey HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Anki\"\n  DeleteRegKey HKCU Software\\Anki\n\nSectionEnd\n"
  },
  {
    "path": "qt/launcher/win/build.bat",
    "content": "@echo off\n\nif \"%NOCOMP%\"==\"1\" (\n    set NO_COMPRESS=1\n    set CODESIGN=0\n) else (\n    set CODESIGN=1\n    set NO_COMPRESS=0\n)\ncargo run --bin build_win\n"
  },
  {
    "path": "qt/launcher/win/fileassoc.nsh",
    "content": "; fileassoc.nsh\n; https://nsis.sourceforge.io/File_Association\n; File association helper macros\n; Written by Saivert\n;\n; Features automatic backup system and UPDATEFILEASSOC macro for\n; shell change notification.\n;\n; |> How to use <|\n; To associate a file with an application so you can double-click it in explorer, use\n; the APP_ASSOCIATE macro like this:\n;\n;   Example:\n;   !insertmacro APP_ASSOCIATE \"txt\" \"myapp.textfile\" \"$INSTDIR\\myapp.exe,0\" \\\n;     \"Open with myapp\" \"$INSTDIR\\myapp.exe $\\\"%1$\\\"\"\n;\n; Never insert the APP_ASSOCIATE macro multiple times, it is only meant\n; to associate an application with a single file and using the\n; the \"open\" verb as default. To add more verbs (actions) to a file\n; use the APP_ASSOCIATE_ADDVERB macro.\n;\n;   Example:\n;   !insertmacro APP_ASSOCIATE_ADDVERB \"myapp.textfile\" \"edit\" \"Edit with myapp\" \\\n;     \"$INSTDIR\\myapp.exe /edit $\\\"%1$\\\"\"\n;\n; To have access to more options when registering the file association use the\n; APP_ASSOCIATE_EX macro. Here you can specify the verb and what verb is to be the\n; standard action (default verb).\n;\n; And finally: To remove the association from the registry use the APP_UNASSOCIATE\n; macro. Here is another example just to wrap it up:\n;   !insertmacro APP_UNASSOCIATE \"txt\" \"myapp.textfile\"\n;\n; |> Note <|\n; When defining your file class string always use the short form of your application title\n; then a period (dot) and the type of file. This keeps the file class sort of unique.\n;   Examples:\n;   Winamp.Playlist\n;   NSIS.Script\n;   Photoshop.JPEGFile\n;\n; |> Tech info <|\n; The registry key layout for a file association is:\n; HKEY_CLASSES_ROOT\n;     <applicationID> = <\"description\">\n;         shell\n;             <verb> = <\"menu-item text\">\n;                 command = <\"command string\">\n;\n \n!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND\n  ; Backup the previously associated file class\n  ReadRegStr $R0 HKCR \".${EXT}\" \"\"\n  WriteRegStr HKCR \".${EXT}\" \"${FILECLASS}_backup\" \"$R0\"\n \n  WriteRegStr HKCR \".${EXT}\" \"\" \"${FILECLASS}\"\n \n  WriteRegStr HKCR \"${FILECLASS}\" \"\" `${DESCRIPTION}`\n  WriteRegStr HKCR \"${FILECLASS}\\DefaultIcon\" \"\" `${ICON}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\" \"\" \"open\"\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\open\" \"\" `${COMMANDTEXT}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\open\\command\" \"\" `${COMMAND}`\n!macroend\n \n!macro APP_ASSOCIATE_EX EXT FILECLASS DESCRIPTION ICON VERB DEFAULTVERB SHELLNEW COMMANDTEXT COMMAND\n  ; Backup the previously associated file class\n  ReadRegStr $R0 HKCR \".${EXT}\" \"\"\n  WriteRegStr HKCR \".${EXT}\" \"${FILECLASS}_backup\" \"$R0\"\n \n  WriteRegStr HKCR \".${EXT}\" \"\" \"${FILECLASS}\"\n  StrCmp \"${SHELLNEW}\" \"0\" +2\n  WriteRegStr HKCR \".${EXT}\\ShellNew\" \"NullFile\" \"\"\n \n  WriteRegStr HKCR \"${FILECLASS}\" \"\" `${DESCRIPTION}`\n  WriteRegStr HKCR \"${FILECLASS}\\DefaultIcon\" \"\" `${ICON}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\" \"\" `${DEFAULTVERB}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\${VERB}\" \"\" `${COMMANDTEXT}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\${VERB}\\command\" \"\" `${COMMAND}`\n!macroend\n \n!macro APP_ASSOCIATE_ADDVERB FILECLASS VERB COMMANDTEXT COMMAND\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\${VERB}\" \"\" `${COMMANDTEXT}`\n  WriteRegStr HKCR \"${FILECLASS}\\shell\\${VERB}\\command\" \"\" `${COMMAND}`\n!macroend\n \n!macro APP_ASSOCIATE_REMOVEVERB FILECLASS VERB\n  DeleteRegKey HKCR `${FILECLASS}\\shell\\${VERB}`\n!macroend\n \n \n!macro APP_UNASSOCIATE EXT FILECLASS\n  ; Backup the previously associated file class\n  ReadRegStr $R0 HKCR \".${EXT}\" `${FILECLASS}_backup`\n  WriteRegStr HKCR \".${EXT}\" \"\" \"$R0\"\n \n  DeleteRegKey HKCR `${FILECLASS}`\n!macroend\n \n!macro APP_ASSOCIATE_GETFILECLASS OUTPUT EXT\n  ReadRegStr ${OUTPUT} HKCR \".${EXT}\" \"\"\n!macroend\n \n \n; !defines for use with SHChangeNotify\n!ifdef SHCNE_ASSOCCHANGED\n!undef SHCNE_ASSOCCHANGED\n!endif\n!define SHCNE_ASSOCCHANGED 0x08000000\n!ifdef SHCNF_FLUSH\n!undef SHCNF_FLUSH\n!endif\n!define SHCNF_FLUSH        0x1000\n \n!macro UPDATEFILEASSOC\n; Using the system.dll plugin to call the SHChangeNotify Win32 API function so we\n; can update the shell.\n  System::Call \"shell32::SHChangeNotify(i,i,i,i) (${SHCNE_ASSOCCHANGED}, ${SHCNF_FLUSH}, 0, 0)\"\n!macroend\n \n;EOF\n"
  },
  {
    "path": "qt/mac/README.md",
    "content": "Helper library for macOS-specific functionality.\n"
  },
  {
    "path": "qt/mac/anki_mac_helper/__init__.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport sys\nfrom collections.abc import Callable\nfrom ctypes import CDLL, CFUNCTYPE, c_bool, c_char_p\nfrom pathlib import Path\n\n\nclass _MacOSHelper:\n    def __init__(self) -> None:\n        # Look for the dylib in the same directory as this module\n        module_dir = Path(__file__).parent\n        path = module_dir / \"libankihelper.dylib\"\n\n        self._dll = CDLL(str(path))\n        self._dll.system_is_dark.restype = c_bool\n\n    def system_is_dark(self) -> bool:\n        return self._dll.system_is_dark()\n\n    def set_darkmode_enabled(self, enabled: bool) -> bool:\n        return self._dll.set_darkmode_enabled(enabled)\n\n    def start_wav_record(self, path: str, on_error: Callable[[str], None]) -> None:\n        global _on_audio_error\n        _on_audio_error = on_error\n        self._dll.start_wav_record(path.encode(\"utf8\"), _audio_error_callback)\n\n    def end_wav_record(self) -> None:\n        \"On completion, file should be saved if no error has arrived.\"\n        self._dll.end_wav_record()\n\n    def disable_appnap(self) -> None:\n        self._dll.disable_appnap()\n\n    def enable_appnap(self) -> None:\n        self._dll.enable_appnap()\n\n\n# this must not be overwritten or deallocated\n@CFUNCTYPE(None, c_char_p)  # type: ignore\ndef _audio_error_callback(msg: str) -> None:\n    if handler := _on_audio_error:\n        handler(msg)\n\n\n_on_audio_error: Callable[[str], None] | None = None\n\nmacos_helper: _MacOSHelper | None = None\nif sys.platform == \"darwin\":\n    try:\n        macos_helper = _MacOSHelper()\n    except Exception as e:\n        print(\"macos_helper:\", e)\n"
  },
  {
    "path": "qt/mac/anki_mac_helper/py.typed",
    "content": ""
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 55;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t137892AC275D90FC009D0B6E /* theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AB275D90FC009D0B6E /* theme.swift */; };\n\t\t137892B0275DAE22009D0B6E /* record.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AF275DAE22009D0B6E /* record.swift */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXFileReference section */\n\t\t137892AB275D90FC009D0B6E /* theme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = theme.swift; sourceTree = \"<group>\"; };\n\t\t137892AF275DAE22009D0B6E /* record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = record.swift; sourceTree = \"<group>\"; };\n\t\t138B770F2746137F003A3E4F /* libankihelper.dylib */ = {isa = PBXFileReference; explicitFileType = \"compiled.mach-o.dylib\"; includeInIndex = 0; path = libankihelper.dylib; sourceTree = BUILT_PRODUCTS_DIR; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t138B770D2746137F003A3E4F /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t138B77062746137F003A3E4F = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t137892AB275D90FC009D0B6E /* theme.swift */,\n\t\t\t\t137892AF275DAE22009D0B6E /* record.swift */,\n\t\t\t\t138B77102746137F003A3E4F /* Products */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t138B77102746137F003A3E4F /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t138B770F2746137F003A3E4F /* libankihelper.dylib */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXHeadersBuildPhase section */\n\t\t138B770B2746137F003A3E4F /* Headers */ = {\n\t\t\tisa = PBXHeadersBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXHeadersBuildPhase section */\n\n/* Begin PBXNativeTarget section */\n\t\t138B770E2746137F003A3E4F /* ankihelper */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 138B77182746137F003A3E4F /* Build configuration list for PBXNativeTarget \"ankihelper\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t138B770B2746137F003A3E4F /* Headers */,\n\t\t\t\t138B770C2746137F003A3E4F /* Sources */,\n\t\t\t\t138B770D2746137F003A3E4F /* Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = ankihelper;\n\t\t\tproductName = ankihelper;\n\t\t\tproductReference = 138B770F2746137F003A3E4F /* libankihelper.dylib */;\n\t\t\tproductType = \"com.apple.product-type.library.dynamic\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t138B77072746137F003A3E4F /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = 1;\n\t\t\t\tLastUpgradeCheck = 1310;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t138B770E2746137F003A3E4F = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 13.1;\n\t\t\t\t\t\tLastSwiftMigration = 1310;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 138B770A2746137F003A3E4F /* Build configuration list for PBXProject \"ankihelper\" */;\n\t\t\tcompatibilityVersion = \"Xcode 13.0\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 138B77062746137F003A3E4F;\n\t\t\tproductRefGroup = 138B77102746137F003A3E4F /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t138B770E2746137F003A3E4F /* ankihelper */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t138B770C2746137F003A3E4F /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t137892B0275DAE22009D0B6E /* record.swift in Sources */,\n\t\t\t\t137892AC275D90FC009D0B6E /* theme.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin XCBuildConfiguration section */\n\t\t138B77162746137F003A3E4F /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 11;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t138B77172746137F003A3E4F /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 11;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t138B77192746137F003A3E4F /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = 7ZM8SLJM4P;\n\t\t\t\tDYLIB_COMPATIBILITY_VERSION = 1;\n\t\t\t\tDYLIB_CURRENT_VERSION = 1;\n\t\t\t\tEXECUTABLE_PREFIX = lib;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@loader_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t138B771A2746137F003A3E4F /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = 7ZM8SLJM4P;\n\t\t\t\tDYLIB_COMPATIBILITY_VERSION = 1;\n\t\t\t\tDYLIB_CURRENT_VERSION = 1;\n\t\t\t\tEXECUTABLE_PREFIX = lib;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@loader_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t138B770A2746137F003A3E4F /* Build configuration list for PBXProject \"ankihelper\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t138B77162746137F003A3E4F /* Debug */,\n\t\t\t\t138B77172746137F003A3E4F /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t138B77182746137F003A3E4F /* Build configuration list for PBXNativeTarget \"ankihelper\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t138B77192746137F003A3E4F /* Debug */,\n\t\t\t\t138B771A2746137F003A3E4F /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 138B77072746137F003A3E4F /* Project object */;\n}\n"
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/xcschemes/xcschememanagement.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict />\n</plist>\n"
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/ankihelper.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   version = \"1.3\">\n   <BuildAction>\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForRunning = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"138B770E2746137F003A3E4F\"\n               BuildableName = \"libankihelper.dylib\"\n               BlueprintName = \"ankihelper\"\n               ReferencedContainer = \"container:ankihelper.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <LaunchAction\n      useCustomWorkingDirectory = \"NO\"\n      buildConfiguration = \"Debug\"\n      allowLocationSimulation = \"YES\">\n   </LaunchAction>\n</Scheme>\n"
  },
  {
    "path": "qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/xcschememanagement.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>SchemeUserState</key>\n\t<dict>\n\t\t<key>ankihelper.xcscheme</key>\n\t\t<dict>\n\t\t\t<key>orderHint</key>\n\t\t\t<integer>0</integer>\n\t\t</dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "qt/mac/appnap.swift",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport Foundation\n\nprivate var currentActivity: NSObjectProtocol?\n\n@_cdecl(\"disable_appnap\")\npublic func disableAppNap() {\n    // No-op if already assigned\n    guard currentActivity == nil else { return }\n    \n    currentActivity = ProcessInfo.processInfo.beginActivity(\n        options: .userInitiatedAllowingIdleSystemSleep,\n        reason: \"AppNap is disabled\"\n    )\n}\n\n@_cdecl(\"enable_appnap\")\npublic func enableAppNap() {\n    guard let activity = currentActivity else { return }\n    \n    ProcessInfo.processInfo.endActivity(activity)\n    currentActivity = nil\n}"
  },
  {
    "path": "qt/mac/build.sh",
    "content": "#!/bin/bash\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nset -e\n\n# Get the project root directory\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJ_ROOT=\"$(cd \"$SCRIPT_DIR/../..\" && pwd)\"\n\n# Build the dylib first\necho \"Building macOS helper dylib...\"\n\"$PROJ_ROOT/out/pyenv/bin/python\" \"$SCRIPT_DIR/helper_build.py\"\n\n# Create the wheel using uv\necho \"Creating wheel...\"\ncd \"$SCRIPT_DIR\"\nrm -rf dist\n\"$PROJ_ROOT/out/extracted/uv/uv\" build --wheel\n\necho \"Build complete!\"\n"
  },
  {
    "path": "qt/mac/helper_build.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport subprocess\nimport sys\nfrom pathlib import Path\n\n# If no arguments provided, build for the anki_mac_helper package\nif len(sys.argv) == 1:\n    script_dir = Path(__file__).parent\n    out_dylib = script_dir / \"anki_mac_helper\" / \"libankihelper.dylib\"\n    src_files = list(script_dir.glob(\"*.swift\"))\nelse:\n    out_dylib, *src_files = sys.argv[1:]\n\nout_dylib = Path(out_dylib)\nout_dir = out_dylib.parent.resolve()\nsrc_dir = Path(src_files[0]).parent.resolve()\n\n# Build for both architectures\narchitectures = [\"arm64\", \"x86_64\"]\ntemp_files = []\n\nfor arch in architectures:\n    target = f\"{arch}-apple-macos11\"\n    temp_out = out_dir / f\"temp_{arch}.dylib\"\n    temp_files.append(temp_out)\n\n    args = [\n        \"swiftc\",\n        \"-target\",\n        target,\n        \"-emit-library\",\n        \"-module-name\",\n        \"ankihelper\",\n        \"-O\",\n    ]\n    if isinstance(src_files[0], Path):\n        args.extend(src_files)\n    else:\n        args.extend(src_dir / Path(file).name for file in src_files)\n    args.extend([\"-o\", str(temp_out)])\n    subprocess.run(args, check=True, cwd=out_dir)\n\n# Ensure output directory exists\nout_dir.mkdir(parents=True, exist_ok=True)\n\n# Create universal binary\nlipo_args = [\"lipo\", \"-create\", \"-output\", str(out_dylib)] + [\n    str(f) for f in temp_files\n]\nsubprocess.run(lipo_args, check=True)\n\n# Clean up temporary files\nfor temp_file in temp_files:\n    temp_file.unlink()\n"
  },
  {
    "path": "qt/mac/pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"anki-mac-helper\"\nversion = \"0.1.1\"\ndescription = \"Small support library for Anki on Macs\"\nrequires-python = \">=3.9\"\nlicense = { text = \"AGPL-3.0-or-later\" }\nauthors = [\n  { name = \"Anki Team\" },\n]\nurls = { Homepage = \"https://github.com/ankitects/anki\" }\n\n[tool.hatch.build.targets.wheel]\npackages = [\"anki_mac_helper\"]\n"
  },
  {
    "path": "qt/mac/record.swift",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport Foundation\nimport AVKit\n\nenum RecordError: Error {\n    case noPermission\n    case audioFormat\n    case recordInvoke\n    case stoppedWithFailure\n    case encodingFailure\n}\n\n@_cdecl(\"start_wav_record\")\npublic func startWavRecord(\n    path: UnsafePointer<CChar>,\n    onError: @escaping @convention(c) (UnsafePointer<CChar>) -> Void\n) {\n    let url = URL(fileURLWithPath: String(cString: path))\n    AudioRecorder.shared.beginRecording(url: url, onError: { error in\n        error.localizedDescription.withCString { cString in\n            onError(cString)\n        }\n    })\n}\n\n@_cdecl(\"end_wav_record\")\npublic func endWavRecord() {\n    AudioRecorder.shared.endRecording()\n}\n\n\nprivate class AudioRecorder: NSObject, AVAudioRecorderDelegate {\n    static let shared = AudioRecorder()\n\n    private var audioRecorder: AVAudioRecorder?\n    private var onError: ((RecordError) -> Void)?\n\n    func beginRecording(url: URL, onError: @escaping (Error) -> Void) {\n        self.endRecording()\n\n        requestPermission { success in\n            if !success {\n                onError(RecordError.noPermission)\n                return\n            }\n\n            do {\n                try self.beginRecordingInner(url: url)\n            } catch {\n                onError(error)\n                return\n            }\n            self.onError = onError\n        }\n\n    }\n\n    func endRecording() {\n        if let recorder = audioRecorder {\n            recorder.stop()\n        }\n        audioRecorder = nil\n        onError = nil\n    }\n\n    /// Request permission, then call provided callback (true on success).\n    private func requestPermission(completionHandler: @escaping (Bool) -> Void) {\n        switch AVCaptureDevice.authorizationStatus(for: .audio) {\n            case .notDetermined:\n                AVCaptureDevice.requestAccess(\n                    for: .audio,\n                    completionHandler: completionHandler\n                )\n                return\n            case .authorized:\n                completionHandler(true)\n                return\n            case .restricted:\n                print(\"recording restricted\")\n            case .denied:\n                print(\"recording denied\")\n            @unknown default:\n                print(\"recording unknown permission\")\n        }\n        completionHandler(false)\n    }\n\n    private func beginRecordingInner(url: URL) throws {\n        guard let audioFormat = AVAudioFormat.init(\n            commonFormat: .pcmFormatInt16,\n            sampleRate: 44100,\n            channels: 1,\n            interleaved: true\n        ) else {\n            throw RecordError.audioFormat\n        }\n        let recorder = try AVAudioRecorder(url: url, format: audioFormat)\n        if !recorder.record() {\n            throw RecordError.recordInvoke\n        }\n        audioRecorder = recorder\n    }\n\n\n    func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {\n        if !flag {\n            onError?(.stoppedWithFailure)\n        }\n    }\n\n\n    func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {\n        onError?(.encodingFailure)\n    }\n}\n"
  },
  {
    "path": "qt/mac/theme.swift",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport AppKit\nimport Foundation\n\n/// Force our app to be either light or dark mode.\n@_cdecl(\"set_darkmode_enabled\")\npublic func setDarkmodeEnabled(_ enabled: Bool) {\n    NSApplication.shared.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)\n}\n\n/// True if the system is set to dark mode.\n@_cdecl(\"system_is_dark\")\npublic func systemIsDark() -> Bool {\n    let styleSet = UserDefaults.standard.object(forKey: \"AppleInterfaceStyle\") != nil\n    return styleSet\n}\n"
  },
  {
    "path": "qt/mac/update-launcher-env",
    "content": "#!/bin/bash\n#\n# Build and install into the launcher venv\n\nset -e\n\n./build.sh\nif [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n    export VIRTUAL_ENV=$HOME/Library/Application\\ Support/AnkiProgramFiles/.venv\nelse\n    export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv\nfi\n../../out/extracted/uv/uv pip install dist/*.whl\n \n"
  },
  {
    "path": "qt/pyproject.toml",
    "content": "[project]\nname = \"aqt\"\ndynamic = [\"version\"]\nrequires-python = \">=3.9\"\nlicense = \"AGPL-3.0-or-later\"\ndependencies = [\n  \"beautifulsoup4\",\n  \"flask\",\n  \"flask_cors\",\n  \"jsonschema\",\n  \"requests\",\n  \"send2trash\",\n  \"waitress>=2.0.0\",\n  \"pywin32; sys.platform == 'win32'\",\n  \"anki-mac-helper>=0.1.1; sys.platform == 'darwin'\",\n  \"pip-system-certs!=5.1\",\n  \"pyqt6>=6.2\",\n  \"pyqt6-webengine>=6.2\",\n  # anki dependency is added dynamically in hatch_build.py with exact version\n]\n\n[project.optional-dependencies]\naudio = [\n  \"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\",\n]\nqt66 = [\n  \"pyqt6==6.6.1\",\n  \"pyqt6-qt6==6.6.2\",\n  \"pyqt6-webengine==6.6.0\",\n  \"pyqt6-webengine-qt6==6.6.2\",\n  \"pyqt6_sip==13.6.0\",\n]\nqt67 = [\n  \"pyqt6==6.7.1\",\n  \"pyqt6-qt6==6.7.3\",\n  \"pyqt6-webengine==6.7.0\",\n  \"pyqt6-webengine-qt6==6.7.3\",\n  \"pyqt6_sip==13.10.2\",\n]\nqt = [\n  \"pyqt6==6.9.1\",\n  \"pyqt6-qt6==6.9.1\",\n  \"pyqt6-webengine==6.8.0\",\n  \"pyqt6-webengine-qt6==6.8.2\",\n  \"pyqt6_sip==13.10.2\",\n]\nqt68 = [\n  \"pyqt6==6.8.0\",\n  \"pyqt6-qt6==6.8.1\",\n  \"pyqt6-webengine==6.8.0\",\n  \"pyqt6-webengine-qt6==6.8.1\",\n  \"pyqt6_sip==13.10.2\",\n]\n\n[tool.uv]\nconflicts = [\n  [\n    { extra = \"qt\" },\n    { extra = \"qt66\" },\n    { extra = \"qt67\" },\n    { extra = \"qt68\" },\n  ],\n]\n\n[tool.uv.sources]\nanki = { workspace = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project.scripts]\nanki = \"aqt:run\"\n\n[project.gui-scripts]\nankiw = \"aqt:run\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"aqt\"]\nexclude = [\"aqt/data\", \"**/*.ui\"]\n\n[tool.hatch.version]\nsource = \"code\"\npath = \"../python/version.py\"\n\n[tool.hatch.build.hooks.custom]\npath = \"hatch_build.py\"\n"
  },
  {
    "path": "qt/release/.gitignore",
    "content": "pyproject.toml\npyproject.toml.old"
  },
  {
    "path": "qt/release/build.sh",
    "content": "#!/bin/bash\n\nset -e\n\ntest -f build.sh || {\n  echo \"run from release folder\"\n  exit 1\n}\n\n# Get the project root (two levels up from qt/release)\nPROJ_ROOT=\"$(cd \"$(dirname \"$0\")/../..\" && pwd)\"\n\n# Use extracted uv binary\nUV=\"$PROJ_ROOT/out/extracted/uv/uv\"\n\n# Read version from .version file\nVERSION=$(cat \"$PROJ_ROOT/.version\" | tr -d '[:space:]')\n\n# Copy existing pyproject.toml to .old if it exists\nif [ -f pyproject.toml ]; then\n    cp pyproject.toml pyproject.toml.old\nfi\n\n# Export dependencies using uv\necho \"Exporting dependencies...\"\nrm -f pyproject.toml\nDEPS=$(cd \"$PROJ_ROOT\" && \"$UV\" export --no-hashes --no-annotate --no-header --extra audio --extra qt --all-packages --no-dev --no-emit-workspace)\n\n# Generate the pyproject.toml file\ncat > pyproject.toml << EOF\n[project]\nname = \"anki-release\"\nversion = \"$VERSION\"\ndescription = \"A package to lock Anki's dependencies\"\nrequires-python = \">=3.9\"\ndependencies = [\n  \"anki==$VERSION\",\n  \"aqt==$VERSION\",\nEOF\n\n# Add the exported dependencies to the file\necho \"$DEPS\" | while IFS= read -r line; do\n    if [[ -n \"$line\" ]]; then\n        echo \"  \\\"$line\\\",\" >> pyproject.toml\n    fi\ndone\n\n# Complete the pyproject.toml file\ncat >> pyproject.toml << 'EOF'\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n# hatch throws an error if nothing is included\n[tool.hatch.build.targets.wheel]\ninclude = [\"no-such-file\"]\nEOF\n\necho \"Generated pyproject.toml with version $VERSION\"\n\n# Show diff if .old file exists\nif [ -f pyproject.toml.old ]; then\n    echo\n    echo \"Differences from previous release version:\"\n    diff -u --color=always pyproject.toml.old pyproject.toml || true\nfi\n\necho \"Building wheel...\"\n\"$UV\" build --wheel --out-dir \"$PROJ_ROOT/out/wheels\"\n"
  },
  {
    "path": "qt/runanki.py",
    "content": "#!/usr/bin/env python3\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport sys\n\ntry:\n    import bazelfixes\n\n    bazelfixes.fix_pywin32_in_bazel()\n    bazelfixes.fix_extraneous_path_in_bazel()\n    bazelfixes.fix_run_on_macos()\nexcept ImportError:\n    pass\n\nimport aqt\n\nif not os.environ.get(\"ANKI_IMPORT_ONLY\"):\n    aqt.run()\n"
  },
  {
    "path": "qt/tests/__init__.py",
    "content": ""
  },
  {
    "path": "qt/tests/test_addons.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os.path\nfrom tempfile import TemporaryDirectory\nfrom zipfile import ZipFile\n\nfrom mock import MagicMock\n\nfrom aqt.addons import AddonManager, package_name_valid\n\n\ndef test_readMinimalManifest():\n    assertReadManifest(\n        '{\"package\": \"yes\", \"name\": \"no\"}', {\"package\": \"yes\", \"name\": \"no\"}\n    )\n\n\ndef test_readExtraKeys():\n    assertReadManifest(\n        '{\"package\": \"a\", \"name\": \"b\", \"mod\": 3, \"conflicts\": [\"d\", \"e\"]}',\n        {\"package\": \"a\", \"name\": \"b\", \"mod\": 3, \"conflicts\": [\"d\", \"e\"]},\n    )\n\n\ndef test_invalidManifest():\n    assertReadManifest('{\"one\": 1}', {})\n\n\ndef test_mustHaveName():\n    assertReadManifest('{\"package\": \"something\"}', {})\n\n\ndef test_mustHavePackage():\n    assertReadManifest('{\"name\": \"something\"}', {})\n\n\ndef test_invalidJson():\n    assertReadManifest(\"this is not a JSON dictionary\", {})\n\n\ndef test_missingManifest():\n    assertReadManifest(\n        '{\"package\": \"what\", \"name\": \"ever\"}', {}, nameInZip=\"not-manifest.bin\"\n    )\n\n\ndef test_ignoreExtraKeys():\n    assertReadManifest(\n        '{\"package\": \"a\", \"name\": \"b\", \"game\": \"c\"}', {\"package\": \"a\", \"name\": \"b\"}\n    )\n\n\ndef test_conflictsMustBeStrings():\n    assertReadManifest(\n        '{\"package\": \"a\", \"name\": \"b\", \"conflicts\": [\"c\", 4, {\"d\": \"e\"}]}', {}\n    )\n\n\ndef assertReadManifest(contents, expectedManifest, nameInZip=\"manifest.json\"):\n    with TemporaryDirectory() as td:\n        zfn = os.path.join(td, \"addon.zip\")\n        with ZipFile(zfn, \"w\") as zfile:\n            zfile.writestr(nameInZip, contents)\n\n        adm = AddonManager(MagicMock())\n\n        with ZipFile(zfn, \"r\") as zfile:\n            assert adm.readManifestFile(zfile) == expectedManifest\n\n\ndef test_package_name_validation():\n    assert not package_name_valid(\"\")\n    assert not package_name_valid(\"/\")\n    assert not package_name_valid(\"a/b\")\n    assert not package_name_valid(\"..\")\n    assert package_name_valid(\"ab\")\n"
  },
  {
    "path": "qt/tests/test_i18n.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport anki.lang\n\n\ndef test_no_collection_i18n():\n    anki.lang.set_lang(\"zz\")\n    tr = anki.lang.tr_legacyglobal\n    no_uni = anki.lang.without_unicode_isolation\n    assert no_uni(tr.statistics_reviews(reviews=2)) == \"2 reviews\"\n\n    anki.lang.set_lang(\"ja\")\n    assert no_uni(tr.statistics_reviews(reviews=2)) == \"2枚\"\n\n\ndef test_legacy_enum():\n    anki.lang.set_lang(\"ja\")\n    TR = anki.lang.TR\n    tr = anki.lang.tr_legacyglobal\n    no_uni = anki.lang.without_unicode_isolation\n\n    assert no_uni(tr(TR.STATISTICS_REVIEWS, reviews=2)) == \"2枚\"\n"
  },
  {
    "path": "qt/tools/build_qrc.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport sys\n\nqrc_file = os.path.abspath(sys.argv[1])\nicons = sys.argv[2:]\n\nfile_skeleton = \"\"\"\n<RCC>\n    <qresource prefix=\"/\">\nFILES\n    </qresource>\n</RCC>\n\"\"\".strip()\n\nindent = \" \" * 8\nlines = []\nfor icon in icons:\n    base = os.path.basename(icon)\n    path = os.path.relpath(icon, start=os.path.dirname(qrc_file))\n    line = f'{indent}<file alias=\"icons/{base}\">{path}</file>'\n    lines.append(line)\n\nwith open(qrc_file, \"w\") as file:\n    file.write(file_skeleton.replace(\"FILES\", \"\\n\".join(lines)))\n"
  },
  {
    "path": "qt/tools/build_ui.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\nimport io\nimport re\nimport sys\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom PyQt6.uic import compileUi\n\n\ndef compile(ui_file: str | Path) -> str:\n    buf = io.StringIO()\n    with open(ui_file) as f:\n        compileUi(f, buf)\n    return buf.getvalue()\n\n\ndef with_fixes_for_qt6(code: str) -> str:\n    code = code.replace(\n        \"from PyQt6 import QtCore, QtGui, QtWidgets\",\n        \"from PyQt6 import QtCore, QtGui, QtWidgets\\nfrom aqt.utils import tr\\n\",\n    )\n    code = re.sub(\n        r'(?:QtGui\\.QApplication\\.)?_?translate\\(\".*?\", \"(.*?)\"', \"tr.\\\\1(\", code\n    )\n    outlines = []\n    qt_bad_types = [\n        \".connect(\",\n    ]\n    for line in code.splitlines():\n        for substr in qt_bad_types:\n            if substr in line:\n                line = line + \"  # type: ignore\"\n                break\n        if line == \"from . import icons_rc\":\n            continue\n        line = line.replace(\":/icons/\", \"icons:\")\n        line = line.replace(\n            \"QAction.PreferencesRole\", \"QAction.MenuRole.PreferencesRole\"\n        )\n        line = line.replace(\"QAction.AboutRole\", \"QAction.MenuRole.AboutRole\")\n        outlines.append(line)\n    return \"\\n\".join(outlines)\n\n\n@dataclass\nclass UiFileAndOutputs:\n    ui_file: Path\n    qt6_file: str\n\n\ndef get_files() -> list[UiFileAndOutputs]:\n    \"The ui->py map, and output __init__.py path\"\n    ui_folder = Path(\"qt/aqt/forms\")\n    out_folder = Path(sys.argv[1]).parent\n    out = []\n    for path in ui_folder.iterdir():\n        if path.suffix == \".ui\":\n            outpath = str(out_folder / path.name)\n            out.append(\n                UiFileAndOutputs(\n                    ui_file=path,\n                    qt6_file=outpath.replace(\".ui\", \"_qt6.py\"),\n                )\n            )\n    return out\n\n\nif __name__ == \"__main__\":\n    for entry in get_files():\n        stock = compile(entry.ui_file)\n        for_qt6 = with_fixes_for_qt6(stock)\n        with open(entry.qt6_file, \"w\") as file:\n            file.write(for_qt6)\n"
  },
  {
    "path": "qt/tools/color_svg.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport re\nimport sys\nfrom pathlib import Path\n\nsys.path.append(\"out/qt\")\nfrom _aqt import colors\n\ninput_path = Path(sys.argv[1])\ninput_name = input_path.stem\ncolor_names = sys.argv[2].split(\":\")\n\n# two files created for each additional color\noffset = len(color_names) * 2\nsvg_paths = sys.argv[3 : 3 + offset]\n\nwith open(input_path, \"r\") as f:\n    svg_data = f.read()\n\n    for color_name in color_names:\n        color = getattr(colors, color_name)\n        light_svg = dark_svg = \"\"\n\n        if color_name == \"FG\":\n            prefix = input_name\n        else:\n            prefix = f\"{input_name}-{color_name}\"\n\n        for path in svg_paths:\n            if f\"{prefix}-light.svg\" in path:\n                light_svg = path\n            elif f\"{prefix}-dark.svg\" in path:\n                dark_svg = path\n\n        def substitute(data: str, filename: str, mode: str) -> None:\n            if \"fill\" in data:\n                data = re.sub(r\"fill=\\\"#.+?\\\"\", f'fill=\"{color[mode]}\"', data)\n            else:\n                data = re.sub(r\"<svg\", f'<svg fill=\"{color[mode]}\"', data, count=1)\n            with open(filename, \"w\") as f:\n                f.write(data)\n\n        substitute(svg_data, light_svg, \"light\")\n        substitute(svg_data, dark_svg, \"dark\")\n"
  },
  {
    "path": "qt/tools/extract_sass_vars.py",
    "content": "#!/usr/bin/env python3\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport json\nimport re\nimport sys\n\n# bazel genrule \"srcs\"\nroot_vars_css = sys.argv[1]\n\n# bazel genrule \"outs\"\ncolors_py = sys.argv[2]\nprops_py = sys.argv[3]\n\ncolors: dict[str, dict[str, str]] = {}\nprops: dict[str, dict[str, str]] = {}\nreached_props = False\ncomment = \"\"\n\nwith open(root_vars_css) as f:\n    data = f.read()\nfor line in re.split(r\"[;\\{\\}]|\\*\\/\", data):\n    line = line.strip()\n\n    if not line:\n        continue\n    if line.startswith(\"/*!\"):\n        if \"props\" in line:\n            reached_props = True\n        elif \"rest\" in line:\n            break\n        else:\n            comment = re.match(r\"\\/\\*!\\s*(.*)$\", line)[1]\n        continue\n\n    m = re.match(r\"--(.+):(.+)$\", line)\n\n    if not m:\n        if (\n            line != \"}\"\n            and \":root\" not in line\n            and \"Copyright\" not in line\n            and \"License\" not in line\n            and \"color-scheme\" not in line\n            and \"sourceMappingURL\" not in line\n        ):\n            print(\"failed to match\", line)\n        continue\n\n    # convert variable names to Qt style\n    var = m.group(1).replace(\"-\", \"_\").upper()\n    val = m.group(2)\n\n    if reached_props:\n        # remove trailing ms from time props\n        val = re.sub(r\"^(\\d+)ms$\", r\"\\1\", val)\n\n        if var not in props:\n            props.setdefault(var, {})[\"comment\"] = comment\n            props[var][\"light\"] = val\n        else:\n            props[var][\"dark\"] = val\n    else:\n        if var not in colors:\n            colors.setdefault(var, {})[\"comment\"] = comment\n            colors[var][\"light\"] = val\n        else:\n            colors[var][\"dark\"] = val\n\n    comment = \"\"\n\n\ncopyright_notice = \"\"\"\\\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\\n\n\"\"\"\n\nwith open(colors_py, \"w\") as buf:\n    buf.write(copyright_notice)\n    buf.write(\"# This file was automatically generated from _root-vars.scss\\n\")\n\n    for color, val in colors.items():\n        if \"dark\" not in val:\n            val[\"dark\"] = val[\"light\"]\n\n        buf.write(re.sub(r\"\\\"\\n\", '\",\\n', f\"{color} = {json.dumps(val, indent=4)}\\n\"))\n\n\nwith open(props_py, \"w\") as buf:\n    buf.write(copyright_notice)\n    buf.write(\"# This file was automatically generated from _root-vars.scss\\n\")\n\n    for prop, val in props.items():\n        if \"dark\" not in val:\n            val[\"dark\"] = val[\"light\"]\n\n        buf.write(re.sub(r\"\\\"\\n\", '\",\\n', f\"{prop} = {json.dumps(val, indent=4)}\\n\"))\n"
  },
  {
    "path": "qt/tools/genhooks_gui.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nSee pylib/tools/genhooks.py for more info.\n\"\"\"\n\nimport sys\n\nsys.path.append(\"pylib/tools\")\n\nfrom hookslib import Hook, write_file\n\nprefix = \"\"\"\\\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# This file is automatically generated; edit tools/genhooks_gui.py instead.\n# Please import from anki.hooks instead of this file.\n\nfrom __future__ import annotations\n\nfrom typing import Any, Callable, Sequence, Literal, Type\n\nimport anki\nimport aqt\nfrom anki.cards import Card\nfrom anki.decks import DeckDict, DeckConfigDict\nfrom anki.hooks import runFilter, runHook\nfrom anki.models import NotetypeDict\nfrom anki.collection import OpChangesAfterUndo\nfrom aqt.qt import QDialog, QEvent, QMenu, QModelIndex, QWidget, QMimeData\nfrom aqt.tagedit import TagEdit\nfrom aqt.undo import UndoActionsInfo\n\"\"\"\n\n# Hook list\n######################################################################\n\nhooks = [\n    # Reviewing\n    ###################\n    Hook(\n        name=\"overview_did_refresh\",\n        args=[\"overview: aqt.overview.Overview\"],\n        doc=\"\"\"Allow to update the overview window. E.g. add the deck name in the\n        title.\"\"\",\n    ),\n    Hook(\n        name=\"overview_will_render_content\",\n        args=[\n            \"overview: aqt.overview.Overview\",\n            \"content: aqt.overview.OverviewContent\",\n        ],\n        doc=\"\"\"Used to modify HTML content sections in the overview body\n\n        'content' contains the sections of HTML content the overview body\n        will be updated with.\n\n        When modifying the content of a particular section, please make sure your\n        changes only perform the minimum required edits to make your add-on work.\n        You should avoid overwriting or interfering with existing data as much\n        as possible, instead opting to append your own changes, e.g.:\n\n            def on_overview_will_render_content(overview, content):\n                content.table += \"\\n<div>my html</div>\"\n        \"\"\",\n    ),\n    Hook(\n        name=\"overview_will_render_bottom\",\n        args=[\n            \"link_handler: Callable[[str], bool]\",\n            \"links: list[list[str]]\",\n        ],\n        return_type=\"Callable[[str], bool]\",\n        doc=\"\"\"Allows adding buttons to the Overview bottom bar.\n\n        Append a list of strings to 'links' argument to add new buttons.\n        - The first value is the shortcut to appear in the tooltip.\n        - The second value is the url to be triggered.\n        - The third value is the text of the new button.\n\n        Extend the callable 'link_handler' to handle new urls. This callable\n        accepts one argument: the triggered url.\n        Make a check of the triggered url, call any functions related to\n        that trigger, and return the new link_handler.\n\n        Example:\n        links.append(['H', 'hello', 'Click me!'])\n        def custom_link_handler(url):\n            if url == 'hello':\n                print('Hello World!')\n            return link_handler(url=url)\n        return custom_link_handler\n        \"\"\",\n    ),\n    Hook(\n        name=\"reviewer_did_show_question\",\n        args=[\"card: Card\"],\n        legacy_hook=\"showQuestion\",\n        legacy_no_args=True,\n    ),\n    Hook(\n        name=\"reviewer_will_compare_answer\",\n        args=[\n            \"expected_provided_tuple: tuple[str, str]\",\n            \"type_pattern: str\",\n        ],\n        return_type=\"tuple[str, str]\",\n        doc=\"\"\"Modify expected answer and provided answer before comparing\n\n        expected_provided_tuple is a tuple composed of:\n        - expected answer\n        - provided answer\n        type_pattern is the detail of the type tag on the card\n\n        Return a tuple composed of:\n        - modified expected answer\n        - modified provided answer\n        \"\"\",\n    ),\n    Hook(\n        name=\"reviewer_will_render_compared_answer\",\n        args=[\n            \"output: str\",\n            \"initial_expected: str\",\n            \"initial_provided: str\",\n            \"type_pattern: str\",\n        ],\n        return_type=\"str\",\n        doc=\"\"\"Modify the output of default compare answer feature\n\n        output is the result of default compare answer function\n        initial_expected is the expected answer from the card\n        initial_provided is the answer provided during review\n        type_pattern is the detail of the type tag on the card\n\n        Return a string comparing expected and provided answers\n        \"\"\",\n    ),\n    Hook(\n        name=\"reviewer_did_show_answer\",\n        args=[\"card: Card\"],\n        legacy_hook=\"showAnswer\",\n        legacy_no_args=True,\n    ),\n    Hook(\n        name=\"reviewer_will_init_answer_buttons\",\n        args=[\n            \"buttons_tuple: tuple[tuple[int, str], ...]\",\n            \"reviewer: aqt.reviewer.Reviewer\",\n            \"card: Card\",\n        ],\n        return_type=\"tuple[tuple[int, str], ...]\",\n        doc=\"\"\"Used to modify list of answer buttons\n\n        buttons_tuple is a tuple of buttons, with each button represented by a\n        tuple containing an int for the button's ease and a string for the\n        button's label.\n\n        Return a tuple of the form ((int, str), ...), e.g.:\n            ((1, \"Label1\"), (2, \"Label2\"), ...)\n\n        Note: import _ from anki.lang to support translation, using, e.g.,\n            ((1, _(\"Label1\")), ...)\n        \"\"\",\n    ),\n    Hook(\n        name=\"reviewer_will_answer_card\",\n        args=[\n            \"ease_tuple: tuple[bool, Literal[1, 2, 3, 4]]\",\n            \"reviewer: aqt.reviewer.Reviewer\",\n            \"card: Card\",\n        ],\n        return_type=\"tuple[bool, Literal[1, 2, 3, 4]]\",\n        doc=\"\"\"Used to modify the ease at which a card is rated or to bypass\n        rating the card completely.\n\n        ease_tuple is a tuple consisting of a boolean expressing whether the reviewer\n        should continue with rating the card, and an integer expressing the ease at\n        which the card should be rated.\n\n        If your code just needs to be notified of the card rating event, you should use\n        the reviewer_did_answer_card hook instead.\"\"\",\n    ),\n    Hook(\n        name=\"reviewer_did_answer_card\",\n        args=[\n            \"reviewer: aqt.reviewer.Reviewer\",\n            \"card: Card\",\n            \"ease: Literal[1, 2, 3, 4]\",\n        ],\n    ),\n    Hook(\n        name=\"reviewer_will_show_context_menu\",\n        args=[\"reviewer: aqt.reviewer.Reviewer\", \"menu: QMenu\"],\n        legacy_hook=\"Reviewer.contextMenuEvent\",\n    ),\n    Hook(\n        name=\"reviewer_will_end\",\n        legacy_hook=\"reviewCleanup\",\n        doc=\"Called before Anki transitions from the review screen to another screen.\",\n    ),\n    Hook(\n        name=\"reviewer_will_play_question_sounds\",\n        args=[\"card: Card\", \"tags: list[anki.sound.AVTag]\"],\n        doc=\"\"\"Called before showing the question/front side.\n\n        `tags` can be used to inspect and manipulate the sounds\n        that will be played (if any).\n\n        This won't be called when the user manually plays sounds\n        using `Replay Audio`.\n\n        Note that this hook is called even when the `Automatically play audio`\n        option is unchecked; This is so as to allow playing custom\n        sounds regardless of that option.\"\"\",\n    ),\n    Hook(\n        name=\"reviewer_will_play_answer_sounds\",\n        args=[\"card: Card\", \"tags: list[anki.sound.AVTag]\"],\n        doc=\"\"\"Called before showing the answer/back side.\n\n        `tags` can be used to inspect and manipulate the sounds\n        that will be played (if any).\n\n        This won't be called when the user manually plays sounds\n        using `Replay Audio`.\n\n        Note that this hook is called even when the `Automatically play audio`\n        option is unchecked; This is so as to allow playing custom\n        sounds regardless of that option.\"\"\",\n    ),\n    Hook(\n        name=\"reviewer_will_replay_recording\",\n        args=[\"path: str\"],\n        return_type=\"str\",\n        doc=\"\"\"Used to inspect and modify a recording recorded by \"Record Own Voice\" before replaying.\"\"\",\n    ),\n    Hook(\n        name=\"reviewer_will_suspend_note\",\n        args=[\"nid: int\"],\n    ),\n    Hook(\n        name=\"reviewer_will_suspend_card\",\n        args=[\"id: int\"],\n    ),\n    Hook(\n        name=\"reviewer_will_bury_note\",\n        args=[\"nid: int\"],\n    ),\n    Hook(\n        name=\"reviewer_will_bury_card\",\n        args=[\"id: int\"],\n    ),\n    Hook(\n        name=\"audio_did_pause_or_unpause\",\n        args=[\"webview: aqt.webview.AnkiWebView\"],\n        doc=\"\"\"Called when the audio is paused or unpaused.\n        This hook is triggered by the action in Anki's More menu or the related key binding.\n        The webview is provided in case you wish to use this hook with web-based audio.\"\"\",\n    ),\n    Hook(\n        name=\"audio_did_seek_relative\",\n        args=[\"webview: aqt.webview.AnkiWebView\", \"seek_seconds: int\"],\n        doc=\"\"\"Called when the audio is sought forward (positive seek) or backwards (negative seek).\n        This hook is triggered by the action in Anki's More menu or the related key binding.\n        The webview is provided in case you wish to use this hook with web-based audio.\"\"\",\n    ),\n    # Debug\n    ###################\n    Hook(\n        name=\"debug_console_will_show\",\n        args=[\"debug_window: QDialog\"],\n        doc=\"\"\"Allows editing the debug window. E.g. setting a default code, or\n        previous code.\"\"\",\n    ),\n    Hook(\n        name=\"debug_console_did_evaluate_python\",\n        args=[\"output: str\", \"query: str\", \"debug_window: aqt.forms.debug.Ui_Dialog\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows processing the debug result. E.g. logging queries and\n        result, saving last query to display it later...\"\"\",\n    ),\n    # Card layout\n    ###################\n    Hook(\n        name=\"card_layout_will_show\",\n        args=[\"clayout: aqt.clayout.CardLayout\"],\n        doc=\"\"\"Allow to change the display of the card layout. After most values are\n         set and before the window is actually shown.\"\"\",\n    ),\n    # Reviewer\n    ###################\n    Hook(\n        name=\"reviewer_did_init\",\n        args=[\"reviewer: aqt.reviewer.Reviewer\"],\n        doc=\"\"\"Called after the reviewer is initialized.\"\"\",\n    ),\n    # Multiple windows\n    ###################\n    # reviewer and previewer\n    Hook(\n        name=\"audio_will_replay\",\n        args=[\"webview: aqt.webview.AnkiWebView\", \"card: Card\", \"is_front_side: bool\"],\n        doc=\"\"\"Called when the user uses the 'replay audio' action, but not when they click on a play button.\"\"\",\n    ),\n    # reviewer, clayout and browser\n    Hook(\n        name=\"card_will_show\",\n        args=[\"text: str\", \"card: Card\", \"kind: str\"],\n        return_type=\"str\",\n        legacy_hook=\"prepareQA\",\n        doc=\"Can modify card text before review/preview.\",\n    ),\n    # reviewer, main and clayout\n    Hook(\n        name=\"card_review_webview_did_init\",\n        args=[\n            \"webview: aqt.webview.AnkiWebView\",\n            \"kind: aqt.webview.AnkiWebViewKind\",\n        ],\n        doc=\"Called when initializing the webview for the review screen, the card layout screen, and the preview screen.\",\n    ),\n    # Deck browser\n    ###################\n    Hook(\n        name=\"deck_browser_did_render\",\n        args=[\"deck_browser: aqt.deckbrowser.DeckBrowser\"],\n        doc=\"\"\"Allow to update the deck browser window. E.g. change its title.\"\"\",\n    ),\n    Hook(\n        name=\"deck_browser_will_render_content\",\n        args=[\n            \"deck_browser: aqt.deckbrowser.DeckBrowser\",\n            \"content: aqt.deckbrowser.DeckBrowserContent\",\n        ],\n        doc=\"\"\"Used to modify HTML content sections in the deck browser body\n\n        'content' contains the sections of HTML content the deck browser body\n        will be updated with.\n\n        When modifying the content of a particular section, please make sure your\n        changes only perform the minimum required edits to make your add-on work.\n        You should avoid overwriting or interfering with existing data as much\n        as possible, instead opting to append your own changes, e.g.:\n\n            def on_deck_browser_will_render_content(deck_browser, content):\n                content.stats += \"\\\\n<div>my html</div>\"\n        \"\"\",\n    ),\n    # Deck options (legacy screen)\n    ###############################\n    Hook(\n        name=\"deck_conf_did_setup_ui_form\",\n        args=[\"deck_conf: aqt.deckconf.DeckConf\"],\n        doc=\"Allows modifying or adding widgets in the deck options UI form\",\n    ),\n    Hook(\n        name=\"deck_conf_will_show\",\n        args=[\"deck_conf: aqt.deckconf.DeckConf\"],\n        doc=\"Allows modifying the deck options dialog before it is shown\",\n    ),\n    Hook(\n        name=\"deck_conf_did_load_config\",\n        args=[\n            \"deck_conf: aqt.deckconf.DeckConf\",\n            \"deck: DeckDict\",\n            \"config: DeckConfigDict\",\n        ],\n        doc=\"Called once widget state has been set from deck config\",\n    ),\n    Hook(\n        name=\"deck_conf_will_save_config\",\n        args=[\n            \"deck_conf: aqt.deckconf.DeckConf\",\n            \"deck: DeckDict\",\n            \"config: DeckConfigDict\",\n        ],\n        doc=\"Called before widget state is saved to config\",\n    ),\n    Hook(\n        name=\"deck_conf_did_add_config\",\n        args=[\n            \"deck_conf: aqt.deckconf.DeckConf\",\n            \"deck: DeckDict\",\n            \"config: DeckConfigDict\",\n            \"new_name: str\",\n            \"new_conf_id: int\",\n        ],\n        doc=\"\"\"Allows modification of a newly created config group\n\n        This hook is called after the config group was created, but\n        before initializing the widget state.\n\n        `deck_conf` will point to the old config group, `new_conf_id` will\n        point to the newly created config group.\n\n        Config groups are created as clones of the current one.\n        \"\"\",\n    ),\n    Hook(\n        name=\"deck_conf_will_remove_config\",\n        args=[\n            \"deck_conf: aqt.deckconf.DeckConf\",\n            \"deck: DeckDict\",\n            \"config: DeckConfigDict\",\n        ],\n        doc=\"Called before current config group is removed\",\n    ),\n    Hook(\n        name=\"deck_conf_will_rename_config\",\n        args=[\n            \"deck_conf: aqt.deckconf.DeckConf\",\n            \"deck: DeckDict\",\n            \"config: DeckConfigDict\",\n            \"new_name: str\",\n        ],\n        doc=\"Called before config group is renamed\",\n    ),\n    # Deck options (new screen)\n    ############################\n    Hook(\n        name=\"deck_options_did_load\",\n        args=[\n            \"deck_options: aqt.deckoptions.DeckOptionsDialog\",\n        ],\n        doc=\"\"\"Can be used to inject extra options into the config screen.\n\n        See the example add-ons at:\n        https://github.com/ankitects/anki-addons/tree/main/demos/deckoptions_svelte\n        https://github.com/ankitects/anki-addons/tree/main/demos/deckoptions_raw_html\n        \"\"\",\n    ),\n    # Filtered deck options\n    ###################\n    Hook(\n        name=\"filtered_deck_dialog_did_load_deck\",\n        args=[\n            \"filtered_deck_dialog: aqt.filtered_deck.FilteredDeckConfigDialog\",\n            \"filtered_deck: anki.scheduler.FilteredDeckForUpdate\",\n        ],\n        doc=\"Allows updating widget state once the filtered deck config is loaded\",\n    ),\n    Hook(\n        name=\"filtered_deck_dialog_will_add_or_update_deck\",\n        args=[\n            \"filtered_deck_dialog: aqt.filtered_deck.FilteredDeckConfigDialog\",\n            \"filtered_deck: anki.scheduler.FilteredDeckForUpdate\",\n        ],\n        doc=\"Allows modifying the filtered deck config object before it is written\",\n    ),\n    Hook(\n        name=\"filtered_deck_dialog_did_add_or_update_deck\",\n        args=[\n            \"filtered_deck_dialog: aqt.filtered_deck.FilteredDeckConfigDialog\",\n            \"filtered_deck: anki.scheduler.FilteredDeckForUpdate\",\n            \"deck_id: int\",\n        ],\n        doc=\"Allows performing changes after a filtered deck has been added or updated\",\n    ),\n    # Browser\n    ###################\n    Hook(\n        name=\"default_search\",\n        args=[\"current_search: str\", \"c: Card\"],\n        return_type=\"str\",\n        doc=\"Change the default search when the card browser is opened with card `c`.\",\n    ),\n    Hook(name=\"browser_will_show\", args=[\"browser: aqt.browser.Browser\"]),\n    Hook(\n        name=\"browser_menus_did_init\",\n        args=[\"browser: aqt.browser.Browser\"],\n        legacy_hook=\"browser.setupMenus\",\n    ),\n    Hook(\n        name=\"browser_will_show_context_menu\",\n        args=[\"browser: aqt.browser.Browser\", \"menu: QMenu\"],\n        legacy_hook=\"browser.onContextMenu\",\n    ),\n    Hook(\n        name=\"browser_sidebar_will_show_context_menu\",\n        args=[\n            \"sidebar: aqt.browser.SidebarTreeView\",\n            \"menu: QMenu\",\n            \"item: aqt.browser.SidebarItem\",\n            \"index: QModelIndex\",\n        ],\n    ),\n    Hook(\n        name=\"browser_header_will_show_context_menu\",\n        args=[\"browser: aqt.browser.Browser\", \"menu: QMenu\"],\n    ),\n    Hook(\n        name=\"browser_did_change_row\",\n        args=[\"browser: aqt.browser.Browser\"],\n        legacy_hook=\"browser.rowChanged\",\n    ),\n    Hook(\n        name=\"browser_will_build_tree\",\n        args=[\n            \"handled: bool\",\n            \"tree: aqt.browser.SidebarItem\",\n            \"stage: aqt.browser.SidebarStage\",\n            \"browser: aqt.browser.Browser\",\n        ],\n        return_type=\"bool\",\n        doc=\"\"\"Used to add or replace items in the browser sidebar tree\n\n        'tree' is the root SidebarItem that all other items are added to.\n\n        'stage' is an enum describing the different construction stages of\n        the sidebar tree at which you can interject your changes.\n        The different values can be inspected by looking at\n        aqt.browser.SidebarStage.\n\n        If you want Anki to proceed with the construction of the tree stage\n        in question after your have performed your changes or additions,\n        return the 'handled' boolean unchanged.\n\n        On the other hand, if you want to prevent Anki from adding its own\n        items at a particular construction stage (e.g. in case your add-on\n        implements its own version of that particular stage), return 'True'.\n\n        If you return 'True' at SidebarStage.ROOT, the sidebar will not be\n        populated by any of the other construction stages. For any other stage\n        the tree construction will just continue as usual.\n\n        For example, if your code wishes to replace the tag tree, you could do:\n\n            def on_browser_will_build_tree(handled, root, stage, browser):\n                if stage != SidebarStage.TAGS:\n                    # not at tag tree building stage, pass on\n                    return handled\n\n                # your tag tree construction code\n                # root.addChild(...)\n\n                # your code handled tag tree construction, no need for Anki\n                # or other add-ons to build the tag tree\n                return True\n        \"\"\",\n    ),\n    Hook(\n        name=\"browser_will_search\",\n        args=[\"context: aqt.browser.SearchContext\"],\n        doc=\"\"\"Allows you to modify the search text, or perform your own search.\n\n         You can modify context.search to change the text that is sent to the\n         searching backend.\n\n         If you need to pass metadata to the browser_did_search hook, you can\n         do it with context.addon_metadata. For example, to trigger filtering\n         based on a new custom filter.\n\n         If you set context.ids to a list of ids, the regular search will\n         not be performed, and the provided ids will be used instead.\n\n         Your add-on should check if context.ids is not None, and return\n         without making changes if it has been set.\n\n         In versions of Anki lower than 2.1.45 the field to check is\n         context.card_ids rather than context.ids\n         \"\"\",\n    ),\n    Hook(\n        name=\"browser_did_search\",\n        args=[\"context: aqt.browser.SearchContext\"],\n        doc=\"\"\"Allows you to modify the list of returned card ids from a search.\"\"\",\n    ),\n    Hook(\n        name=\"browser_did_fetch_row\",\n        args=[\n            \"card_or_note_id: aqt.browser.ItemId\",\n            \"is_note: bool\",\n            \"row: aqt.browser.CellRow\",\n            \"columns: Sequence[str]\",\n        ],\n        doc=\"\"\"Allows you to add or modify content to a row in the browser.\n\n        You can mutate the row object to change what is displayed. Any columns the\n        backend did not recognize will be returned as an empty string, and can be\n        replaced with custom content.\n\n        You can retrieve metadata passed from browser_will_search with\n        context.addon_metadata (for example to trigger post-processing filtering).\n\n        Columns is a list of string values identifying what each column in the row\n        represents.\n        \"\"\",\n    ),\n    Hook(\n        name=\"browser_did_fetch_columns\",\n        args=[\"columns: dict[str, aqt.browser.Column]\"],\n        doc=\"\"\"Allows you to add custom columns to the browser.\n\n        columns is a dictionary of data objects. You can add an entry with a custom\n        column to describe how it should be displayed in the browser or modify\n        existing entries.\n\n        Every column in the dictionary will be toggleable by the user.\n        \"\"\",\n    ),\n    # Previewer\n    ###################\n    Hook(\n        name=\"previewer_did_init\",\n        args=[\"previewer: aqt.browser.previewer.Previewer\"],\n        doc=\"\"\"Called after the previewer is initialized.\"\"\",\n    ),\n    Hook(\n        name=\"previewer_will_redraw_after_show_both_sides_toggled\",\n        args=[\n            \"webview: aqt.webview.AnkiWebView\",\n            \"card: Card\",\n            \"is_front_side: bool\",\n            \"show_both_sides: bool\",\n        ],\n        doc=\"\"\"Called when the checkbox <show both sides> is toggled by the user.\"\"\",\n    ),\n    # Main window states\n    ###################\n    # these refer to things like deckbrowser, overview and reviewer state,\n    Hook(\n        name=\"state_will_change\",\n        args=[\n            \"new_state: aqt.main.MainWindowState\",\n            \"old_state: aqt.main.MainWindowState\",\n        ],\n        legacy_hook=\"beforeStateChange\",\n    ),\n    Hook(\n        name=\"state_did_change\",\n        args=[\n            \"new_state: aqt.main.MainWindowState\",\n            \"old_state: aqt.main.MainWindowState\",\n        ],\n        legacy_hook=\"afterStateChange\",\n    ),\n    # different sig to original\n    Hook(\n        name=\"state_shortcuts_will_change\",\n        args=[\n            \"state: aqt.main.MainWindowState\",\n            \"shortcuts: list[tuple[str, Callable]]\",\n        ],\n    ),\n    # UI state/refreshing\n    ###################\n    Hook(\n        name=\"state_did_undo\",\n        args=[\"changes: OpChangesAfterUndo\"],\n        doc=\"Called after backend undoes a change.\",\n    ),\n    Hook(\n        name=\"state_did_reset\",\n        legacy_hook=\"reset\",\n        doc=\"\"\"Legacy 'reset' hook. Called by mw.reset() and CollectionOp() to redraw the UI.\n\n        New code should use `operation_did_execute` instead.\n        \"\"\",\n    ),\n    Hook(\n        name=\"operation_did_execute\",\n        args=[\"changes: anki.collection.OpChanges\", \"handler: object | None\"],\n        doc=\"\"\"Called after an operation completes.\n        Changes can be inspected to determine whether the UI needs updating.\n\n        This will also be called when the legacy mw.reset() is used.\n        \"\"\",\n    ),\n    Hook(\n        name=\"focus_did_change\",\n        args=[\n            \"new: QWidget | None\",\n            \"old: QWidget | None\",\n        ],\n        doc=\"\"\"Called each time the focus changes. Can be used to defer updates from\n        `operation_did_execute` until a window is brought to the front.\"\"\",\n    ),\n    Hook(\n        name=\"backend_will_block\",\n        doc=\"\"\"Called before one or more DB tasks are run in the background.\n\n        Subscribers can use this to set a flag to avoid DB queries until the operation\n        completes, as doing so will freeze the UI until the long-running operation\n        completes.\n        \"\"\",\n    ),\n    Hook(\n        name=\"backend_did_block\",\n        doc=\"\"\"Called after one or more DB tasks finish running in the background.\n        Called regardless of the success of individual operations, and only called when\n        there are no outstanding ops.\n        \"\"\",\n    ),\n    Hook(\n        name=\"theme_did_change\",\n        doc=\"Called after night mode is toggled.\",\n    ),\n    Hook(\n        name=\"body_classes_need_update\",\n        doc=\"Called when a setting involving a webview body class is toggled.\",\n    ),\n    # Webview\n    ###################\n    Hook(\n        name=\"webview_did_receive_js_message\",\n        args=[\"handled: tuple[bool, Any]\", \"message: str\", \"context: Any\"],\n        return_type=\"tuple[bool, Any]\",\n        doc=\"\"\"Used to handle pycmd() messages sent from Javascript.\n\n        Message is the string passed to pycmd().\n\n        For messages you don't want to handle, return 'handled' unchanged.\n\n        If you handle a message and don't want it passed to the original\n        bridge command handler, return (True, None).\n\n        If you want to pass a value to pycmd's result callback, you can\n        return it with (True, some_value).\n\n        Context is the instance that was passed to set_bridge_command().\n        It can be inspected to check which screen this hook is firing\n        in, and to get a reference to the screen. For example, if your\n        code wishes to function only in the review screen, you could do:\n\n            if not isinstance(context, aqt.reviewer.Reviewer):\n                # not reviewer, pass on message\n                return handled\n\n            if message == \"my-mark-action\":\n                # our message, call onMark() on the reviewer instance\n                context.onMark()\n                # and don't pass message to other handlers\n                return (True, None)\n            else:\n                # some other command, pass it on\n                return handled\n        \"\"\",\n    ),\n    Hook(\n        name=\"webview_will_set_content\",\n        args=[\n            \"web_content: aqt.webview.WebContent\",\n            \"context: object | None\",\n        ],\n        doc=\"\"\"Used to modify web content before it is rendered.\n\n        Web_content contains the HTML, JS, and CSS the web view will be\n        populated with.\n\n        Context is the instance that was passed to stdHtml().\n        It can be inspected to check which screen this hook is firing\n        in, and to get a reference to the screen. For example, if your\n        code wishes to function only in the review screen, you could do:\n\n            def on_webview_will_set_content(web_content: WebContent, context):\n\n                if not isinstance(context, aqt.reviewer.Reviewer):\n                    # not reviewer, do not modify content\n                    return\n\n                # reviewer, perform changes to content\n\n                context: aqt.reviewer.Reviewer\n\n                addon_package = mw.addonManager.addonFromModule(__name__)\n\n                web_content.css.append(\n                    f\"/_addons/{addon_package}/web/my-addon.css\")\n                web_content.js.append(\n                    f\"/_addons/{addon_package}/web/my-addon.js\")\n\n                web_content.head += \"<script>console.log('my-addon')</script>\"\n                web_content.body += \"<div id='my-addon'></div>\"\n        \"\"\",\n    ),\n    Hook(\n        name=\"webview_will_show_context_menu\",\n        args=[\"webview: aqt.webview.AnkiWebView\", \"menu: QMenu\"],\n        legacy_hook=\"AnkiWebView.contextMenuEvent\",\n    ),\n    Hook(\n        name=\"webview_did_inject_style_into_page\",\n        args=[\"webview: aqt.webview.AnkiWebView\"],\n        doc='''Called after standard styling is injected into an external\n        html file, such as when loading the new graphs. You can use this hook to\n        mutate the DOM before the page is revealed.\n        \n        For example:\n        \n        def mytest(webview: AnkiWebView):\n            if webview.kind != AnkiWebViewKind.DECK_STATS:\n                return\n            webview.eval(\n                \"\"\"\n                div = document.createElement(\"div\");\n                div.innerHTML = 'hello';\n                document.body.appendChild(div);\n                \"\"\"\n            )\n        \n        gui_hooks.webview_did_inject_style_into_page.append(mytest)\n        ''',\n    ),\n    # Main\n    ###################\n    Hook(\n        name=\"main_window_did_init\",\n        doc=\"\"\"Executed after the main window is fully initialized\n\n        A sample use case for this hook would be to delay actions until Anki objects\n        like the profile or collection are fully initialized. In contrast to\n        `profile_did_open`, this hook will only fire once per Anki session and\n        is thus suitable for single-shot subscribers.\n        \"\"\",\n    ),\n    Hook(\n        name=\"main_window_should_require_reset\",\n        args=[\n            \"will_reset: bool\",\n            \"reason: aqt.main.ResetReason | str\",\n            \"context: object | None\",\n        ],\n        return_type=\"bool\",\n        doc=\"\"\"Executed before the main window will require a reset\n\n        This hook can be used to change the behavior of the main window,\n        when other dialogs, like the AddCards or Browser, require a reset\n        from the main window.\n        If you decide to use this hook, make you sure you check the reason for the reset.\n        Some reasons require more attention than others, and skipping important ones might\n        put the main window into an invalid state (e.g. display a deleted note).\n        \"\"\",\n    ),\n    Hook(name=\"backup_did_complete\"),\n    Hook(\n        name=\"profile_did_open\",\n        legacy_hook=\"profileLoaded\",\n        doc=\"\"\"Executed whenever a user profile has been opened\n\n        Please note that this hook will also be called on profile switches, so if you\n        are looking to simply delay an add-on action in a single-shot manner,\n        `main_window_did_init` is likely the more suitable choice.\n        \"\"\",\n    ),\n    Hook(name=\"profile_will_close\", legacy_hook=\"unloadProfile\"),\n    Hook(\n        name=\"collection_will_temporarily_close\",\n        args=[\"col: anki.collection.Collection\"],\n        doc=\"\"\"Called before one-way syncs and colpkg imports/exports.\"\"\",\n    ),\n    Hook(\n        name=\"collection_did_temporarily_close\",\n        args=[\"col: anki.collection.Collection\"],\n        doc=\"\"\"Called after one-way syncs and colpkg imports/exports.\"\"\",\n    ),\n    Hook(\n        name=\"collection_did_load\",\n        args=[\"col: anki.collection.Collection\"],\n        legacy_hook=\"colLoading\",\n    ),\n    Hook(name=\"undo_state_did_change\", args=[\"info: UndoActionsInfo\"]),\n    Hook(\n        name=\"style_did_init\",\n        args=[\"style: str\"],\n        return_type=\"str\",\n        legacy_hook=\"setupStyle\",\n    ),\n    Hook(\n        name=\"top_toolbar_did_init_links\",\n        args=[\"links: list[str]\", \"top_toolbar: aqt.toolbar.Toolbar\"],\n        doc=\"\"\"Used to modify or add links in the top toolbar of Anki's main window\n\n        'links' is a list of HTML link elements. Add-ons can generate their own links\n        by using aqt.toolbar.Toolbar.create_link. Links created in that way can then be\n        appended to the link list, e.g.:\n\n            def on_top_toolbar_did_init_links(links, toolbar):\n                my_link = toolbar.create_link(...)\n                links.append(my_link)\n        \"\"\",\n    ),\n    Hook(\n        name=\"top_toolbar_will_set_left_tray_content\",\n        args=[\"content: list[str]\", \"top_toolbar: aqt.toolbar.Toolbar\"],\n        doc=\"\"\"Used to add custom add-on components to the *left* area of Anki's main\n        window toolbar\n\n        'content' is a list of HTML strings added by add-ons which you can append your\n        own components or elements to. To equip your components with logic and styling\n        please see `webview_will_set_content` and `webview_did_receive_js_message`.\n\n        Please note that Anki's main screen is due to undergo a significant refactor\n        in the future and, as a result, add-ons subscribing to this hook will likely\n        require changes to continue working.\n        \"\"\",\n    ),\n    Hook(\n        name=\"top_toolbar_will_set_right_tray_content\",\n        args=[\"content: list[str]\", \"top_toolbar: aqt.toolbar.Toolbar\"],\n        doc=\"\"\"Used to add custom add-on components to the *right* area of Anki's main\n        window toolbar\n\n        'content' is a list of HTML strings added by add-ons which you can append your\n        own components or elements to. To equip your components with logic and styling\n        please see `webview_will_set_content` and `webview_did_receive_js_message`.\n\n        Please note that Anki's main screen is due to undergo a significant refactor\n        in the future and, as a result, add-ons subscribing to this hook will likely\n        require changes to continue working.\n        \"\"\",\n    ),\n    Hook(\n        name=\"top_toolbar_did_redraw\",\n        args=[\"top_toolbar: aqt.toolbar.Toolbar\"],\n        doc=\"\"\"Executed when the top toolbar is redrawn\"\"\",\n    ),\n    Hook(\n        name=\"media_sync_did_progress\",\n        args=[\"entry: str\"],\n    ),\n    Hook(name=\"media_sync_did_start_or_stop\", args=[\"running: bool\"]),\n    Hook(\n        name=\"empty_cards_will_show\",\n        args=[\"diag: aqt.emptycards.EmptyCardsDialog\"],\n        doc=\"\"\"Allows changing the list of cards to delete.\"\"\",\n    ),\n    Hook(name=\"sync_will_start\", args=[]),\n    Hook(\n        name=\"sync_did_finish\",\n        args=[],\n        doc=\"\"\"Executes after the sync of the collection concluded.\n\n        Note that the media sync did not necessarily finish at this point.\"\"\",\n    ),\n    Hook(name=\"media_check_will_start\", args=[]),\n    Hook(\n        name=\"media_check_did_finish\",\n        args=[\"output: anki.media.CheckMediaResponse\"],\n        doc=\"\"\"Called after Media Check finishes.\n\n        `output` provides access to the unused/missing file lists and the text output that will be shown in the Check Media screen.\"\"\",\n    ),\n    Hook(name=\"day_did_change\", doc=\"\"\"Called when Anki moves to the next day.\"\"\"),\n    # Importing/exporting data\n    ###################\n    Hook(\n        name=\"exporter_will_export\",\n        args=[\n            \"export_options: aqt.import_export.exporting.ExportOptions\",\n            \"exporter: aqt.import_export.exporting.Exporter\",\n        ],\n        return_type=\"aqt.import_export.exporting.ExportOptions\",\n        doc=\"\"\"Called before collection and deck exports.\n\n        Allows add-ons to be notified of impending deck exports and potentially\n        modify the export options. To perform the export unaltered, please return\n        `export_options` as is, e.g.:\n\n            def on_exporter_will_export(export_options: ExportOptions, exporter: Exporter):\n                if not isinstance(exporter, ApkgExporter):\n                    return export_options\n                export_options.limit = ...\n                return export_options\n        \"\"\",\n    ),\n    Hook(\n        name=\"exporter_did_export\",\n        args=[\n            \"export_options: aqt.import_export.exporting.ExportOptions\",\n            \"exporter: aqt.import_export.exporting.Exporter\",\n        ],\n        doc=\"\"\"Called after collection and deck exports.\"\"\",\n    ),\n    Hook(\n        name=\"legacy_exporter_will_export\",\n        args=[\"legacy_exporter: anki.exporting.Exporter\"],\n        doc=\"\"\"Called before collection and deck exports performed by legacy exporters.\"\"\",\n    ),\n    Hook(\n        name=\"legacy_exporter_did_export\",\n        args=[\"legacy_exporter: anki.exporting.Exporter\"],\n        doc=\"\"\"Called after collection and deck exports performed by legacy exporters.\"\"\",\n    ),\n    Hook(\n        name=\"exporters_list_did_initialize\",\n        args=[\"exporters: list[Type[aqt.import_export.exporting.Exporter]]\"],\n        doc=\"\"\"Called after the list of exporter classes is created.\n\n        Allows you to register custom exporters and/or replace existing ones by\n        modifying the exporter list.\n        \"\"\",\n    ),\n    # Dialog Manager\n    ###################\n    Hook(\n        name=\"dialog_manager_did_open_dialog\",\n        args=[\n            \"dialog_manager: aqt.DialogManager\",\n            \"dialog_name: str\",\n            \"dialog_instance: QWidget\",\n        ],\n        doc=\"\"\"Executed after aqt.dialogs creates a dialog window\"\"\",\n    ),\n    # Adding cards\n    ###################\n    Hook(\n        name=\"add_cards_will_show_history_menu\",\n        args=[\"addcards: aqt.addcards.AddCards\", \"menu: QMenu\"],\n        legacy_hook=\"AddCards.onHistory\",\n    ),\n    Hook(\n        name=\"add_cards_did_init\",\n        args=[\"addcards: aqt.addcards.AddCards\"],\n    ),\n    Hook(\n        name=\"add_cards_did_add_note\",\n        args=[\"note: anki.notes.Note\"],\n        legacy_hook=\"AddCards.noteAdded\",\n    ),\n    Hook(\n        name=\"add_cards_will_add_note\",\n        args=[\"problem: str | None\", \"note: anki.notes.Note\"],\n        return_type=\"str | None\",\n        doc=\"\"\"Decides whether the note should be added to the collection or\n        not. It is assumed to come from the addCards window.\n\n        reason_to_already_reject is the first reason to reject that\n        was found, or None. If your filter wants to reject, it should\n        replace return the reason to reject. Otherwise return the\n        input.\"\"\",\n    ),\n    Hook(\n        name=\"add_cards_might_add_note\",\n        args=[\"optional_problems: list[str]\", \"note: anki.notes.Note\"],\n        doc=\"\"\"\n            Allows you to provide an optional reason to reject a note. A\n            yes / no dialog will open displaying the problem, to which the\n            user can decide if they would like to add the note anyway.\n\n            optional_problems is a list containing the optional reasons for which\n            you might reject a note. If your add-on wants to add a reason,\n            it should append the reason to the list.\n\n            An example add-on that asks the user for confirmation before adding a\n            card without tags:\n\n            def might_reject_empty_tag(optional_problems, note):\n                if not any(note.tags):\n                    optional_problems.append(\"Add cards without tags?\")\n        \"\"\",\n    ),\n    Hook(\n        name=\"addcards_will_add_history_entry\",\n        args=[\"line: str\", \"note: anki.notes.Note\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows changing the history line in the add-card window.\"\"\",\n    ),\n    Hook(\n        name=\"add_cards_did_change_note_type\",\n        args=[\"old: anki.models.NoteType\", \"new: anki.models.NoteType\"],\n        doc=\"\"\"Deprecated. Use addcards_did_change_note_type instead.\n        Executed after the user selects a new note type when adding\n        cards.\"\"\",\n    ),\n    Hook(\n        name=\"addcards_did_change_note_type\",\n        args=[\n            \"addcards: aqt.addcards.AddCards\",\n            \"old: anki.models.NoteType\",\n            \"new: anki.models.NoteType\",\n        ],\n        replaces=\"add_cards_did_change_note_type\",\n        replaced_hook_args=[\"old: anki.models.NoteType\", \"new: anki.models.NoteType\"],\n        doc=\"\"\"Executed after the user selects a new note type when adding\n        cards.\"\"\",\n    ),\n    Hook(\n        name=\"add_cards_did_change_deck\",\n        args=[\"new_deck_id: int\"],\n        doc=\"\"\"Executed after the user selects a new different deck when\n        adding cards.\"\"\",\n    ),\n    # Editing\n    ###################\n    Hook(\n        name=\"editor_did_init_left_buttons\",\n        args=[\"buttons: list[str]\", \"editor: aqt.editor.Editor\"],\n    ),\n    Hook(\n        name=\"editor_did_init_buttons\",\n        args=[\"buttons: list[str]\", \"editor: aqt.editor.Editor\"],\n    ),\n    Hook(\n        name=\"editor_did_init_shortcuts\",\n        args=[\"shortcuts: list[tuple]\", \"editor: aqt.editor.Editor\"],\n        legacy_hook=\"setupEditorShortcuts\",\n    ),\n    Hook(\n        name=\"editor_will_show_context_menu\",\n        args=[\"editor_webview: aqt.editor.EditorWebView\", \"menu: QMenu\"],\n        legacy_hook=\"EditorWebView.contextMenuEvent\",\n    ),\n    Hook(\n        name=\"editor_did_fire_typing_timer\",\n        args=[\"note: anki.notes.Note\"],\n        legacy_hook=\"editTimer\",\n    ),\n    Hook(\n        name=\"editor_did_focus_field\",\n        args=[\"note: anki.notes.Note\", \"current_field_idx: int\"],\n        legacy_hook=\"editFocusGained\",\n    ),\n    Hook(\n        name=\"editor_did_unfocus_field\",\n        args=[\"changed: bool\", \"note: anki.notes.Note\", \"current_field_idx: int\"],\n        return_type=\"bool\",\n        legacy_hook=\"editFocusLost\",\n    ),\n    Hook(\n        name=\"editor_did_load_note\",\n        args=[\"editor: aqt.editor.Editor\"],\n        legacy_hook=\"loadNote\",\n    ),\n    Hook(\n        name=\"editor_did_update_tags\",\n        args=[\"note: anki.notes.Note\"],\n        legacy_hook=\"tagsUpdated\",\n    ),\n    Hook(\n        name=\"editor_will_munge_html\",\n        args=[\"txt: str\", \"editor: aqt.editor.Editor\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows manipulating the text that will be saved by the editor\"\"\",\n    ),\n    Hook(\n        name=\"editor_will_use_font_for_field\",\n        args=[\"font: str\"],\n        return_type=\"str\",\n        legacy_hook=\"mungeEditingFontName\",\n    ),\n    Hook(\n        name=\"editor_web_view_did_init\",\n        args=[\"editor_web_view: aqt.editor.EditorWebView\"],\n    ),\n    Hook(\n        name=\"editor_did_init\",\n        args=[\"editor: aqt.editor.Editor\"],\n    ),\n    Hook(\n        name=\"editor_will_load_note\",\n        args=[\"js: str\", \"note: anki.notes.Note\", \"editor: aqt.editor.Editor\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows changing the javascript commands to load note before\n        executing it and do change in the QT editor.\"\"\",\n    ),\n    Hook(\n        name=\"editor_did_paste\",\n        args=[\n            \"editor: aqt.editor.Editor\",\n            \"html: str\",\n            \"internal: bool\",\n            \"extended: bool\",\n        ],\n        doc=\"\"\"Called after some data is pasted by python into an editor field.\"\"\",\n    ),\n    Hook(\n        name=\"editor_will_process_mime\",\n        args=[\n            \"mime: QMimeData\",\n            \"editor_web_view: aqt.editor.EditorWebView\",\n            \"internal: bool\",\n            \"extended: bool\",\n            \"drop_event: bool\",\n        ],\n        return_type=\"QMimeData\",\n        doc=\"\"\"\n        Used to modify MIME data stored in the clipboard after a drop or a paste.\n        Called after the user pastes or drag-and-drops something to Anki\n        before Anki processes the data.\n\n        The function should return a new or existing QMimeData object.\n\n        \"mime\" contains the corresponding QMimeData object.\n        \"internal\" indicates whether the drop or paste is performed between Anki fields.\n        Most likely you want to skip processing if \"internal\" was set to True.\n        \"extended\" indicates whether the user requested an extended paste.\n        \"drop_event\" indicates whether the event was triggered by a drag-and-drop\n        or by a right-click paste.\n        \"\"\",\n    ),\n    Hook(\n        name=\"editor_state_did_change\",\n        args=[\n            \"editor: aqt.editor.Editor\",\n            \"new_state: aqt.editor.EditorState\",\n            \"old_state: aqt.editor.EditorState\",\n        ],\n        doc=\"\"\"Called when the input state of the editor changes, e.g. when\n        switching to an image occlusion note type.\"\"\",\n    ),\n    Hook(\n        name=\"editor_mask_editor_did_load_image\",\n        args=[\"editor: aqt.editor.Editor\", \"path_or_nid: str | anki.notes.NoteId\"],\n        doc=\"\"\"Called when the image occlusion mask editor has completed\n        loading an image.\n\n        When adding new notes `path_or_nid` will be the path to the image file.\n        When editing existing notes `path_or_nid` will be the note id.\"\"\",\n    ),\n    # Tag\n    ###################\n    Hook(name=\"tag_editor_did_process_key\", args=[\"tag_edit: TagEdit\", \"evt: QEvent\"]),\n    # Sound/video\n    ###################\n    Hook(name=\"av_player_will_play\", args=[\"tag: anki.sound.AVTag\"]),\n    Hook(\n        name=\"av_player_did_begin_playing\",\n        args=[\"player: aqt.sound.Player\", \"tag: anki.sound.AVTag\"],\n    ),\n    Hook(name=\"av_player_did_end_playing\", args=[\"player: aqt.sound.Player\"]),\n    Hook(\n        name=\"av_player_will_play_tags\",\n        args=[\n            \"tags: list[anki.sound.AVTag]\",\n            \"side: str\",\n            \"context: Any\",\n        ],\n        doc=\"\"\"Called before playing a card side's sounds.\n\n        `tags` can be used to inspect and manipulate the sounds\n        that will be played (if any).\n\n        `side` can either be \"question\" or \"answer\".\n\n        `context` is the screen where the sounds will be played (e.g., Reviewer, Previewer, and CardLayout).\n\n        This won't be called when the user manually plays sounds\n        using `Replay Audio`.\n\n        Note that this hook is called even when the `Automatically play audio`\n        option is unchecked; This is so as to allow playing custom\n        sounds regardless of that option.\"\"\",\n    ),\n    # Addon\n    ###################\n    Hook(\n        name=\"addon_config_editor_will_display_json\",\n        args=[\"text: str\"],\n        return_type=\"str\",\n        doc=\"\"\"Allows changing the text of the json configuration before actually\n        displaying it to the user. For example, you can replace \"\\\\\\\\n\" by\n        some actual new line. Then you can replace the new lines by \"\\\\\\\\n\"\n        while reading the file and let the user uses real new line in\n        string instead of its encoding.\"\"\",\n    ),\n    Hook(\n        name=\"addon_config_editor_will_save_json\",\n        args=[\"text: str\"],\n        return_type=\"str\",\n        doc=\"\"\"Deprecated. Use addon_config_editor_will_update_json instead.\n        Allows changing the text of the json configuration that was\n        received from the user before actually reading it. For\n        example, you can replace new line in strings by some \"\\\\\\\\n\".\"\"\",\n    ),\n    Hook(\n        name=\"addon_config_editor_will_update_json\",\n        args=[\"text: str\", \"addon: str\"],\n        return_type=\"str\",\n        replaces=\"addon_config_editor_will_save_json\",\n        replaced_hook_args=[\"text: str\"],\n        doc=\"\"\"Allows changing the text of the json configuration that was\n        received from the user before actually reading it. For\n        example, you can replace new line in strings by some \"\\\\\\\\n\".\"\"\",\n    ),\n    Hook(\n        name=\"addons_dialog_will_show\",\n        args=[\"dialog: aqt.addons.AddonsDialog\"],\n        doc=\"\"\"Allows changing the add-on dialog before it is shown. E.g. add\n        buttons.\"\"\",\n    ),\n    Hook(\n        name=\"addons_dialog_did_change_selected_addon\",\n        args=[\"dialog: aqt.addons.AddonsDialog\", \"add_on: aqt.addons.AddonMeta\"],\n        doc=\"\"\"Allows doing an action when a single add-on is selected.\"\"\",\n    ),\n    Hook(\n        name=\"addons_dialog_will_delete_addons\",\n        args=[\"dialog: aqt.addons.AddonsDialog\", \"ids: list[str]\"],\n        doc=\"\"\"Allows doing an action before an add-on is deleted.\"\"\",\n    ),\n    Hook(\n        name=\"addon_manager_will_install_addon\",\n        args=[\"manager: aqt.addons.AddonManager\", \"module: str\"],\n        doc=\"\"\"Called before installing or updating an addon.\n\n        Can be used to release DB connections or open files that\n        would prevent an update from succeeding.\"\"\",\n    ),\n    Hook(\n        name=\"addon_manager_did_install_addon\",\n        args=[\"manager: aqt.addons.AddonManager\", \"module: str\"],\n        doc=\"\"\"Called after installing or updating an addon.\n\n        Can be used to restore DB connections or open files after\n        an add-on has been updated.\"\"\",\n    ),\n    # Model\n    ###################\n    Hook(\n        name=\"models_advanced_will_show\",\n        args=[\"advanced: QDialog\"],\n    ),\n    Hook(\n        name=\"models_did_init_buttons\",\n        args=[\n            \"buttons: list[tuple[str, Callable[[], None]]]\",\n            \"models: aqt.models.Models\",\n        ],\n        return_type=\"list[tuple[str, Callable[[], None]]]\",\n        doc=\"\"\"Allows adding buttons to the Model dialog\"\"\",\n    ),\n    # Fields\n    ###################\n    Hook(\n        name=\"fields_did_add_field\",\n        args=[\"dialog: aqt.fields.FieldDialog\", \"field: anki.models.FieldDict\"],\n    ),\n    Hook(\n        name=\"fields_did_rename_field\",\n        args=[\n            \"dialog: aqt.fields.FieldDialog\",\n            \"field: anki.models.FieldDict\",\n            \"old_name: str\",\n        ],\n    ),\n    Hook(\n        name=\"fields_did_delete_field\",\n        args=[\"dialog: aqt.fields.FieldDialog\", \"field: anki.models.FieldDict\"],\n    ),\n    # Stats\n    ###################\n    Hook(\n        name=\"stats_dialog_will_show\",\n        args=[\"dialog: aqt.stats.NewDeckStats\"],\n        doc=\"\"\"Allows changing the stats dialog before it is shown.\"\"\",\n    ),\n    Hook(\n        name=\"stats_dialog_old_will_show\",\n        args=[\"dialog: aqt.stats.DeckStats\"],\n        doc=\"\"\"Allows changing the old stats dialog before it is shown.\"\"\",\n    ),\n    # Other\n    ###################\n    Hook(\n        name=\"current_note_type_did_change\",\n        args=[\"notetype: NotetypeDict\"],\n        legacy_hook=\"currentModelChanged\",\n        legacy_no_args=True,\n    ),\n    Hook(name=\"sidebar_should_refresh_decks\", doc=\"Legacy, do not use.\"),\n    Hook(name=\"sidebar_should_refresh_notetypes\", doc=\"Legacy, do not use.\"),\n    Hook(\n        name=\"deck_browser_will_show_options_menu\",\n        args=[\"menu: QMenu\", \"deck_id: int\"],\n        legacy_hook=\"showDeckOptions\",\n    ),\n    Hook(\n        name=\"flag_label_did_change\",\n        args=[],\n        doc=\"Used to update the GUI when a new flag label is assigned.\",\n    ),\n]\n\nsuffix = \"\"\n\nif __name__ == \"__main__\":\n    path = sys.argv[1]\n    write_file(path, hooks, prefix, suffix)\n"
  },
  {
    "path": "qt/tools/runanki.system.in",
    "content": "#!/usr/bin/env python3\n\nimport sys\n\nsys.path.append(\"@PREFIX@/share/anki\")\n\nimport aqt\n\naqt.run()\n"
  },
  {
    "path": "rslib/.gitignore",
    "content": "Cargo.lock\n.build\ntarget\n"
  },
  {
    "path": "rslib/Cargo.toml",
    "content": "[package]\nname = \"anki\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\nworkspace = \"..\"\ndescription = \"Anki's Rust library code\"\n\n[features]\nbench = [\"criterion\"]\nrustls = [\"reqwest/rustls-tls\", \"reqwest/rustls-tls-native-roots\"]\nnative-tls = [\"reqwest/native-tls\"]\n\n[[bench]]\nname = \"benchmark\"\nharness = false\nrequired-features = [\"bench\"]\n\n[build-dependencies]\nanki_io.workspace = true\nanki_proto.workspace = true\nanki_proto_gen.workspace = true\nanyhow.workspace = true\ninflections.workspace = true\nitertools.workspace = true\nprettyplease.workspace = true\nprost.workspace = true\nprost-reflect.workspace = true\nsyn.workspace = true\n\n[dev-dependencies]\nasync-stream.workspace = true\nreqwest = { workspace = true, features = [\"native-tls\"] }\nwiremock.workspace = true\n\n[dependencies]\ncriterion = { workspace = true, optional = true }\n\nammonia.workspace = true\nanki_i18n.workspace = true\nanki_io.workspace = true\nanki_proto.workspace = true\nasync-compression.workspace = true\nasync-trait.workspace = true\naxum.workspace = true\naxum-client-ip.workspace = true\naxum-extra.workspace = true\nbitflags.workspace = true\nblake3.workspace = true\nbytes.workspace = true\nchrono.workspace = true\ncoarsetime.workspace = true\nconvert_case.workspace = true\ncsv.workspace = true\ndata-encoding.workspace = true\ndifflib.workspace = true\ndirs.workspace = true\nenvy.workspace = true\nflate2.workspace = true\nfluent.workspace = true\nfluent-bundle.workspace = true\nfnv.workspace = true\nfsrs.workspace = true\nfutures.workspace = true\nhex.workspace = true\nhtmlescape.workspace = true\nhyper.workspace = true\nid_tree.workspace = true\nitertools.workspace = true\nnom.workspace = true\nnum_cpus.workspace = true\nnum_enum.workspace = true\nonce_cell.workspace = true\npbkdf2.workspace = true\npercent-encoding-iri.workspace = true\npermutation.workspace = true\nphf.workspace = true\npin-project.workspace = true\nprost.workspace = true\npulldown-cmark.workspace = true\nrand.workspace = true\nrayon.workspace = true\nregex.workspace = true\nreqwest.workspace = true\nrusqlite.workspace = true\nrustls-pemfile.workspace = true\nscopeguard.workspace = true\nserde.workspace = true\nserde-aux.workspace = true\nserde_json.workspace = true\nserde_repr.workspace = true\nserde_tuple.workspace = true\nsha1.workspace = true\nsnafu.workspace = true\nstrum.workspace = true\ntempfile.workspace = true\ntokio.workspace = true\ntokio-util.workspace = true\ntower-http.workspace = true\ntracing.workspace = true\ntracing-appender.workspace = true\ntracing-subscriber.workspace = true\nunic-ucd-category.workspace = true\nunicase.workspace = true\nunicode-normalization.workspace = true\nzip.workspace = true\nzstd.workspace = true\n\n[target.'cfg(windows)'.dependencies]\nwindows.workspace = true\n"
  },
  {
    "path": "rslib/README.md",
    "content": "Anki's Rust code.\n"
  },
  {
    "path": "rslib/bench.sh",
    "content": "#!/bin/bash\n\ncargo install cargo-criterion --version 1.1.0\ncargo criterion --bench benchmark --features bench\n"
  },
  {
    "path": "rslib/benches/benchmark.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki::card_rendering::anki_directive_benchmark;\nuse criterion::criterion_group;\nuse criterion::criterion_main;\nuse criterion::Criterion;\n\npub fn criterion_benchmark(c: &mut Criterion) {\n    c.bench_function(\"anki_tag_parse\", |b| b.iter(|| anki_directive_benchmark()));\n}\n\ncriterion_group!(benches, criterion_benchmark);\ncriterion_main!(benches);\n"
  },
  {
    "path": "rslib/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod rust_interface;\n\nuse std::fs;\n\nuse anki_proto_gen::descriptors_path;\nuse anyhow::Result;\nuse prost_reflect::DescriptorPool;\n\nfn main() -> Result<()> {\n    println!(\"cargo:rerun-if-changed=../out/buildhash\");\n    let buildhash = fs::read_to_string(\"../out/buildhash\").unwrap_or_default();\n    println!(\"cargo:rustc-env=BUILDHASH={buildhash}\");\n\n    let descriptors_path = descriptors_path();\n    println!(\"cargo:rerun-if-changed={}\", descriptors_path.display());\n    let pool = DescriptorPool::decode(std::fs::read(descriptors_path)?.as_ref())?;\n    rust_interface::write_rust_interface(&pool)?;\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/i18n/Cargo.toml",
    "content": "[package]\nname = \"anki_i18n\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Anki's Rust library i18n code\"\n\n[lib]\nname = \"anki_i18n\"\npath = \"src/lib.rs\"\n\n[build-dependencies]\nfluent-syntax.workspace = true\nfluent.workspace = true\nunic-langid.workspace = true\nserde.workspace = true\nserde_json.workspace = true\ninflections.workspace = true\nanki_io.workspace = true\nanyhow.workspace = true\nitertools.workspace = true\nregex.workspace = true\n\n[dependencies]\nfluent.workspace = true\nfluent-bundle.workspace = true\nintl-memoizer.workspace = true\nnum-format.workspace = true\nphf.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nunic-langid.workspace = true\n"
  },
  {
    "path": "rslib/i18n/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod check;\nmod extract;\nmod gather;\nmod python;\nmod typescript;\nmod write_strings;\n\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anyhow::Result;\nuse check::check;\nuse extract::get_modules;\nuse gather::get_ftl_data;\nuse write_strings::write_strings;\n\n// fixme: check all variables are present in translations as well?\n\nfn main() -> Result<()> {\n    // generate our own requirements\n    let mut map = get_ftl_data();\n    check(&map);\n    let mut modules = get_modules(&map);\n    write_strings(&map, &modules, \"strings.rs\", \"All\");\n\n    typescript::write_ts_interface(&modules)?;\n    python::write_py_interface(&modules)?;\n\n    // write strings.json file to requested path\n    if let Some(path) = option_env!(\"STRINGS_JSON\") {\n        if !path.is_empty() {\n            let path = PathBuf::from(path);\n            let meta_json = serde_json::to_string_pretty(&modules).unwrap();\n            create_dir_all(path.parent().unwrap())?;\n            write_file_if_changed(path, meta_json)?;\n        }\n    }\n\n    // generate strings for the launcher\n    map.iter_mut()\n        .for_each(|(_, modules)| modules.retain(|module, _| module == \"launcher\"));\n    modules.retain(|module| module.name == \"launcher\");\n    write_strings(&map, &modules, \"strings_launcher.rs\", \"Launcher\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/i18n/check.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Check the .ftl files at build time to ensure we don't get runtime load\n//! failures.\n\nuse fluent::FluentBundle;\nuse fluent::FluentResource;\nuse unic_langid::LanguageIdentifier;\n\nuse super::gather::TranslationsByLang;\n\npub fn check(lang_map: &TranslationsByLang) {\n    for (lang, files_map) in lang_map {\n        for (fname, content) in files_map {\n            check_content(lang, fname, content);\n        }\n    }\n}\n\nfn check_content(lang: &str, fname: &str, content: &str) {\n    let lang_id: LanguageIdentifier = \"en-US\".parse().unwrap();\n    let resource = FluentResource::try_new(content.into()).unwrap_or_else(|e| {\n        panic!(\"{content}\\nUnable to parse {lang}/{fname}: {e:?}\");\n    });\n\n    let mut bundle: FluentBundle<FluentResource> = FluentBundle::new(vec![lang_id]);\n    bundle.add_resource(resource).unwrap_or_else(|e| {\n        panic!(\"{content}\\nUnable to bundle - duplicate key? {lang}/{fname}: {e:?}\");\n    });\n}\n"
  },
  {
    "path": "rslib/i18n/extract.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::fmt::Write;\n\nuse fluent_syntax::ast::Entry;\nuse fluent_syntax::ast::Expression;\nuse fluent_syntax::ast::InlineExpression;\nuse fluent_syntax::ast::Pattern;\nuse fluent_syntax::ast::PatternElement;\nuse fluent_syntax::parser::parse;\nuse serde::Serialize;\n\nuse crate::gather::TranslationsByLang;\n#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]\npub struct Module {\n    pub name: String,\n    pub translations: Vec<Translation>,\n    pub index: usize,\n}\n\n#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]\npub struct Translation {\n    pub key: String,\n    pub text: String,\n    pub variables: Vec<Variable>,\n    pub index: usize,\n}\n\n#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]\npub struct Variable {\n    pub name: String,\n    pub kind: VariableKind,\n}\n\n#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]\npub enum VariableKind {\n    Int,\n    Float,\n    String,\n    Any,\n}\n\npub fn get_modules(data: &TranslationsByLang) -> Vec<Module> {\n    let mut output = vec![];\n\n    for (module, text) in &data[\"templates\"] {\n        output.push(Module {\n            name: module.to_string(),\n            translations: extract_metadata(text),\n            index: 0,\n        });\n    }\n\n    output.sort_unstable();\n\n    for (module_idx, module) in output.iter_mut().enumerate() {\n        module.index = module_idx;\n        for (entry_idx, entry) in module.translations.iter_mut().enumerate() {\n            entry.index = entry_idx;\n        }\n    }\n\n    output\n}\n\nfn extract_metadata(ftl_text: &str) -> Vec<Translation> {\n    let res = parse(ftl_text).unwrap();\n    let mut output = vec![];\n\n    for entry in res.body {\n        if let Entry::Message(m) = entry {\n            if let Some(pattern) = m.value {\n                let mut visitor = Visitor::default();\n                visitor.visit_pattern(&pattern);\n                let key = m.id.name.to_string();\n\n                // special case translations that were ported from gettext, and use embedded\n                // terms that reference other variables that aren't visible to our visitor\n                if key == \"statistics-studied-today\" {\n                    visitor.variables.push(\"amount\".to_string());\n                    visitor.variables.push(\"cards\".to_string());\n                } else if key == \"statistics-average-answer-time\" {\n                    visitor.variables.push(\"cards-per-minute\".to_string());\n                }\n\n                let (text, variables) = visitor.into_output();\n\n                output.push(Translation {\n                    key,\n                    text,\n                    variables,\n                    index: 0,\n                })\n            }\n        }\n    }\n\n    output.sort_unstable();\n\n    output\n}\n\n/// Gather variable names and (rough) text from Fluent AST.\n#[derive(Default)]\nstruct Visitor {\n    text: String,\n    variables: Vec<String>,\n}\n\nimpl Visitor {\n    fn into_output(self) -> (String, Vec<Variable>) {\n        // make unique, preserving order\n        let mut seen = HashSet::new();\n        let vars: Vec<_> = self\n            .variables\n            .into_iter()\n            .filter(|v| {\n                if seen.contains(v) {\n                    false\n                } else {\n                    seen.insert(v.clone())\n                }\n            })\n            .map(Into::into)\n            .collect();\n        (self.text, vars)\n    }\n\n    fn visit_pattern(&mut self, pattern: &Pattern<&str>) {\n        for element in &pattern.elements {\n            match element {\n                PatternElement::TextElement { value } => self.text.push_str(value),\n                PatternElement::Placeable { expression } => self.visit_expression(expression),\n            }\n        }\n    }\n\n    fn visit_inline_expression(&mut self, expr: &InlineExpression<&str>, in_select: bool) {\n        match expr {\n            InlineExpression::VariableReference { id } => {\n                if !in_select {\n                    write!(self.text, \"{{${}}}\", id.name).unwrap();\n                }\n                self.variables.push(id.name.to_string());\n            }\n            InlineExpression::Placeable { expression } => {\n                self.visit_expression(expression);\n            }\n            _ => {}\n        }\n    }\n\n    fn visit_expression(&mut self, expression: &Expression<&str>) {\n        match expression {\n            Expression::Select { selector, variants } => {\n                self.visit_inline_expression(selector, true);\n                self.visit_pattern(&variants.last().unwrap().value)\n            }\n            Expression::Inline(expr) => self.visit_inline_expression(expr, false),\n        }\n    }\n}\n\nimpl From<String> for Variable {\n    fn from(name: String) -> Self {\n        // rather than adding more items here as we add new strings, we should probably\n        // try to either reuse existing ones, or consider some sort of Hungarian\n        // notation\n        let kind = match name.as_str() {\n            \"cards\" | \"notes\" | \"count\" | \"amount\" | \"reviews\" | \"total\" | \"selected\"\n            | \"kilobytes\" | \"daysStart\" | \"daysEnd\" | \"days\" | \"secs-per-card\" | \"remaining\"\n            | \"hourStart\" | \"hourEnd\" | \"correct\" | \"decks\" | \"changed\" => VariableKind::Int,\n            \"average-seconds\" | \"cards-per-minute\" => VariableKind::Float,\n            \"val\" | \"found\" | \"expected\" | \"part\" | \"percent\" | \"day\" | \"number\" | \"up\"\n            | \"down\" | \"seconds\" | \"megs\" => VariableKind::Any,\n            term => {\n                let term = term.to_ascii_lowercase();\n                if term.ends_with(\"count\") {\n                    VariableKind::Int\n                } else if term.starts_with(\"num\") {\n                    VariableKind::Any\n                } else {\n                    VariableKind::String\n                }\n            }\n        };\n        Variable { name, kind }\n    }\n}\n"
  },
  {
    "path": "rslib/i18n/gather.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! By default, the Qt translations will be included in rslib. EXTRA_FTL_ROOT\n//! can be set to an external folder so the mobile clients can use their own\n//! translations instead.\n\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::Path;\nuse std::path::PathBuf;\n\npub type TranslationsByFile = HashMap<String, String>;\npub type TranslationsByLang = HashMap<String, TranslationsByFile>;\n\n/// Read the contents of the FTL files into a TranslationMap structure.\npub fn get_ftl_data() -> TranslationsByLang {\n    let mut map = TranslationsByLang::default();\n\n    // English core templates are taken from this repo\n    let ftl_base = source_tree_root();\n    add_folder(&mut map, &ftl_base.join(\"core\"), \"templates\");\n    // And core translations from submodule\n    add_translation_root(&mut map, &ftl_base.join(\"core-repo/core\"), true);\n\n    if let Some(path) = extra_ftl_root() {\n        // Mobile client has requested its own extra translations\n        add_translation_root(&mut map, &path, false);\n    } else {\n        // Qt core templates from this repo\n        add_folder(&mut map, &ftl_base.join(\"qt\"), \"templates\");\n        // And translations from submodule\n        add_translation_root(&mut map, &ftl_base.join(\"qt-repo/desktop\"), true)\n    }\n    map\n}\n\n/// For each .ftl file in the provided folder, add it to the translation map.\nfn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) {\n    let map_entry = map.entry(lang.to_string()).or_default();\n    for entry in fs::read_dir(folder).unwrap() {\n        let entry = entry.unwrap();\n        let fname = entry.file_name().to_string_lossy().to_string();\n        if !fname.ends_with(\".ftl\") {\n            continue;\n        }\n        let module = fname.trim_end_matches(\".ftl\").replace('-', \"_\");\n        let text = fs::read_to_string(entry.path()).unwrap();\n        assert!(\n            text.ends_with('\\n'),\n            \"file was missing final newline: {entry:?}\"\n        );\n        map_entry.entry(module).or_default().push_str(&text);\n        println!(\"cargo:rerun-if-changed={}\", entry.path().to_str().unwrap());\n    }\n}\n\n/// For each language folder in `root`, add the ftl files stored inside.\n/// If ignore_templates is true, the templates/ folder will be ignored, on the\n/// assumption the templates were extracted from the source tree.\nfn add_translation_root(map: &mut TranslationsByLang, root: &Path, ignore_templates: bool) {\n    for entry in fs::read_dir(root).unwrap() {\n        let entry = entry.unwrap();\n        let lang = entry.file_name().to_string_lossy().to_string();\n        if ignore_templates && lang == \"templates\" {\n            continue;\n        }\n        add_folder(map, &entry.path(), &lang);\n    }\n}\n\nfn source_tree_root() -> PathBuf {\n    PathBuf::from(\"../../ftl\")\n}\n\nfn extra_ftl_root() -> Option<PathBuf> {\n    std::env::var(\"EXTRA_FTL_ROOT\").ok().map(PathBuf::from)\n}\n"
  },
  {
    "path": "rslib/i18n/python.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Write;\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anyhow::Result;\nuse inflections::Inflect;\nuse itertools::Itertools;\n\nuse crate::extract::Module;\nuse crate::extract::Variable;\nuse crate::extract::VariableKind;\n\npub fn write_py_interface(modules: &[Module]) -> Result<()> {\n    let mut out = header();\n\n    render_methods(modules, &mut out);\n    render_legacy_enum(modules, &mut out);\n\n    if let Some(path) = option_env!(\"STRINGS_PY\") {\n        let path = PathBuf::from(path);\n        create_dir_all(path.parent().unwrap())?;\n        write_file_if_changed(path, out)?;\n    }\n\n    Ok(())\n}\n\nfn render_legacy_enum(modules: &[Module], out: &mut String) {\n    out.push_str(\"class LegacyTranslationEnum:\\n\");\n    for (mod_idx, module) in modules.iter().enumerate() {\n        for (str_idx, translation) in module.translations.iter().enumerate() {\n            let upper = translation.key.replace('-', \"_\").to_upper_case();\n            writeln!(out, r#\"    {upper} = ({mod_idx}, {str_idx})\"#).unwrap();\n        }\n    }\n}\n\nfn render_methods(modules: &[Module], out: &mut String) {\n    for (mod_idx, module) in modules.iter().enumerate() {\n        for (str_idx, translation) in module.translations.iter().enumerate() {\n            let text = &translation.text;\n            let key = &translation.key;\n            let func_name = key.replace('-', \"_\").to_snake_case();\n            let arg_types = get_arg_types(&translation.variables);\n            let args = get_args(&translation.variables);\n            writeln!(\n                out,\n                r#\"\n    def {func_name}(self, {arg_types}) -> str:\n        r''' {text} '''\n        return self._translate({mod_idx}, {str_idx}, {{{args}}})\n\"#,\n            )\n            .unwrap();\n        }\n    }\n}\n\nfn header() -> String {\n    \"# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# This file is automatically generated from the *.ftl files.\n\nfrom __future__ import annotations\n\nfrom typing import Union\n\nFluentVariable = Union[str, int, float]\n\nclass GeneratedTranslations:\n    def _translate(self, module: int, translation: int, args: dict) -> str:\n        raise Exception('not implemented')\n\n\"\n    .to_string()\n}\n\nfn get_arg_types(args: &[Variable]) -> String {\n    let args = args\n        .iter()\n        .map(|arg| format!(\"{}: {}\", arg.name.to_snake_case(), arg_kind(&arg.kind)))\n        .join(\", \");\n    if args.is_empty() {\n        \"\".into()\n    } else {\n        args\n    }\n}\n\nfn get_args(args: &[Variable]) -> String {\n    args.iter()\n        .map(|arg| format!(\"\\\"{}\\\": {}\", arg.name, arg.name.to_snake_case()))\n        .join(\", \")\n}\n\nfn arg_kind(kind: &VariableKind) -> &str {\n    match kind {\n        VariableKind::Int => \"int\",\n        VariableKind::Float => \"float\",\n        VariableKind::String => \"str\",\n        VariableKind::Any => \"FluentVariable\",\n    }\n}\n"
  },
  {
    "path": "rslib/i18n/src/generated.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![allow(clippy::all)]\n\n#[derive(Clone)]\npub struct All;\n\n// Include auto-generated content\ninclude!(concat!(env!(\"OUT_DIR\"), \"/strings.rs\"));\n\nimpl Translations for All {\n    const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS;\n    const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE;\n}\n"
  },
  {
    "path": "rslib/i18n/src/generated_launcher.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![allow(clippy::all)]\n\n#[derive(Clone)]\npub struct Launcher;\n\n// Include auto-generated content\ninclude!(concat!(env!(\"OUT_DIR\"), \"/strings_launcher.rs\"));\n\nimpl Translations for Launcher {\n    const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS;\n    const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE;\n}\n"
  },
  {
    "path": "rslib/i18n/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod generated;\nmod generated_launcher;\n\nuse std::borrow::Cow;\nuse std::marker::PhantomData;\nuse std::sync::Arc;\nuse std::sync::Mutex;\n\nuse fluent::types::FluentNumber;\nuse fluent::FluentArgs;\nuse fluent::FluentResource;\nuse fluent::FluentValue;\nuse fluent_bundle::bundle::FluentBundle as FluentBundleOrig;\nuse num_format::Locale;\nuse serde::Serialize;\nuse unic_langid::LanguageIdentifier;\n\ntype FluentBundle<T> = FluentBundleOrig<T, intl_memoizer::concurrent::IntlLangMemoizer>;\n\npub use fluent::fluent_args as tr_args;\n\npub use crate::generated::All;\npub use crate::generated_launcher::Launcher;\n\npub trait Number: Into<FluentNumber> {\n    fn round(self) -> Self;\n}\nimpl Number for i32 {\n    #[inline]\n    fn round(self) -> Self {\n        self\n    }\n}\nimpl Number for i64 {\n    #[inline]\n    fn round(self) -> Self {\n        self\n    }\n}\nimpl Number for u32 {\n    #[inline]\n    fn round(self) -> Self {\n        self\n    }\n}\nimpl Number for f32 {\n    // round to 2 decimal places\n    #[inline]\n    fn round(self) -> Self {\n        (self * 100.0).round() / 100.0\n    }\n}\nimpl Number for u64 {\n    #[inline]\n    fn round(self) -> Self {\n        self\n    }\n}\nimpl Number for usize {\n    #[inline]\n    fn round(self) -> Self {\n        self\n    }\n}\n\nfn remapped_lang_name(lang: &LanguageIdentifier) -> &str {\n    let region = lang.region.as_ref().map(|v| v.as_str());\n    match lang.language.as_str() {\n        \"en\" => {\n            match region {\n                Some(\"GB\") | Some(\"AU\") => \"en-GB\",\n                // go directly to fallback\n                _ => \"templates\",\n            }\n        }\n        \"zh\" => match region {\n            Some(\"TW\") | Some(\"HK\") => \"zh-TW\",\n            _ => \"zh-CN\",\n        },\n        \"pt\" => {\n            if let Some(\"PT\") = region {\n                \"pt-PT\"\n            } else {\n                \"pt-BR\"\n            }\n        }\n        \"ga\" => \"ga-IE\",\n        \"hy\" => \"hy-AM\",\n        \"nb\" => \"nb-NO\",\n        \"sv\" => \"sv-SE\",\n        other => other,\n    }\n}\n\n/// Some sample text for testing purposes.\nfn test_en_text() -> &'static str {\n    \"\nvalid-key = a valid key\nonly-in-english = not translated\ntwo-args-key = two args: {$one} and {$two}\nplural = You have {$hats ->\n    [one]   1 hat\n   *[other] {$hats} hats\n}.\n\"\n}\n\nfn test_jp_text() -> &'static str {\n    \"\nvalid-key = キー\ntwo-args-key = {$one}と{$two}    \n\"\n}\n\nfn test_pl_text() -> &'static str {\n    \"\none-arg-key = fake Polish {$one}\n\"\n}\n\n/// Parse resource text into an AST for inclusion in a bundle.\n/// Returns None if text contains errors.\n/// extra_text may contain resources loaded from the filesystem\n/// at runtime. If it contains errors, they will not prevent a\n/// bundle from being returned.\nfn get_bundle(\n    text: &str,\n    extra_text: String,\n    locales: &[LanguageIdentifier],\n) -> Option<FluentBundle<FluentResource>> {\n    let res = FluentResource::try_new(text.into())\n        .map_err(|e| {\n            println!(\"Unable to parse translations file: {e:?}\");\n        })\n        .ok()?;\n\n    let mut bundle: FluentBundle<FluentResource> = FluentBundle::new_concurrent(locales.to_vec());\n    bundle\n        .add_resource(res)\n        .map_err(|e| {\n            println!(\"Duplicate key detected in translation file: {e:?}\");\n        })\n        .ok()?;\n\n    if !extra_text.is_empty() {\n        match FluentResource::try_new(extra_text) {\n            Ok(res) => bundle.add_resource_overriding(res),\n            Err((_res, e)) => println!(\"Unable to parse translations file: {e:?}\"),\n        }\n    }\n\n    // add numeric formatter\n    set_bundle_formatter_for_langs(&mut bundle, locales);\n\n    Some(bundle)\n}\n\n/// Get a bundle that includes any filesystem overrides.\nfn get_bundle_with_extra(\n    text: &str,\n    lang: Option<LanguageIdentifier>,\n) -> Option<FluentBundle<FluentResource>> {\n    let mut extra_text = \"\".into();\n    if cfg!(test) {\n        // inject some test strings in test mode\n        match &lang {\n            None => {\n                extra_text += test_en_text();\n            }\n            Some(lang) if lang.language == \"ja\" => {\n                extra_text += test_jp_text();\n            }\n            Some(lang) if lang.language == \"pl\" => {\n                extra_text += test_pl_text();\n            }\n            _ => {}\n        }\n    }\n\n    let mut locales = if let Some(lang) = lang {\n        vec![lang]\n    } else {\n        vec![]\n    };\n    locales.push(\"en-US\".parse().unwrap());\n\n    get_bundle(text, extra_text, &locales)\n}\n\npub trait Translations {\n    const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>>;\n    const KEYS_BY_MODULE: &[&[&str]];\n}\n\n#[derive(Clone)]\npub struct I18n<P: Translations = All> {\n    inner: Arc<Mutex<I18nInner>>,\n    _translations_type: std::marker::PhantomData<P>,\n}\n\nimpl<P: Translations> I18n<P> {\n    fn get_key(module_idx: usize, translation_idx: usize) -> &'static str {\n        P::KEYS_BY_MODULE\n            .get(module_idx)\n            .and_then(|translations| translations.get(translation_idx))\n            .cloned()\n            .unwrap_or(\"invalid-module-or-translation-index\")\n    }\n\n    fn get_modules(langs: &[LanguageIdentifier], desired_modules: &[String]) -> Vec<String> {\n        langs\n            .iter()\n            .map(|lang| {\n                let mut buf = String::new();\n                let lang_name = remapped_lang_name(lang);\n                if let Some(strings) = P::STRINGS.get(lang_name) {\n                    if desired_modules.is_empty() {\n                        // empty list, provide all modules\n                        for value in strings.values() {\n                            buf.push_str(value)\n                        }\n                    } else {\n                        for module_name in desired_modules {\n                            if let Some(text) = strings.get(module_name.as_str()) {\n                                buf.push_str(text);\n                            }\n                        }\n                    }\n                }\n                buf\n            })\n            .collect()\n    }\n\n    /// This temporarily behaves like the older code; in the future we could\n    /// either access each &str separately, or load them on demand.\n    fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {\n        let lang = remapped_lang_name(lang);\n        if let Some(module) = P::STRINGS.get(lang) {\n            let mut text = String::new();\n            for module_text in module.values() {\n                text.push_str(module_text)\n            }\n            Some(text)\n        } else {\n            None\n        }\n    }\n\n    pub fn template_only() -> Self {\n        Self::new::<&str>(&[])\n    }\n\n    pub fn new<S: AsRef<str>>(locale_codes: &[S]) -> Self {\n        let mut input_langs = vec![];\n        let mut bundles = Vec::with_capacity(locale_codes.len() + 1);\n\n        for code in locale_codes {\n            let code = code.as_ref();\n            if let Ok(lang) = code.parse::<LanguageIdentifier>() {\n                input_langs.push(lang.clone());\n                if lang.language == \"en\" {\n                    // if English was listed, any further preferences are skipped,\n                    // as the template has 100% coverage, and we need to ensure\n                    // it is tried prior to any other langs.\n                    break;\n                }\n            }\n        }\n\n        let mut output_langs = vec![];\n        for lang in input_langs {\n            // if the language is bundled in the binary\n            if let Some(text) = Self::ftl_localized_text(&lang).or_else(|| {\n                // when testing, allow missing translations\n                if cfg!(test) {\n                    Some(String::new())\n                } else {\n                    None\n                }\n            }) {\n                if let Some(bundle) = get_bundle_with_extra(&text, Some(lang.clone())) {\n                    bundles.push(bundle);\n                    output_langs.push(lang);\n                } else {\n                    println!(\"Failed to create bundle for {:?}\", lang.language)\n                }\n            }\n        }\n\n        // add English templates\n        let template_lang = \"en-US\".parse().unwrap();\n        let template_text = Self::ftl_localized_text(&template_lang).unwrap();\n        let template_bundle = get_bundle_with_extra(&template_text, None).unwrap();\n        bundles.push(template_bundle);\n        output_langs.push(template_lang);\n\n        if locale_codes.is_empty() || cfg!(test) {\n            // disable isolation characters in test mode\n            for bundle in &mut bundles {\n                bundle.set_use_isolating(false);\n            }\n        }\n\n        Self {\n            inner: Arc::new(Mutex::new(I18nInner {\n                bundles,\n                langs: output_langs,\n            })),\n            _translations_type: PhantomData,\n        }\n    }\n\n    pub fn translate_via_index(\n        &self,\n        module_index: usize,\n        message_index: usize,\n        args: FluentArgs,\n    ) -> String {\n        let key = Self::get_key(module_index, message_index);\n        self.translate(key, Some(args)).into()\n    }\n\n    fn translate<'a>(&'a self, key: &str, args: Option<FluentArgs>) -> Cow<'a, str> {\n        for bundle in &self.inner.lock().unwrap().bundles {\n            let msg = match bundle.get_message(key) {\n                Some(msg) => msg,\n                // not translated in this bundle\n                None => continue,\n            };\n\n            let pat = match msg.value() {\n                Some(val) => val,\n                // empty value\n                None => continue,\n            };\n\n            let mut errs = vec![];\n            let out = bundle.format_pattern(pat, args.as_ref(), &mut errs);\n            if !errs.is_empty() {\n                println!(\"Error(s) in translation '{key}': {errs:?}\");\n            }\n            // clone so we can discard args\n            return out.to_string().into();\n        }\n\n        // return the key name if it was missing\n        key.to_string().into()\n    }\n\n    /// Return text from configured locales for use with the JS Fluent\n    /// implementation.\n    pub fn resources_for_js(&self, desired_modules: &[String]) -> ResourcesForJavascript {\n        let inner = self.inner.lock().unwrap();\n        let resources = Self::get_modules(&inner.langs, desired_modules);\n        ResourcesForJavascript {\n            langs: inner.langs.iter().map(ToString::to_string).collect(),\n            resources,\n        }\n    }\n}\n\nstruct I18nInner {\n    // bundles in preferred language order, with template English as the\n    // last element\n    bundles: Vec<FluentBundle<FluentResource>>,\n    langs: Vec<LanguageIdentifier>,\n}\n\n// Simple number formatting implementation\n\nfn set_bundle_formatter_for_langs<T>(bundle: &mut FluentBundle<T>, langs: &[LanguageIdentifier]) {\n    let formatter = if want_comma_as_decimal_separator(langs) {\n        format_decimal_with_comma\n    } else {\n        format_decimal_with_period\n    };\n\n    bundle.set_formatter(Some(formatter));\n}\n\nfn first_available_num_format_locale(langs: &[LanguageIdentifier]) -> Option<Locale> {\n    for lang in langs {\n        if let Some(locale) = num_format_locale(lang) {\n            return Some(locale);\n        }\n    }\n    None\n}\n\n// try to locate a num_format locale for a given language identifier\nfn num_format_locale(lang: &LanguageIdentifier) -> Option<Locale> {\n    // region provided?\n    if let Some(region) = lang.region {\n        let code = format!(\"{}_{}\", lang.language, region);\n        if let Ok(locale) = Locale::from_name(code) {\n            return Some(locale);\n        }\n    }\n    // try the language alone\n    Locale::from_name(lang.language.as_str()).ok()\n}\n\nfn want_comma_as_decimal_separator(langs: &[LanguageIdentifier]) -> bool {\n    let separator = if let Some(locale) = first_available_num_format_locale(langs) {\n        locale.decimal()\n    } else {\n        \".\"\n    };\n\n    separator == \",\"\n}\n\nfn format_decimal_with_comma(\n    val: &FluentValue,\n    _intl: &intl_memoizer::concurrent::IntlLangMemoizer,\n) -> Option<String> {\n    format_number_values(val, Some(\",\"))\n}\n\nfn format_decimal_with_period(\n    val: &FluentValue,\n    _intl: &intl_memoizer::concurrent::IntlLangMemoizer,\n) -> Option<String> {\n    format_number_values(val, None)\n}\n\n#[inline]\nfn format_number_values(val: &FluentValue, alt_separator: Option<&'static str>) -> Option<String> {\n    match val {\n        FluentValue::Number(num) => {\n            // create a string with desired maximum digits\n            let max_frac_digits = 2;\n            let with_max_precision = format!(\n                \"{number:.precision$}\",\n                number = num.value,\n                precision = max_frac_digits\n            );\n\n            // remove any excess trailing zeros\n            let mut val: Cow<str> = with_max_precision.trim_end_matches('0').into();\n\n            // adding back any required to meet minimum_fraction_digits\n            if let Some(minfd) = num.options.minimum_fraction_digits {\n                let pos = val.find('.').expect(\"expected . in formatted string\");\n                let frac_num = val.len() - pos - 1;\n                let zeros_needed = minfd - frac_num;\n                if zeros_needed > 0 {\n                    val = format!(\"{}{}\", val, \"0\".repeat(zeros_needed)).into();\n                }\n            }\n\n            // lop off any trailing '.'\n            let result = val.trim_end_matches('.');\n\n            if let Some(sep) = alt_separator {\n                Some(result.replace('.', sep))\n            } else {\n                Some(result.to_string())\n            }\n        }\n        _ => None,\n    }\n}\n\n#[derive(Serialize)]\npub struct ResourcesForJavascript {\n    langs: Vec<String>,\n    resources: Vec<String>,\n}\n\npub fn without_unicode_isolation(s: &str) -> String {\n    s.replace(['\\u{2068}', '\\u{2069}'], \"\")\n}\n\n#[cfg(test)]\nmod test {\n    use unic_langid::langid;\n\n    use super::*;\n\n    #[test]\n    fn numbers() {\n        assert!(!want_comma_as_decimal_separator(&[langid!(\"en-US\")]));\n        assert!(want_comma_as_decimal_separator(&[langid!(\"pl-PL\")]));\n    }\n\n    #[test]\n    fn decimal_rounding() {\n        let tr = I18n::new(&[\"en\"]);\n\n        assert_eq!(tr.browsing_cards_deleted(1.001), \"1 card deleted.\");\n        assert_eq!(tr.browsing_cards_deleted(1.01), \"1.01 cards deleted.\");\n    }\n\n    #[test]\n    fn i18n() {\n        // English template\n        let tr = I18n::<All>::new(&[\"zz\"]);\n        assert_eq!(tr.translate(\"valid-key\", None), \"a valid key\");\n        assert_eq!(tr.translate(\"invalid-key\", None), \"invalid-key\");\n\n        assert_eq!(\n            tr.translate(\"two-args-key\", Some(tr_args![\"one\"=>1.1, \"two\"=>\"2\"])),\n            \"two args: 1.1 and 2\"\n        );\n\n        assert_eq!(\n            tr.translate(\"plural\", Some(tr_args![\"hats\"=>1.0])),\n            \"You have 1 hat.\"\n        );\n        assert_eq!(\n            tr.translate(\"plural\", Some(tr_args![\"hats\"=>1.1])),\n            \"You have 1.1 hats.\"\n        );\n        assert_eq!(\n            tr.translate(\"plural\", Some(tr_args![\"hats\"=>3])),\n            \"You have 3 hats.\"\n        );\n\n        // Another language\n        let tr = I18n::<All>::new(&[\"ja_JP\"]);\n        assert_eq!(tr.translate(\"valid-key\", None), \"キー\");\n        assert_eq!(tr.translate(\"only-in-english\", None), \"not translated\");\n        assert_eq!(tr.translate(\"invalid-key\", None), \"invalid-key\");\n\n        assert_eq!(\n            tr.translate(\"two-args-key\", Some(tr_args![\"one\"=>1, \"two\"=>\"2\"])),\n            \"1と2\"\n        );\n\n        // Decimal separator\n        let tr = I18n::<All>::new(&[\"pl-PL\"]);\n        // Polish will use a comma if the string is translated\n        assert_eq!(\n            tr.translate(\"one-arg-key\", Some(tr_args![\"one\"=>2.07])),\n            \"fake Polish 2,07\"\n        );\n\n        // but if it falls back on English, it will use an English separator\n        assert_eq!(\n            tr.translate(\"two-args-key\", Some(tr_args![\"one\"=>1, \"two\"=>2.07])),\n            \"two args: 1 and 2.07\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/i18n/typescript.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Write;\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anyhow::Result;\nuse inflections::Inflect;\nuse itertools::Itertools;\n\nuse crate::extract::Module;\nuse crate::extract::Variable;\nuse crate::extract::VariableKind;\n\npub fn write_ts_interface(modules: &[Module]) -> Result<()> {\n    let mut ts_out = header();\n    write_imports(&mut ts_out);\n\n    render_module_map(modules, &mut ts_out);\n    render_methods(modules, &mut ts_out);\n\n    if let Some(path) = option_env!(\"STRINGS_TS\") {\n        let path = PathBuf::from(path);\n        create_dir_all(path.parent().unwrap())?;\n        write_file_if_changed(path, ts_out)?;\n    }\n\n    Ok(())\n}\n\nfn render_module_map(modules: &[Module], ts_out: &mut String) {\n    ts_out.push_str(\"export enum ModuleName {\\n\");\n    for module in modules {\n        let name = &module.name;\n        let upper = name.to_upper_case();\n        writeln!(ts_out, r#\"    {upper} = \"{name}\",\"#).unwrap();\n    }\n    ts_out.push('}');\n}\n\nfn render_methods(modules: &[Module], ts_out: &mut String) {\n    for module in modules {\n        for translation in &module.translations {\n            let text = &translation.text;\n            let key = &translation.key;\n            let func_name = key.replace('-', \"_\").to_camel_case();\n            let arg_types = get_arg_types(&translation.variables);\n            let args = get_args(&translation.variables);\n            let maybe_args = if translation.variables.is_empty() {\n                \"\".to_string()\n            } else {\n                arg_types\n            };\n            writeln!(\n                ts_out,\n                r#\"\n/** {text} */\nexport function {func_name}({maybe_args}) {{\n    return translate(\"{key}\", {args})\n}}\"#,\n            )\n            .unwrap();\n        }\n    }\n}\n\nfn get_args(variables: &[Variable]) -> String {\n    if variables.is_empty() {\n        \"\".into()\n    } else if variables\n        .iter()\n        .all(|v| v.name == typescript_arg_name(&v.name))\n    {\n        // can use as-is\n        \"args\".into()\n    } else {\n        let out = variables\n            .iter()\n            .map(|v| format!(\"\\\"{}\\\": args.{}\", v.name, typescript_arg_name(&v.name)))\n            .join(\", \");\n        format!(\"{{{out}}}\")\n    }\n}\n\nfn typescript_arg_name(name: &str) -> String {\n    name.replace('-', \"_\").to_camel_case()\n}\n\nfn write_imports(buf: &mut String) {\n    buf.push_str(\n        \"\nimport { translate } from './ftl-helpers';\nexport { firstLanguage, setBundles } from './ftl-helpers';\nexport { FluentBundle, FluentResource } from '@fluent/bundle';\n\",\n    );\n}\n\nfn header() -> String {\n    \"// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\n    .to_string()\n}\n\nfn get_arg_types(args: &[Variable]) -> String {\n    let args = args\n        .iter()\n        .map(|arg| format!(\"{}: {}\", arg.name.to_camel_case(), arg_kind(&arg.kind)))\n        .join(\", \");\n    if args.is_empty() {\n        \"\".into()\n    } else {\n        format!(\"args: {{{args}}}\",)\n    }\n}\n\nfn arg_kind(kind: &VariableKind) -> &str {\n    match kind {\n        VariableKind::Int | VariableKind::Float => \"number\",\n        VariableKind::String => \"string\",\n        VariableKind::Any => \"number | string\",\n    }\n}\n"
  },
  {
    "path": "rslib/i18n/write_strings.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Write strings to a strings.rs file that will be compiled into the binary.\n\nuse std::fmt::Write;\nuse std::fs;\nuse std::path::PathBuf;\n\nuse inflections::Inflect;\n\nuse crate::extract::Module;\nuse crate::extract::Translation;\nuse crate::extract::VariableKind;\nuse crate::gather::TranslationsByFile;\nuse crate::gather::TranslationsByLang;\n\npub fn write_strings(map: &TranslationsByLang, modules: &[Module], out_fn: &str, tag: &str) {\n    let mut buf = String::new();\n\n    // lang->module map\n    write_lang_map(map, &mut buf);\n    // module name->translations\n    write_translations_by_module(map, &mut buf);\n    // ordered list of translations by module\n    write_translation_key_index(modules, &mut buf);\n    // methods to generate messages\n    write_methods(modules, &mut buf, tag);\n\n    let dir = PathBuf::from(std::env::var(\"OUT_DIR\").unwrap());\n    let path = dir.join(out_fn);\n    fs::write(path, buf).unwrap();\n}\n\nfn write_methods(modules: &[Module], buf: &mut String, tag: &str) {\n    buf.push_str(\n        r#\"\n#[allow(unused_imports)]\nuse crate::{I18n,Number,Translations};\n#[allow(unused_imports)]\nuse fluent::{FluentValue, FluentArgs};\nuse std::borrow::Cow;\n\n\"#,\n    );\n    writeln!(buf, \"impl I18n<{tag}> {{\").unwrap();\n    for module in modules {\n        for translation in &module.translations {\n            let func = translation.key.to_snake_case();\n            let key = &translation.key;\n            let doc = translation.text.replace('\\n', \" \");\n            let in_args;\n            let out_args;\n            let var_build;\n            if translation.variables.is_empty() {\n                in_args = \"\".to_string();\n                out_args = \", None\".to_string();\n                var_build = \"\".to_string();\n            } else {\n                in_args = build_in_args(translation);\n                var_build = build_vars(translation);\n                out_args = \", Some(args)\".to_string();\n            }\n\n            writeln!(\n                buf,\n                r#\"\n    /// {doc}\n    #[inline]\n    pub fn {func}<'a>(&'a self{in_args}) -> Cow<'a, str> {{\n{var_build}\n        self.translate(\"{key}\"{out_args})\n    }}\"#,\n            )\n            .unwrap();\n        }\n    }\n\n    buf.push_str(\"}\\n\");\n}\n\nfn build_vars(translation: &Translation) -> String {\n    if translation.variables.is_empty() {\n        \"let args = None;\\n\".into()\n    } else {\n        let mut buf = String::from(\n            r#\"\n        let mut args = FluentArgs::new();\n\"#,\n        );\n        for v in &translation.variables {\n            let fluent_name = &v.name;\n            let rust_name = v.name.to_snake_case();\n            let trailer = match v.kind {\n                VariableKind::Any => \"\",\n                VariableKind::Int | VariableKind::Float => \".round().into()\",\n                VariableKind::String => \".into()\",\n            };\n            writeln!(\n                buf,\n                r#\"        args.set(\"{fluent_name}\", {rust_name}{trailer});\"#,\n            )\n            .unwrap();\n        }\n\n        buf\n    }\n}\n\nfn build_in_args(translation: &Translation) -> String {\n    let v: Vec<_> = translation\n        .variables\n        .iter()\n        .map(|var| {\n            let kind = match var.kind {\n                VariableKind::Int => \"impl Number\",\n                VariableKind::Float => \"impl Number\",\n                VariableKind::String => \"impl Into<String>\",\n                // VariableKind::Any => \"&str\",\n                _ => \"impl Into<FluentValue<'a>>\",\n            };\n            format!(\"{}: {}\", var.name.to_snake_case(), kind)\n        })\n        .collect();\n    format!(\", {}\", v.join(\", \"))\n}\n\nfn write_translation_key_index(modules: &[Module], buf: &mut String) {\n    for module in modules {\n        writeln!(\n            buf,\n            \"pub(crate) const {key}: [&str; {count}] = [\",\n            key = module_constant_name(&module.name),\n            count = module.translations.len(),\n        )\n        .unwrap();\n\n        for translation in &module.translations {\n            writeln!(buf, r#\"    \"{key}\",\"#, key = translation.key).unwrap();\n        }\n\n        buf.push_str(\"];\\n\")\n    }\n\n    writeln!(\n        buf,\n        \"pub(crate) const _KEYS_BY_MODULE: [&[&str]; {count}] = [\",\n        count = modules.len(),\n    )\n    .unwrap();\n\n    for module in modules {\n        writeln!(\n            buf,\n            r#\"    &{module_slice},\"#,\n            module_slice = module_constant_name(&module.name)\n        )\n        .unwrap();\n    }\n\n    buf.push_str(\"];\\n\")\n}\n\nfn write_lang_map(map: &TranslationsByLang, buf: &mut String) {\n    buf.push_str(\n        \"\npub(crate) const _STRINGS: phf::Map<&str, &phf::Map<&str, &str>> = phf::phf_map! {\n\",\n    );\n\n    for lang in map.keys() {\n        writeln!(\n            buf,\n            r#\"    \"{lang}\" => &{constant},\"#,\n            lang = lang,\n            constant = lang_constant_name(lang)\n        )\n        .unwrap();\n    }\n\n    buf.push_str(\"};\\n\");\n}\n\nfn write_translations_by_module(map: &TranslationsByLang, buf: &mut String) {\n    for (lang, modules) in map {\n        write_module_map(buf, lang, modules);\n    }\n}\n\nfn write_module_map(buf: &mut String, lang: &str, modules: &TranslationsByFile) {\n    writeln!(\n        buf,\n        \"\npub(crate) const {lang_name}: phf::Map<&str, &str> = phf::phf_map! {{\",\n        lang_name = lang_constant_name(lang)\n    )\n    .unwrap();\n\n    for (module, contents) in modules {\n        let escaped_contents = escape_unicode_control_chars(contents);\n        writeln!(\n            buf,\n            r###\"        \"{module}\" => r##\"{escaped_contents}\"##,\"###\n        )\n        .unwrap();\n    }\n\n    buf.push_str(\"};\\n\");\n}\n\nfn escape_unicode_control_chars(input: &str) -> String {\n    use regex::Regex;\n\n    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();\n    let re = RE.get_or_init(|| Regex::new(r\"[\\u{202a}-\\u{202e}\\u{2066}-\\u{2069}]\").unwrap());\n\n    re.replace_all(input, |caps: &regex::Captures| {\n        let c = caps.get(0).unwrap().as_str().chars().next().unwrap();\n        format!(\"\\\\u{{{:04x}}}\", c as u32)\n    })\n    .into_owned()\n}\n\nfn lang_constant_name(lang: &str) -> String {\n    lang.to_ascii_uppercase().replace('-', \"_\")\n}\n\nfn module_constant_name(module: &str) -> String {\n    format!(\"{}_KEYS\", module.to_ascii_uppercase())\n}\n"
  },
  {
    "path": "rslib/io/Cargo.toml",
    "content": "[package]\nname = \"anki_io\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Utils for better I/O error reporting\"\n\n[dependencies]\ncamino.workspace = true\nsnafu.workspace = true\ntempfile.workspace = true\n"
  },
  {
    "path": "rslib/io/src/error.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::path::PathBuf;\n\nuse snafu::Snafu;\n\n/// Wrapper for [std::io::Error] with additional information on the attempted\n/// operation.\n#[derive(Debug, Snafu)]\n#[snafu(visibility(pub), display(\"{op:?} {path:?}\"))]\npub struct FileIoError {\n    pub path: PathBuf,\n    pub op: FileOp,\n    pub source: std::io::Error,\n}\n\nimpl PartialEq for FileIoError {\n    fn eq(&self, other: &Self) -> bool {\n        self.path == other.path && self.op == other.op\n    }\n}\n\nimpl Eq for FileIoError {}\n\n#[derive(Debug, PartialEq, Clone, Eq)]\npub enum FileOp {\n    Read,\n    Open,\n    Create,\n    Write,\n    Remove,\n    CopyFrom(PathBuf),\n    Persist,\n    Sync,\n    Metadata,\n    DecodeUtf8Filename,\n    SetFileTimes,\n    /// For legacy errors without any context.\n    Unknown,\n}\n\nimpl FileOp {\n    pub fn copy(from: impl Into<PathBuf>) -> Self {\n        Self::CopyFrom(from.into())\n    }\n}\n\nimpl FileIoError {\n    pub fn message(&self) -> String {\n        format!(\n            \"Failed to {} '{}': {}\",\n            match &self.op {\n                FileOp::Unknown => return format!(\"{}\", self.source),\n                FileOp::Open => \"open\".into(),\n                FileOp::Read => \"read\".into(),\n                FileOp::Create => \"create file in\".into(),\n                FileOp::Write => \"write\".into(),\n                FileOp::Remove => \"remove\".into(),\n                FileOp::CopyFrom(p) => format!(\"copy from '{}' to\", p.to_string_lossy()),\n                FileOp::Persist => \"persist\".into(),\n                FileOp::Sync => \"sync\".into(),\n                FileOp::Metadata => \"get metadata\".into(),\n                FileOp::DecodeUtf8Filename => \"decode utf8 filename\".into(),\n                FileOp::SetFileTimes => \"set file times\".into(),\n            },\n            self.path.to_string_lossy(),\n            self.source\n        )\n    }\n\n    pub fn is_not_found(&self) -> bool {\n        self.source.kind() == std::io::ErrorKind::NotFound\n    }\n}\n\nimpl From<tempfile::PathPersistError> for FileIoError {\n    fn from(err: tempfile::PathPersistError) -> Self {\n        FileIoError {\n            path: err.path.to_path_buf(),\n            op: FileOp::Persist,\n            source: err.error,\n        }\n    }\n}\n\nimpl From<tempfile::PersistError> for FileIoError {\n    fn from(err: tempfile::PersistError) -> Self {\n        FileIoError {\n            path: err.file.path().into(),\n            op: FileOp::Persist,\n            source: err.error,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/io/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod error;\n\nuse std::fs::File;\nuse std::fs::FileTimes;\nuse std::fs::OpenOptions;\nuse std::io::Read;\nuse std::io::Seek;\nuse std::io::Write;\nuse std::path::Component;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse camino::Utf8Path;\nuse camino::Utf8PathBuf;\nuse snafu::ResultExt;\nuse tempfile::NamedTempFile;\n\npub use crate::error::FileIoError;\npub use crate::error::FileIoSnafu;\npub use crate::error::FileOp;\n\npub type Result<T, E = FileIoError> = std::result::Result<T, E>;\n\n/// See [File::create].\npub fn create_file(path: impl AsRef<Path>) -> Result<File> {\n    File::create(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Create,\n    })\n}\n\n/// See [File::open].\npub fn open_file(path: impl AsRef<Path>) -> Result<File> {\n    File::open(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Open,\n    })\n}\n\npub fn open_file_ext(path: impl AsRef<Path>, options: OpenOptions) -> Result<File> {\n    options.open(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Open,\n    })\n}\n\n/// See [std::fs::write].\npub fn write_file(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<()> {\n    std::fs::write(&path, contents).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Write,\n    })\n}\n\npub fn write_file_and_flush(\n    path: impl AsRef<Path> + Clone,\n    contents: impl AsRef<[u8]>,\n) -> Result<()> {\n    let mut file = create_file(path.clone())?;\n    file.write_all(contents.as_ref()).context(FileIoSnafu {\n        path: path.clone().as_ref(),\n        op: FileOp::Write,\n    })?;\n    file.sync_all().context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Sync,\n    })\n}\n\n/// See [File::set_times].\npub fn set_file_times(path: impl AsRef<Path>, times: FileTimes) -> Result<()> {\n    #[cfg(not(windows))]\n    let file = open_file(&path)?;\n\n    #[cfg(windows)]\n    let file = {\n        use std::os::windows::fs::OpenOptionsExt;\n        open_file_ext(\n            &path,\n            OpenOptions::new()\n                .write(true)\n                // It's required to modify the time attributes of a directory in windows system.\n                .custom_flags(0x02000000) // FILE_FLAG_BACKUP_SEMANTICS\n                .to_owned(),\n        )?\n    };\n\n    file.set_times(times).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::SetFileTimes,\n    })\n}\n\n/// See [std::fs::remove_file].\n#[allow(dead_code)]\npub fn remove_file(path: impl AsRef<Path>) -> Result<()> {\n    std::fs::remove_file(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Remove,\n    })\n}\n\n/// See [std::fs::remove_dir_all].\n#[allow(dead_code)]\npub fn remove_dir_all(path: impl AsRef<Path>) -> Result<()> {\n    std::fs::remove_dir_all(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Remove,\n    })\n}\n\n/// See [std::fs::create_dir].\npub fn create_dir(path: impl AsRef<Path>) -> Result<()> {\n    std::fs::create_dir(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Create,\n    })\n}\n\n/// See [std::fs::create_dir_all].\npub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {\n    std::fs::create_dir_all(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Create,\n    })\n}\n\n/// See [std::fs::read].\npub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {\n    std::fs::read(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Read,\n    })\n}\n\n/// See [std::fs::read_to_string].\npub fn read_to_string(path: impl AsRef<Path>) -> Result<String> {\n    std::fs::read_to_string(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Read,\n    })\n}\n\n/// See [std::fs::copy].\npub fn copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<u64> {\n    std::fs::copy(&src, &dst).context(FileIoSnafu {\n        path: dst.as_ref(),\n        op: FileOp::CopyFrom(src.as_ref().to_owned()),\n    })\n}\n\n/// Copy a file from src to dst if dst doesn't exist or if src is newer than\n/// dst. Preserves the modification time from the source file.\npub fn copy_if_newer(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<bool> {\n    let src = src.as_ref();\n    let dst = dst.as_ref();\n\n    let should_copy = if !dst.exists() {\n        true\n    } else {\n        let src_time = modified_time(src)?;\n        let dst_time = modified_time(dst)?;\n        src_time > dst_time\n    };\n\n    if should_copy {\n        copy_file(src, dst)?;\n\n        // Preserve the modification time from the source file\n        let src_mtime = modified_time(src)?;\n        let times = FileTimes::new().set_modified(src_mtime);\n        set_file_times(dst, times)?;\n\n        Ok(true)\n    } else {\n        Ok(false)\n    }\n}\n\n/// Like [read_file], but skips the section that is potentially locked by\n/// SQLite.\npub fn read_locked_db_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {\n    read_locked_db_file_inner(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Read,\n    })\n}\n\nconst LOCKED_SECTION_START_BYTE: usize = 1024 * 1024 * 1024;\nconst LOCKED_SECTION_LEN_BYTES: usize = 512;\nconst LOCKED_SECTION_END_BYTE: usize = LOCKED_SECTION_START_BYTE + LOCKED_SECTION_LEN_BYTES;\n\nfn read_locked_db_file_inner(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {\n    let size = std::fs::metadata(&path)?.len() as usize;\n    if size < LOCKED_SECTION_END_BYTE {\n        return std::fs::read(path);\n    }\n\n    let mut file = File::open(&path)?;\n    let mut buf = vec![0; size];\n    file.read_exact(&mut buf[..LOCKED_SECTION_START_BYTE])?;\n    file.seek(std::io::SeekFrom::Current(LOCKED_SECTION_LEN_BYTES as i64))?;\n    file.read_exact(&mut buf[LOCKED_SECTION_END_BYTE..])?;\n\n    Ok(buf)\n}\n\n/// See [std::fs::metadata].\npub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {\n    std::fs::metadata(&path).context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Metadata,\n    })\n}\n\n/// Get the modification time of a file.\npub fn modified_time(path: impl AsRef<Path>) -> Result<std::time::SystemTime> {\n    metadata(&path)?.modified().context(FileIoSnafu {\n        path: path.as_ref(),\n        op: FileOp::Metadata,\n    })\n}\n\npub fn new_tempfile() -> Result<NamedTempFile> {\n    NamedTempFile::new().context(FileIoSnafu {\n        path: std::env::temp_dir(),\n        op: FileOp::Create,\n    })\n}\n\npub fn new_tempfile_in(dir: impl AsRef<Path>) -> Result<NamedTempFile> {\n    NamedTempFile::new_in(&dir).context(FileIoSnafu {\n        path: dir.as_ref(),\n        op: FileOp::Create,\n    })\n}\n\npub fn new_tempfile_in_parent_of(file: &Path) -> Result<NamedTempFile> {\n    let dir = file.parent().unwrap_or(file);\n    NamedTempFile::new_in(dir).context(FileIoSnafu {\n        path: dir,\n        op: FileOp::Create,\n    })\n}\n\n/// Atomically replace the target path with the provided temp file.\n///\n/// If `fsync` is true, file data is synced to disk prior to renaming, and the\n/// folder is synced on UNIX platforms after renaming. This minimizes the\n/// chances of corruption if there is a crash or power loss directly after the\n/// op, but it can be considerably slower.\npub fn atomic_rename(file: NamedTempFile, target: &Path, fsync: bool) -> Result<()> {\n    if fsync {\n        file.as_file().sync_all().context(FileIoSnafu {\n            path: file.path(),\n            op: FileOp::Sync,\n        })?;\n    }\n    file.persist(target)?;\n    #[cfg(not(windows))]\n    if fsync {\n        if let Some(parent) = target.parent() {\n            open_file(parent)?.sync_all().context(FileIoSnafu {\n                path: parent,\n                op: FileOp::Sync,\n            })?;\n        }\n    }\n    Ok(())\n}\n\n/// Like [std::fs::read_dir], but only yielding files. [Err]s are not filtered.\npub fn read_dir_files(path: impl AsRef<Path>) -> Result<ReadDirFiles> {\n    std::fs::read_dir(&path)\n        .map(ReadDirFiles)\n        .context(FileIoSnafu {\n            path: path.as_ref(),\n            op: FileOp::Read,\n        })\n}\n\n/// A shortcut for gathering the utf8 paths in a folder into a vec. Will\n/// abort if any dir entry is unreadable. Does not gather files from subfolders.\npub fn paths_in_dir(path: impl AsRef<Path>) -> Result<Vec<Utf8PathBuf>> {\n    read_dir_files(path.as_ref())?\n        .map(|entry| {\n            let entry = entry.context(FileIoSnafu {\n                path: path.as_ref(),\n                op: FileOp::Read,\n            })?;\n            entry.path().utf8()\n        })\n        .collect()\n}\n\n/// True if name does not contain any path separators.\npub fn filename_is_safe(name: &str) -> bool {\n    let mut components = Path::new(name).components();\n    let first_element_normal = components\n        .next()\n        .map(|component| matches!(component, Component::Normal(_)))\n        .unwrap_or_default();\n\n    first_element_normal && components.next().is_none()\n}\n\npub struct ReadDirFiles(std::fs::ReadDir);\n\nimpl Iterator for ReadDirFiles {\n    type Item = std::io::Result<std::fs::DirEntry>;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        let next = self.0.next();\n        if let Some(Ok(entry)) = next.as_ref() {\n            match entry.metadata().map(|metadata| metadata.is_file()) {\n                Ok(true) => next,\n                Ok(false) => self.next(),\n                Err(error) => Some(Err(error)),\n            }\n        } else {\n            next\n        }\n    }\n}\n\n/// True if changed.\npub fn write_file_if_changed(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<bool> {\n    let path = path.as_ref();\n    let contents = contents.as_ref();\n    let changed = {\n        read_file(path)\n            .map(|existing| existing != contents)\n            .unwrap_or(true)\n    };\n\n    match std::env::var(\"CARGO_PKG_NAME\") {\n        Ok(pkg) if pkg == \"anki_proto\" || pkg == \"anki_i18n\" => {\n            // at comptime for the proto/i18n crates, register implicit output as input\n            println!(\"cargo:rerun-if-changed={}\", path.to_str().unwrap());\n        }\n        _ => {}\n    }\n\n    if changed {\n        write_file(path, contents)?;\n        Ok(true)\n    } else {\n        Ok(false)\n    }\n}\n\npub trait ToUtf8PathBuf {\n    fn utf8(self) -> Result<Utf8PathBuf>;\n}\n\nimpl ToUtf8PathBuf for PathBuf {\n    fn utf8(self) -> Result<Utf8PathBuf> {\n        Utf8PathBuf::from_path_buf(self).map_err(|path| FileIoError {\n            path,\n            op: FileOp::DecodeUtf8Filename,\n            source: std::io::Error::from(std::io::ErrorKind::InvalidData),\n        })\n    }\n}\n\npub trait ToUtf8Path {\n    fn utf8(&self) -> Result<&Utf8Path>;\n}\n\nimpl ToUtf8Path for Path {\n    fn utf8(&self) -> Result<&Utf8Path> {\n        Utf8Path::from_path(self).ok_or_else(|| FileIoError {\n            path: self.into(),\n            op: FileOp::DecodeUtf8Filename,\n            source: std::io::Error::from(std::io::ErrorKind::InvalidData),\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn path_traversal() {\n        assert!(filename_is_safe(\"foo\"));\n\n        assert!(!filename_is_safe(\"..\"));\n        assert!(!filename_is_safe(\"foo/bar\"));\n        assert!(!filename_is_safe(\"/foo\"));\n        assert!(!filename_is_safe(\"../foo\"));\n\n        if cfg!(windows) {\n            assert!(!filename_is_safe(\"foo\\\\bar\"));\n            assert!(!filename_is_safe(\"c:\\\\foo\"));\n            assert!(!filename_is_safe(\"\\\\foo\"));\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/linkchecker/Cargo.toml",
    "content": "[package]\nname = \"linkchecker\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanki.workspace = true\nfutures.workspace = true\nitertools.workspace = true\nlinkcheck.workspace = true\nregex.workspace = true\nreqwest.workspace = true\nstrum.workspace = true\ntokio.workspace = true\n\n[features]\nrustls = [\"reqwest/rustls-tls\", \"reqwest/rustls-tls-native-roots\"]\nnative-tls = [\"reqwest/native-tls\"]\n"
  },
  {
    "path": "rslib/linkchecker/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n"
  },
  {
    "path": "rslib/linkchecker/tests/links.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n\nuse std::borrow::Cow;\nuse std::env;\nuse std::iter;\nuse std::sync::LazyLock;\nuse std::time::Duration;\n\nuse anki::links::help_page_link_suffix;\nuse anki::links::help_page_to_link;\nuse anki::links::HelpPage;\nuse futures::StreamExt;\nuse itertools::Itertools;\nuse linkcheck::validation::check_web;\nuse linkcheck::validation::Context;\nuse linkcheck::validation::Reason;\nuse linkcheck::BasicContext;\nuse regex::Regex;\nuse reqwest::Url;\nuse strum::IntoEnumIterator;\n\nconst WEB_TIMEOUT: Duration = Duration::from_secs(60);\n\n/// Aggregates [`Outcome`]s by collecting the error messages of the invalid\n/// ones.\n#[derive(Default)]\nstruct Outcomes(Vec<String>);\n\n#[derive(Debug)]\nenum Outcome {\n    Valid,\n    Invalid(String),\n}\n\n#[derive(Clone)]\nenum CheckableUrl {\n    HelpPage(HelpPage),\n    String(&'static str),\n}\n\nimpl CheckableUrl {\n    fn url(&self) -> Cow<'_, str> {\n        match *self {\n            Self::HelpPage(page) => help_page_to_link(page).into(),\n            Self::String(s) => s.into(),\n        }\n    }\n\n    fn anchor(&self) -> Cow<'_, str> {\n        match *self {\n            Self::HelpPage(page) => help_page_link_suffix(page).into(),\n            Self::String(s) => s.split('#').next_back().unwrap_or_default().into(),\n        }\n    }\n}\n\nimpl From<HelpPage> for CheckableUrl {\n    fn from(value: HelpPage) -> Self {\n        Self::HelpPage(value)\n    }\n}\n\nimpl From<&'static str> for CheckableUrl {\n    fn from(value: &'static str) -> Self {\n        Self::String(value)\n    }\n}\n\nfn ts_help_pages() -> impl Iterator<Item = &'static str> {\n    static QUOTED_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(\"\\\"(http.+)\\\"\").unwrap());\n\n    QUOTED_URL\n        .captures_iter(include_str!(\"../../../ts/lib/tslib/help-page.ts\"))\n        .map(|caps| caps.get(1).unwrap().as_str())\n}\n\n#[tokio::test]\nasync fn check_links() {\n    if env::var(\"ONLINE_TESTS\").is_err() {\n        println!(\"test disabled; ONLINE_TESTS not set\");\n        return;\n    }\n    let ctx = BasicContext::default();\n    let result = futures::stream::iter(\n        HelpPage::iter()\n            .map(CheckableUrl::from)\n            .chain(ts_help_pages().map(CheckableUrl::from)),\n    )\n    .map(|page| check_url(page, &ctx))\n    .buffer_unordered(ctx.concurrency())\n    .collect::<Outcomes>()\n    .await;\n    if !result.0.is_empty() {\n        panic!(\"{}\", result.message());\n    }\n}\n\nasync fn check_url(page: CheckableUrl, ctx: &BasicContext) -> Outcome {\n    let link = page.url();\n    match Url::parse(&link) {\n        Ok(url) if url.as_str() == link => {\n            let future = check_web(&url, ctx);\n            let timeout = tokio::time::timeout(WEB_TIMEOUT, future);\n            match timeout.await {\n                Err(_) => Outcome::Invalid(format!(\"Timed out: {link}\")),\n                Ok(Ok(())) => Outcome::Valid,\n                Ok(Err(Reason::Dom)) => Outcome::Invalid(format!(\n                    \"'#{}' not found on '{}{}'\",\n                    url.fragment().unwrap(),\n                    url.domain().unwrap(),\n                    url.path(),\n                )),\n                Ok(Err(Reason::Web(err))) => Outcome::Invalid(err.to_string()),\n                _ => unreachable!(),\n            }\n        }\n        Ok(_) => Outcome::Invalid(format!(\"'{}' is not a valid URL part\", page.anchor(),)),\n        Err(err) => Outcome::Invalid(err.to_string()),\n    }\n}\n\nimpl Extend<Outcome> for Outcomes {\n    fn extend<T: IntoIterator<Item = Outcome>>(&mut self, items: T) {\n        for outcome in items {\n            match outcome {\n                Outcome::Valid => (),\n                Outcome::Invalid(err) => self.0.push(err),\n            }\n        }\n    }\n}\n\nimpl Outcomes {\n    fn message(&self) -> String {\n        iter::once(\"invalid links found:\")\n            .chain(self.0.iter().map(String::as_str))\n            .join(\"\\n  - \")\n    }\n}\n"
  },
  {
    "path": "rslib/process/Cargo.toml",
    "content": "[package]\nname = \"anki_process\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Utils for better process error reporting\"\n\n[dependencies]\nitertools.workspace = true\nsnafu.workspace = true\n"
  },
  {
    "path": "rslib/process/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::ffi::OsStr;\nuse std::iter::once;\nuse std::process::Command;\nuse std::string::FromUtf8Error;\n\nuse itertools::Itertools;\nuse snafu::ensure;\nuse snafu::ResultExt;\nuse snafu::Snafu;\n\n#[derive(Debug)]\npub struct CodeDisplay(Option<i32>);\n\nimpl std::fmt::Display for CodeDisplay {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self.0 {\n            Some(code) => write!(f, \"{code}\"),\n            None => write!(f, \"?\"),\n        }\n    }\n}\n\nimpl From<Option<i32>> for CodeDisplay {\n    fn from(code: Option<i32>) -> Self {\n        CodeDisplay(code)\n    }\n}\n\n#[derive(Debug, Snafu)]\npub enum Error {\n    #[snafu(display(\"Failed to execute: {cmdline}\"))]\n    DidNotExecute {\n        cmdline: String,\n        source: std::io::Error,\n    },\n    #[snafu(display(\"Failed to run ({code}): {cmdline}\"))]\n    ReturnedError { cmdline: String, code: CodeDisplay },\n    #[snafu(display(\"Failed to run ({code}): {cmdline}: {stdout}{stderr}\"))]\n    ReturnedWithOutputError {\n        cmdline: String,\n        code: CodeDisplay,\n        stdout: String,\n        stderr: String,\n    },\n    #[snafu(display(\"Couldn't decode stdout/stderr as utf8\"))]\n    InvalidUtf8 {\n        cmdline: String,\n        source: FromUtf8Error,\n    },\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n\npub struct Utf8Output {\n    pub stdout: String,\n    pub stderr: String,\n}\n\npub trait CommandExt {\n    /// A shortcut for when the command and its args are known up-front and have\n    /// no spaces in them.\n    fn run(cmd_and_args: impl AsRef<str>) -> Result<()> {\n        let mut all_args = cmd_and_args.as_ref().split(' ');\n        Command::new(all_args.next().unwrap())\n            .args(all_args)\n            .ensure_success()?;\n        Ok(())\n    }\n    fn run_with_output<I, S>(cmd_and_args: I) -> Result<Utf8Output>\n    where\n        I: IntoIterator<Item = S>,\n        S: AsRef<OsStr>,\n    {\n        let mut all_args = cmd_and_args.into_iter();\n        Command::new(all_args.next().unwrap())\n            .args(all_args)\n            .utf8_output()\n    }\n\n    fn ensure_success(&mut self) -> Result<&mut Self>;\n    fn utf8_output(&mut self) -> Result<Utf8Output>;\n    fn ensure_spawn(&mut self) -> Result<std::process::Child>;\n    #[cfg(unix)]\n    fn ensure_exec(&mut self) -> Result<()>;\n}\n\nimpl CommandExt for Command {\n    fn ensure_success(&mut self) -> Result<&mut Self> {\n        let status = self.status().with_context(|_| DidNotExecuteSnafu {\n            cmdline: get_cmdline(self),\n        })?;\n        ensure!(\n            status.success(),\n            ReturnedSnafu {\n                cmdline: get_cmdline(self),\n                code: CodeDisplay::from(status.code()),\n            }\n        );\n        Ok(self)\n    }\n\n    fn utf8_output(&mut self) -> Result<Utf8Output> {\n        let cmdline = get_cmdline(self);\n        let output = self.output().with_context(|_| DidNotExecuteSnafu {\n            cmdline: cmdline.clone(),\n        })?;\n\n        let stdout = String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu {\n            cmdline: cmdline.clone(),\n        })?;\n        let stderr = String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu {\n            cmdline: cmdline.clone(),\n        })?;\n\n        ensure!(\n            output.status.success(),\n            ReturnedWithOutputSnafu {\n                cmdline,\n                code: CodeDisplay::from(output.status.code()),\n                stdout: stdout.clone(),\n                stderr: stderr.clone(),\n            }\n        );\n\n        Ok(Utf8Output { stdout, stderr })\n    }\n\n    fn ensure_spawn(&mut self) -> Result<std::process::Child> {\n        self.spawn().with_context(|_| DidNotExecuteSnafu {\n            cmdline: get_cmdline(self),\n        })\n    }\n\n    #[cfg(unix)]\n    fn ensure_exec(&mut self) -> Result<()> {\n        use std::os::unix::process::CommandExt as UnixCommandExt;\n        let cmdline = get_cmdline(self);\n        let error = self.exec();\n        Err(Error::DidNotExecute {\n            cmdline,\n            source: error,\n        })\n    }\n}\n\nfn get_cmdline(arg: &mut Command) -> String {\n    once(arg.get_program().to_string_lossy())\n        .chain(arg.get_args().map(|arg| arg.to_string_lossy()))\n        .join(\" \")\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn test_run() {\n        assert_eq!(\n            Command::run(\"fakefake 1 2\").unwrap_err().to_string(),\n            \"Failed to execute: fakefake 1 2\"\n        );\n        #[cfg(not(windows))]\n        assert!(matches!(\n            Command::new(\"false\").ensure_success(),\n            Err(Error::ReturnedError {\n                code: CodeDisplay(_),\n                ..\n            })\n        ));\n    }\n}\n"
  },
  {
    "path": "rslib/proto/Cargo.toml",
    "content": "[package]\nname = \"anki_proto\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Anki's Rust library protobuf code\"\n\n[build-dependencies]\nanki_io.workspace = true\nanki_proto_gen.workspace = true\nanyhow.workspace = true\ninflections.workspace = true\nitertools.workspace = true\nprost-build.workspace = true\nprost-reflect.workspace = true\nprost-types.workspace = true\n\n[dependencies]\nprost.workspace = true\nserde.workspace = true\nsnafu.workspace = true\nstrum.workspace = true\n"
  },
  {
    "path": "rslib/proto/build.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod python;\npub mod rust;\npub mod typescript;\n\nuse anki_proto_gen::descriptors_path;\nuse anki_proto_gen::get_services;\nuse anyhow::Result;\n\nfn main() -> Result<()> {\n    let descriptors_path = descriptors_path();\n\n    let pool = rust::write_rust_protos(descriptors_path)?;\n    let (_, services) = get_services(&pool);\n    python::write_python_interface(&services)?;\n    typescript::write_ts_interface(&services)?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/proto/python.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::io::Cursor;\nuse std::io::Write;\nuse std::path::Path;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anki_proto_gen::BackendService;\nuse anki_proto_gen::Method;\nuse anyhow::Result;\nuse inflections::Inflect;\nuse prost_reflect::FieldDescriptor;\nuse prost_reflect::Kind;\nuse prost_reflect::MessageDescriptor;\n\npub(crate) fn write_python_interface(services: &[BackendService]) -> Result<()> {\n    let output_path = Path::new(\"../../out/pylib/anki/_backend_generated.py\");\n    create_dir_all(output_path.parent().unwrap())?;\n    let mut out = Cursor::new(Vec::new());\n    write_header(&mut out)?;\n\n    for service in services {\n        if [\"BackendAnkidroidService\", \"BackendFrontendService\"].contains(&service.name.as_str()) {\n            continue;\n        }\n        for method in service.all_methods() {\n            render_method(service, method, &mut out);\n        }\n    }\n    write_file_if_changed(output_path, out.into_inner())?;\n\n    Ok(())\n}\n\n/// Generates text like the following:\n///\n/// def get_field_names_raw(self, message: bytes) -> bytes:\n///     return self._run_command(7, 16, message)\n///\n/// def get_field_names(self, ntid: int) -> Sequence[str]:\n///     message = anki.notetypes_pb2.NotetypeId(ntid=ntid)\n///     raw_bytes = self._run_command(7, 16, message.SerializeToString())\n///     output = anki.generic_pb2.StringList()\n///     output.ParseFromString(raw_bytes)\n///     return output.vals\nfn render_method(service: &BackendService, method: &Method, out: &mut impl Write) {\n    let method_name = method.name.to_snake_case();\n    let input = method.proto.input();\n    let output = method.proto.output();\n    let service_idx = service.index;\n    let method_idx = method.index;\n    let comments = format_comments(&method.comments);\n\n    // raw bytes\n    write!(\n        out,\n        r#\"    def {method_name}_raw(self, message: bytes) -> bytes:\n        {comments}return self._run_command({service_idx}, {method_idx}, message)\n\n\"#\n    )\n    .unwrap();\n\n    // (possibly destructured) message\n    let (input_params, input_assign) = maybe_destructured_input(&input);\n    let output_constructor = full_name_to_python(output.full_name());\n    let (output_msg_or_single_field, output_type) = maybe_destructured_output(&output);\n    write!(\n        out,\n        r#\"    def {method_name}({input_params}) -> {output_type}:\n        {comments}{input_assign}\n        raw_bytes = self._run_command({service_idx}, {method_idx}, message.SerializeToString())\n        output = {output_constructor}()\n        output.ParseFromString(raw_bytes)\n        return {output_msg_or_single_field}\n\n\"#\n    )\n    .unwrap();\n}\n\nfn format_comments(comments: &Option<String>) -> String {\n    comments\n        .as_ref()\n        .map(|c| {\n            format!(\n                r#\"\"\"\"{c}\"\"\"\n        \"#\n            )\n        })\n        .unwrap_or_default()\n}\n\n/// If any of the following apply to the input type:\n/// - it has a single field\n/// - its name ends in Request\n/// - it has any optional fields\n///\n/// ...then destructuring will be skipped, and the method will take the input\n/// message directly. Returns (params_line, assignment_lines)\nfn maybe_destructured_input(input: &MessageDescriptor) -> (String, String) {\n    if (input.name().ends_with(\"Request\") || input.fields().len() < 2)\n        && input.oneofs().next().is_none()\n    {\n        // destructure\n        let method_args = build_method_arguments(input);\n        let input_type = full_name_to_python(input.full_name());\n        let input_message_args = build_input_message_arguments(input);\n        let assignment = format!(\"message = {input_type}({input_message_args})\",);\n        (method_args, assignment)\n    } else {\n        // no destructure\n        let params = format!(\"self, message: {}\", full_name_to_python(input.full_name()));\n        let assignment = String::new();\n        (params, assignment)\n    }\n}\n\n/// e.g. \"self, *, note_ids: Sequence[int], new_fields: Sequence[int]\"\nfn build_method_arguments(input: &MessageDescriptor) -> String {\n    let fields = input.fields();\n    let mut args = vec![\"self\".to_string()];\n    if fields.len() >= 2 {\n        args.push(\"*\".to_string());\n    }\n    for field in fields {\n        let arg = format!(\"{}: {}\", field.name(), python_type(&field, false));\n        args.push(arg);\n    }\n    args.join(\", \")\n}\n\n/// e.g. \"note_ids=note_ids, new_fields=new_fields\"\nfn build_input_message_arguments(input: &MessageDescriptor) -> String {\n    input\n        .fields()\n        .map(|field| {\n            let name = field.name();\n            format!(\"{name}={name}\")\n        })\n        .collect::<Vec<_>>()\n        .join(\", \")\n}\n\n// If output type has a single field and is not an enum, we return its single\n// field value directly. Returns (expr, type), where expr is 'output' or\n// 'output.<only_field>'.\nfn maybe_destructured_output(output: &MessageDescriptor) -> (String, String) {\n    let first_field = output.fields().next();\n    if output.fields().len() == 1 && !matches!(first_field.as_ref().unwrap().kind(), Kind::Enum(_))\n    {\n        let field = first_field.unwrap();\n        (\n            format!(\"output.{}\", field.name()),\n            python_type(&field, true),\n        )\n    } else {\n        (\"output\".into(), full_name_to_python(output.full_name()))\n    }\n}\n\n/// e.g. uint32 -> int; repeated bool -> Sequence[bool]\nfn python_type(field: &FieldDescriptor, output: bool) -> String {\n    let kind = match field.kind() {\n        Kind::Int32\n        | Kind::Int64\n        | Kind::Uint32\n        | Kind::Uint64\n        | Kind::Sint32\n        | Kind::Sint64\n        | Kind::Fixed32\n        | Kind::Fixed64\n        | Kind::Sfixed32\n        | Kind::Sfixed64 => \"int\".into(),\n        Kind::Float | Kind::Double => \"float\".into(),\n        Kind::Bool => \"bool\".into(),\n        Kind::String => \"str\".into(),\n        Kind::Bytes => \"bytes\".into(),\n        Kind::Message(msg) => full_name_to_python(msg.full_name()),\n        Kind::Enum(en) => format!(\"{}.V\", full_name_to_python(en.full_name())),\n    };\n    if field.is_list() {\n        if output {\n            format!(\"Sequence[{kind}]\")\n        } else {\n            format!(\"Iterable[{kind}]\")\n        }\n    } else if field.is_map() {\n        let map_kind = field.kind();\n        let map_kind = map_kind.as_message().unwrap();\n        let map_kv: Vec<_> = map_kind.fields().map(|f| python_type(&f, output)).collect();\n        format!(\"Mapping[{}, {}]\", map_kv[0], map_kv[1])\n    } else {\n        kind\n    }\n}\n\n// e.g. anki.import_export.ImportResponse ->\n// anki.import_export_pb2.ImportResponse\nfn full_name_to_python(name: &str) -> String {\n    let mut name = name.splitn(3, '.');\n    format!(\n        \"{}.{}_pb2.{}\",\n        name.next().unwrap(),\n        name.next().unwrap(),\n        name.next().unwrap()\n    )\n}\n\nfn write_header(out: &mut impl Write) -> Result<()> {\n    out.write_all(\n        br#\"# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; https://www.gnu.org/licenses/agpl.html\n\nfrom __future__ import annotations\n\n\"\"\"\nTHIS FILE IS AUTOMATICALLY GENERATED - DO NOT EDIT.\n\nPlease do not access methods on the backend directly - they may be changed\nor removed at any time. Instead, please use the methods on the collection\ninstead. Eg, don't use col.backend.all_deck_config(), instead use\ncol.decks.all_config()\n\"\"\"\n\nfrom typing import *\n\nimport anki\nimport anki.ankiweb_pb2\nimport anki.backend_pb2\nimport anki.card_rendering_pb2\nimport anki.cards_pb2\nimport anki.collection_pb2\nimport anki.config_pb2\nimport anki.deck_config_pb2\nimport anki.decks_pb2\nimport anki.i18n_pb2\nimport anki.image_occlusion_pb2\nimport anki.import_export_pb2\nimport anki.links_pb2\nimport anki.media_pb2\nimport anki.notes_pb2\nimport anki.notetypes_pb2\nimport anki.scheduler_pb2\nimport anki.search_pb2\nimport anki.stats_pb2\nimport anki.sync_pb2\nimport anki.tags_pb2\nimport anki.ankihub_pb2\n\nclass RustBackendGenerated:\n    def _run_command(self, service: int, method: int, input: Any) -> bytes:\n        raise Exception(\"not implemented\")\n\n\"#,\n    )?;\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/proto/rust.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\nuse anki_io::read_file;\nuse anki_io::write_file_if_changed;\nuse anki_proto_gen::add_must_use_annotations;\nuse anki_proto_gen::determine_if_message_is_empty;\nuse anyhow::Context;\nuse anyhow::Result;\nuse prost_reflect::DescriptorPool;\n\npub fn write_rust_protos(descriptors_path: PathBuf) -> Result<DescriptorPool> {\n    set_protoc_path();\n    let proto_dir = PathBuf::from(\"../../proto\");\n    let paths = gather_proto_paths(&proto_dir)?;\n    let out_dir = PathBuf::from(env::var(\"OUT_DIR\").unwrap());\n    let tmp_descriptors = out_dir.join(\"descriptors.tmp\");\n    prost_build::Config::new()\n        .out_dir(&out_dir)\n        .file_descriptor_set_path(&tmp_descriptors)\n        .type_attribute(\n            \"Deck.Filtered.SearchTerm.Order\",\n            \"#[derive(strum::EnumIter)]\",\n        )\n        .type_attribute(\n            \"Deck.Normal.DayLimit\",\n            \"#[derive(Eq, serde::Deserialize, serde::Serialize)]\",\n        )\n        .type_attribute(\"HelpPageLinkRequest.HelpPage\", \"#[derive(strum::EnumIter)]\")\n        .type_attribute(\"CsvMetadata.Delimiter\", \"#[derive(strum::EnumIter)]\")\n        .type_attribute(\n            \"Preferences.BackupLimits\",\n            \"#[derive(serde::Deserialize, serde::Serialize)]\",\n        )\n        .type_attribute(\n            \"CsvMetadata.DupeResolution\",\n            \"#[derive(serde::Deserialize, serde::Serialize)]\",\n        )\n        .type_attribute(\n            \"CsvMetadata.MatchScope\",\n            \"#[derive(serde::Deserialize, serde::Serialize)]\",\n        )\n        .type_attribute(\n            \"ImportAnkiPackageUpdateCondition\",\n            \"#[derive(serde::Deserialize, serde::Serialize)]\",\n        )\n        .compile_protos(paths.as_slice(), &[proto_dir])\n        .context(\"prost build\")?;\n\n    let descriptors = read_file(&tmp_descriptors)?;\n    create_dir_all(\n        descriptors_path\n            .parent()\n            .context(\"missing parent of descriptor\")?,\n    )?;\n    write_file_if_changed(descriptors_path, &descriptors)?;\n\n    let pool = DescriptorPool::decode(descriptors.as_ref())?;\n    add_must_use_annotations(\n        &out_dir,\n        |path| path.file_name().unwrap().starts_with(\"anki.\"),\n        |path, name| determine_if_message_is_empty(&pool, path, name),\n    )?;\n    Ok(pool)\n}\n\nfn gather_proto_paths(proto_dir: &Path) -> Result<Vec<PathBuf>> {\n    let subfolders = &[\"anki\"];\n    let mut paths = vec![];\n    for subfolder in subfolders {\n        for entry in proto_dir.join(subfolder).read_dir().unwrap() {\n            let entry = entry.unwrap();\n            let path = entry.path();\n            if path\n                .file_name()\n                .unwrap()\n                .to_str()\n                .unwrap()\n                .ends_with(\".proto\")\n            {\n                println!(\"cargo:rerun-if-changed={}\", path.to_str().unwrap());\n                paths.push(path);\n            }\n        }\n    }\n    paths.sort();\n    Ok(paths)\n}\n\n/// Set PROTOC to the custom path provided by PROTOC_BINARY, or add .exe to\n/// the standard path if on Windows.\nfn set_protoc_path() {\n    if let Ok(custom_protoc) = env::var(\"PROTOC_BINARY\") {\n        env::set_var(\"PROTOC\", custom_protoc);\n    } else if let Ok(bundled_protoc) = env::var(\"PROTOC\") {\n        if cfg!(windows) && !bundled_protoc.ends_with(\".exe\") {\n            env::set_var(\"PROTOC\", format!(\"{bundled_protoc}.exe\"));\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/proto/src/generic_helpers.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimpl From<Vec<u8>> for crate::generic::Json {\n    fn from(json: Vec<u8>) -> Self {\n        crate::generic::Json { json }\n    }\n}\n\nimpl From<String> for crate::generic::String {\n    fn from(val: String) -> Self {\n        crate::generic::String { val }\n    }\n}\n\nimpl From<Vec<String>> for crate::generic::StringList {\n    fn from(vals: Vec<String>) -> Self {\n        crate::generic::StringList { vals }\n    }\n}\n\nimpl From<bool> for crate::generic::Bool {\n    fn from(val: bool) -> Self {\n        crate::generic::Bool { val }\n    }\n}\n\nimpl From<i32> for crate::generic::Int32 {\n    fn from(val: i32) -> Self {\n        crate::generic::Int32 { val }\n    }\n}\n\nimpl From<i64> for crate::generic::Int64 {\n    fn from(val: i64) -> Self {\n        crate::generic::Int64 { val }\n    }\n}\n\nimpl From<u32> for crate::generic::UInt32 {\n    fn from(val: u32) -> Self {\n        crate::generic::UInt32 { val }\n    }\n}\n\nimpl From<usize> for crate::generic::UInt32 {\n    fn from(val: usize) -> Self {\n        crate::generic::UInt32 { val: val as u32 }\n    }\n}\n"
  },
  {
    "path": "rslib/proto/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// DeckConfig inside deck_config.proto\n#![allow(clippy::module_inception)]\n\nmod generic_helpers;\n\nmacro_rules! protobuf {\n    ($ident:ident, $name:literal) => {\n        pub mod $ident {\n            include!(concat!(env!(\"OUT_DIR\"), \"/anki.\", $name, \".rs\"));\n        }\n    };\n}\n\nprotobuf!(ankidroid, \"ankidroid\");\nprotobuf!(ankiweb, \"ankiweb\");\nprotobuf!(backend, \"backend\");\nprotobuf!(card_rendering, \"card_rendering\");\nprotobuf!(cards, \"cards\");\nprotobuf!(collection, \"collection\");\nprotobuf!(config, \"config\");\nprotobuf!(deck_config, \"deck_config\");\nprotobuf!(decks, \"decks\");\nprotobuf!(generic, \"generic\");\nprotobuf!(i18n, \"i18n\");\nprotobuf!(image_occlusion, \"image_occlusion\");\nprotobuf!(import_export, \"import_export\");\nprotobuf!(links, \"links\");\nprotobuf!(media, \"media\");\nprotobuf!(notes, \"notes\");\nprotobuf!(notetypes, \"notetypes\");\nprotobuf!(scheduler, \"scheduler\");\nprotobuf!(search, \"search\");\nprotobuf!(stats, \"stats\");\nprotobuf!(sync, \"sync\");\nprotobuf!(tags, \"tags\");\nprotobuf!(ankihub, \"ankihub\");\n"
  },
  {
    "path": "rslib/proto/typescript.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::fmt::Write as WriteFmt;\nuse std::path::Path;\n\nuse anki_io::create_dir_all;\nuse anki_io::write_file_if_changed;\nuse anki_proto_gen::BackendService;\nuse anki_proto_gen::Method;\nuse anyhow::Result;\nuse inflections::Inflect;\nuse itertools::Itertools;\n\npub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> {\n    let root = Path::new(\"../../out/ts/lib/generated\");\n    create_dir_all(root)?;\n\n    let mut ts_out = String::new();\n    let mut referenced_packages = HashSet::new();\n\n    for service in services {\n        if service.name == \"BackendAnkidroidService\" {\n            continue;\n        }\n\n        for method in service.all_methods() {\n            let method = MethodDetails::from_method(method);\n            record_referenced_type(&mut referenced_packages, &method.input_type);\n            record_referenced_type(&mut referenced_packages, &method.output_type);\n            write_ts_method(&method, &mut ts_out);\n        }\n    }\n\n    let imports = imports(referenced_packages);\n    write_file_if_changed(\n        root.join(\"backend.ts\"),\n        format!(\"{}{}{}\", ts_header(), imports, ts_out),\n    )?;\n\n    Ok(())\n}\n\nfn ts_header() -> String {\n    r#\"// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; https://www.gnu.org/licenses/agpl.html\n\nimport type { PlainMessage } from \"@bufbuild/protobuf\";\nimport type { PostProtoOptions } from \"./post\";\nimport { postProto } from \"./post\";\n\"#\n    .into()\n}\n\nfn imports(referenced_packages: HashSet<String>) -> String {\n    let mut out = String::new();\n    for package in referenced_packages.iter().sorted() {\n        writeln!(\n            &mut out,\n            \"import * as {} from \\\"./anki/{}_pb\\\";\",\n            package,\n            package.to_snake_case()\n        )\n        .unwrap();\n    }\n    out\n}\n\nfn write_ts_method(\n    MethodDetails {\n        method_name,\n        input_type,\n        output_type,\n        comments,\n    }: &MethodDetails,\n    out: &mut String,\n) {\n    let comments = format_comments(comments);\n    writeln!(\n        out,\n        r#\"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{\n        return await postProto(\"{method_name}\", new {input_type}(input), {output_type}, options);\n}}\"#\n    ).unwrap()\n}\n\nfn format_comments(comments: &Option<String>) -> String {\n    comments\n        .as_ref()\n        .map(|s| format!(\"/** {s} */\\n\"))\n        .unwrap_or_default()\n}\n\nstruct MethodDetails {\n    method_name: String,\n    input_type: String,\n    output_type: String,\n    comments: Option<String>,\n}\n\nimpl MethodDetails {\n    fn from_method(method: &Method) -> MethodDetails {\n        let name = method.name.to_camel_case();\n        let input_type = full_name_to_imported_reference(method.proto.input().full_name());\n        let output_type = full_name_to_imported_reference(method.proto.output().full_name());\n        let comments = method.comments.clone();\n        Self {\n            method_name: name,\n            input_type,\n            output_type,\n            comments,\n        }\n    }\n}\n\nfn record_referenced_type(referenced_packages: &mut HashSet<String>, type_name: &str) {\n    referenced_packages.insert(type_name.split('.').next().unwrap().to_string());\n}\n\n// e.g. anki.import_export.ImportResponse ->\n// importExport.ImportResponse\nfn full_name_to_imported_reference(name: &str) -> String {\n    let mut name = name.splitn(3, '.');\n    name.next().unwrap();\n    format!(\n        \"{}.{}\",\n        name.next().unwrap().to_camel_case(),\n        name.next().unwrap()\n    )\n}\n"
  },
  {
    "path": "rslib/proto_gen/Cargo.toml",
    "content": "[package]\nname = \"anki_proto_gen\"\npublish = false\ndescription = \"Helpers for interface code generation\"\n\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\nrust-version.workspace = true\n\n[dependencies]\nanki_io.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\ninflections.workspace = true\nitertools.workspace = true\nprost-reflect.workspace = true\nprost-types.workspace = true\nregex.workspace = true\nwalkdir.workspace = true\n"
  },
  {
    "path": "rslib/proto_gen/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Some helpers for code generation in external crates, that ensure indexes\n//! match.\n\nuse std::collections::HashMap;\nuse std::env;\nuse std::path::PathBuf;\nuse std::sync::LazyLock;\n\nuse anki_io::read_to_string;\nuse anki_io::write_file_if_changed;\nuse anki_io::ToUtf8Path;\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse inflections::Inflect;\nuse itertools::Either;\nuse itertools::Itertools;\nuse prost_reflect::DescriptorPool;\nuse prost_reflect::MessageDescriptor;\nuse prost_reflect::MethodDescriptor;\nuse prost_reflect::ServiceDescriptor;\nuse regex::Captures;\nuse regex::Regex;\nuse walkdir::WalkDir;\n\n/// We look for ExampleService and BackedExampleService, both of which are\n/// expected to exist (but may be empty).\n///\n/// - If a method is listed in BackendExampleService and not in ExampleService,\n///   that method is only available with a Backend.\n/// - If a method is listed in both services, you can provide separate\n///   implementations for each of the traits.\n/// - If a method is listed only in ExampleService, a forwarding method on\n///   Backend is automatically implemented. This bypasses the trait and\n///   implements directly on Backend.\n///\n/// It's important that service and method indices are the same for\n/// client-generated code, so the client code should use the .index fields\n/// of Service and Method provided by get_services(), and not\n/// .enumerate() or .proto.index()\n///\n/// Client code will want to ignore CollectionServices, and focus on\n/// BackendServices.\npub fn get_services(pool: &DescriptorPool) -> (Vec<CollectionService>, Vec<BackendService>) {\n    // split services into backend and collection\n    let (mut col_services, mut backend_services): (Vec<_>, Vec<_>) =\n        pool.services().partition_map(|service| {\n            if service.name().starts_with(\"Backend\") {\n                Either::Right(BackendService::from_proto(service))\n            } else {\n                Either::Left(CollectionService::from_proto(service))\n            }\n        });\n    // frontend.proto is only in col_services\n    assert_eq!(col_services.len(), backend_services.len());\n    // copy collection methods into backend services if they don't have one with\n    // a matching name\n    for service in &mut backend_services {\n        // locate associated collection service\n        let Some(col_service) = col_services\n            .iter()\n            .find(|cs| cs.name == service.name.trim_start_matches(\"Backend\"))\n        else {\n            panic!(\"missing associated service: {}\", service.name)\n        };\n\n        // add any methods that don't exist in backend trait methods to the delegating\n        // methods\n        service.delegating_methods = col_service\n            .trait_methods\n            .iter()\n            .filter(|m| service.trait_methods.iter().all(|bm| bm.name != m.name))\n            .map(|method| Method {\n                index: method.index + service.trait_methods.len(),\n                ..method.clone()\n            })\n            .collect();\n    }\n    // fill comments in\n    let comments = MethodComments::from_pool(pool);\n    for service in &mut col_services {\n        for method in &mut service.trait_methods {\n            method.comments = comments.get_for_method(&method.proto);\n        }\n    }\n    for service in &mut backend_services {\n        for method in &mut service.trait_methods {\n            method.comments = comments.get_for_method(&method.proto);\n        }\n        for method in &mut service.delegating_methods {\n            method.comments = comments.get_for_method(&method.proto);\n        }\n    }\n\n    (col_services, backend_services)\n}\n\n#[derive(Debug)]\npub struct CollectionService {\n    pub name: String,\n    pub index: usize,\n    pub trait_methods: Vec<Method>,\n    pub proto: ServiceDescriptor,\n}\n\n#[derive(Debug)]\npub struct BackendService {\n    pub name: String,\n    pub index: usize,\n    pub trait_methods: Vec<Method>,\n    pub delegating_methods: Vec<Method>,\n    pub proto: ServiceDescriptor,\n}\n\n#[derive(Debug, Clone)]\npub struct Method {\n    pub name: String,\n    pub index: usize,\n    pub comments: Option<String>,\n    pub proto: MethodDescriptor,\n}\n\nimpl CollectionService {\n    pub fn from_proto(service: prost_reflect::ServiceDescriptor) -> Self {\n        CollectionService {\n            name: service.name().to_string(),\n            index: service.index(),\n            trait_methods: service.methods().map(Method::from_proto).collect(),\n            proto: service,\n        }\n    }\n}\n\nimpl BackendService {\n    pub fn from_proto(service: prost_reflect::ServiceDescriptor) -> Self {\n        BackendService {\n            name: service.name().to_string(),\n            index: service.index(),\n            trait_methods: service.methods().map(Method::from_proto).collect(),\n            proto: service,\n            // filled in later\n            delegating_methods: vec![],\n        }\n    }\n\n    pub fn all_methods(&self) -> impl Iterator<Item = &Method> {\n        self.trait_methods\n            .iter()\n            .chain(self.delegating_methods.iter())\n    }\n}\n\nimpl Method {\n    pub fn from_proto(method: prost_reflect::MethodDescriptor) -> Self {\n        Method {\n            name: method.name().to_snake_case(),\n            index: method.index(),\n            proto: method,\n            // filled in later\n            comments: None,\n        }\n    }\n\n    /// The input type, if not empty.\n    pub fn input(&self) -> Option<MessageDescriptor> {\n        msg_if_not_empty(self.proto.input())\n    }\n\n    /// The output type, if not empty.\n    pub fn output(&self) -> Option<MessageDescriptor> {\n        msg_if_not_empty(self.proto.output())\n    }\n}\n\nfn msg_if_not_empty(msg: MessageDescriptor) -> Option<MessageDescriptor> {\n    if msg.full_name() == \"anki.generic.Empty\" {\n        None\n    } else {\n        Some(msg)\n    }\n}\n\n#[derive(Debug)]\nstruct MethodComments<'a> {\n    // package name -> method path -> comment\n    by_package_and_path: HashMap<&'a str, HashMap<Vec<i32>, String>>,\n}\n\nimpl<'a> MethodComments<'a> {\n    pub fn from_pool(pool: &'a DescriptorPool) -> MethodComments<'a> {\n        let mut by_package_and_path = HashMap::new();\n        for file in pool.file_descriptor_protos() {\n            let path_map = file\n                .source_code_info\n                .as_ref()\n                .unwrap()\n                .location\n                .iter()\n                .map(|l| (l.path.clone(), l.leading_comments().trim().to_string()))\n                .collect();\n            by_package_and_path.insert(file.package(), path_map);\n        }\n        Self {\n            by_package_and_path,\n        }\n    }\n\n    pub fn get_for_method(&self, method: &MethodDescriptor) -> Option<String> {\n        self.by_package_and_path\n            .get(method.parent_file().package_name())\n            .and_then(|by_path| by_path.get(method.path()))\n            .and_then(|s| if s.is_empty() { None } else { Some(s.into()) })\n    }\n}\n\npub fn add_must_use_annotations<P, E>(\n    out_dir: &PathBuf,\n    should_process_path: P,\n    is_empty: E,\n) -> Result<()>\nwhere\n    P: Fn(&Utf8Path) -> bool,\n    E: Fn(&Utf8Path, &str) -> bool,\n{\n    for file in WalkDir::new(out_dir).into_iter() {\n        let file = file?;\n        let path = file.path().utf8()?;\n        if path.file_name().unwrap().ends_with(\".rs\") && should_process_path(path) {\n            add_must_use_annotations_to_file(path, &is_empty)?;\n        }\n    }\n    Ok(())\n}\n\npub fn add_must_use_annotations_to_file<E>(path: &Utf8Path, is_empty: E) -> Result<()>\nwhere\n    E: Fn(&Utf8Path, &str) -> bool,\n{\n    static MESSAGE_OR_ENUM_RE: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"pub (struct|enum) ([[:alnum:]]+?)\\s\").unwrap());\n    let contents = read_to_string(path)?;\n    let contents = MESSAGE_OR_ENUM_RE.replace_all(&contents, |caps: &Captures| {\n        let is_enum = caps.get(1).unwrap().as_str() == \"enum\";\n        let name = caps.get(2).unwrap().as_str();\n        if is_enum || !is_empty(path, name) {\n            format!(\"#[must_use]\\n{}\", caps.get(0).unwrap().as_str())\n        } else {\n            caps.get(0).unwrap().as_str().to_string()\n        }\n    });\n    write_file_if_changed(path, contents.as_ref())?;\n    Ok(())\n}\n\n/// Given a generated prost filename and a struct name, try to determine whether\n/// the message has 0 fields.\n///\n/// This is unfortunately rather circuitous, as Prost doesn't allow us to easily\n/// alter the code generation with access to the associated proto descriptor. So\n/// we need to infer the full proto path based on the filename and the Rust type\n/// name, which we can only do for top-level elements. For any nested messages\n/// we can't find, we assume they must be used.\npub fn determine_if_message_is_empty(pool: &DescriptorPool, path: &Utf8Path, name: &str) -> bool {\n    let package = path.file_stem().unwrap();\n    let full_name = format!(\"{package}.{name}\");\n    if let Some(msg) = pool.get_message_by_name(&full_name) {\n        msg.fields().count() == 0\n    } else {\n        false\n    }\n}\n\n/// - When building via a local checkout, the path defined in .cargo/config\n/// - When building via cargo install or a third-party crate,\n///   OUT_DIR/../../anki_descriptors.bin (so it can be seen by the rslib crate)\npub fn descriptors_path() -> PathBuf {\n    if let Ok(path) = env::var(\"DESCRIPTORS_BIN\") {\n        PathBuf::from(path)\n    } else {\n        PathBuf::from(env::var(\"OUT_DIR\").unwrap()).join(\"../../anki_descriptors.bin\")\n    }\n}\n"
  },
  {
    "path": "rslib/rust_interface.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::fmt::Write;\nuse std::path::PathBuf;\n\nuse anki_io::write_file_if_changed;\nuse anki_proto_gen::get_services;\nuse anki_proto_gen::BackendService;\nuse anki_proto_gen::CollectionService;\nuse anki_proto_gen::Method;\nuse anyhow::Context;\nuse anyhow::Result;\nuse inflections::Inflect;\nuse itertools::Itertools;\nuse prost_reflect::DescriptorPool;\n\npub fn write_rust_interface(pool: &DescriptorPool) -> Result<()> {\n    let mut buf = String::new();\n    buf.push_str(\"use crate::error::Result; use prost::Message;\");\n\n    let (col_services, backend_services) = get_services(pool);\n    let col_services = col_services\n        .into_iter()\n        .filter(|s| s.name != \"FrontendService\")\n        .collect_vec();\n    let backend_services = backend_services\n        .into_iter()\n        .filter(|s| s.name != \"BackendFrontendService\")\n        .collect_vec();\n\n    render_collection_services(&col_services, &mut buf)?;\n    render_backend_services(&backend_services, &mut buf)?;\n\n    let buf = format_code(buf)?;\n    // println!(\"{}\", &buf);\n    // panic!();\n    let out_dir = env::var(\"OUT_DIR\").unwrap();\n    let path = PathBuf::from(out_dir).join(\"backend.rs\");\n    write_file_if_changed(path, buf).context(\"write file\")?;\n    Ok(())\n}\n\nfn render_collection_services(col_services: &[CollectionService], buf: &mut String) -> Result<()> {\n    for service in col_services {\n        render_collection_trait(service, buf);\n        render_individual_service_run_method_for_collection(buf, service);\n    }\n    render_top_level_run_method(\n        col_services.iter().map(|s| (s.index, s.name.as_str())),\n        \"&mut self\",\n        \"crate::collection::Collection\",\n        buf,\n    );\n\n    Ok(())\n}\n\nfn render_backend_services(backend_services: &[BackendService], buf: &mut String) -> Result<()> {\n    for service in backend_services {\n        render_backend_trait(service, buf);\n        render_delegating_backend_methods(service, buf);\n        render_individual_service_run_method_for_backend(buf, service);\n    }\n    render_top_level_run_method(\n        backend_services.iter().map(|s| (s.index, s.name.as_str())),\n        \"&self\",\n        \"crate::backend::Backend\",\n        buf,\n    );\n\n    Ok(())\n}\n\nfn format_code(code: String) -> Result<String> {\n    let syntax_tree = syn::parse_file(&code)?;\n    Ok(prettyplease::unparse(&syntax_tree))\n}\n\nfn render_collection_trait(service: &CollectionService, buf: &mut String) {\n    let name = &service.name;\n    writeln!(buf, \"pub trait {name} {{\").unwrap();\n    for method in &service.trait_methods {\n        render_trait_method(method, \"&mut self\", buf);\n    }\n    buf.push('}');\n}\n\nfn render_trait_method(method: &Method, self_kind: &str, buf: &mut String) {\n    let method_name = &method.name;\n    let input_with_label = method.get_input_arg_with_label();\n    let output_type = method.get_output_type();\n    writeln!(\n        buf,\n        \"fn {method_name}({self_kind}, {input_with_label}) -> Result<{output_type}>;\"\n    )\n    .unwrap();\n}\n\nfn render_backend_trait(service: &BackendService, buf: &mut String) {\n    let name = &service.name;\n    writeln!(buf, \"pub trait {name} {{\").unwrap();\n    for method in &service.trait_methods {\n        render_trait_method(method, \"&self\", buf);\n    }\n    buf.push('}');\n}\n\nfn render_delegating_backend_methods(service: &BackendService, buf: &mut String) {\n    buf.push_str(\"impl crate::backend::Backend {\");\n    for method in &service.delegating_methods {\n        render_delegating_backend_method(method, service.name.trim_start_matches(\"Backend\"), buf);\n    }\n    buf.push('}');\n}\n\nfn render_delegating_backend_method(method: &Method, method_qualifier: &str, buf: &mut String) {\n    let method_name = &method.name;\n    let input_with_label = method.get_input_arg_with_label();\n    let input = method.text_if_input_not_empty(|_| \"input\".into());\n    let output_type = method.get_output_type();\n    writeln!(\n        buf,\n        \"fn {method_name}(&self, {input_with_label}) -> Result<{output_type}> {{\n        self.with_col(|col| {method_qualifier}::{method_name}(col, {input})) }}\",\n    )\n    .unwrap();\n}\n\n// Matches all service types and delegates to the revelant self.run_foo_method()\nfn render_top_level_run_method<'a>(\n    // (index, name)\n    services: impl Iterator<Item = (usize, &'a str)>,\n    self_kind: &str,\n    struct_name: &str,\n    buf: &mut String,\n) {\n    writeln!(buf,\n        r#\" impl {struct_name} {{\n    pub fn run_service_method({self_kind}, service: u32, method: u32, input: &[u8]) -> Result<Vec<u8>, Vec<u8>> {{\n        match service {{\n\"#,\n    ).unwrap();\n    for (idx, service) in services {\n        writeln!(\n            buf,\n            \"{idx} => self.run_{service}_method(method, input),\",\n            service = service.to_snake_case()\n        )\n        .unwrap();\n    }\n    buf.push_str(\n        r#\"\n            _ => Err(crate::error::AnkiError::InvalidServiceIndex),\n        }\n        .map_err(|err| {\n                let backend_err = err.into_protobuf(&self.tr);\n                let mut bytes = Vec::new();\n                backend_err.encode(&mut bytes).unwrap();\n                bytes\n            })\n    } }\"#,\n    );\n}\n\nfn render_individual_service_run_method_for_collection(\n    buf: &mut String,\n    service: &CollectionService,\n) {\n    let service_name = &service.name.to_snake_case();\n    writeln!(\n        buf,\n        \"#[allow(unused_variables, clippy::match_single_binding)]\n        impl crate::collection::Collection {{ pub(crate) fn run_{service_name}_method(&mut self,\n        method: u32, input: &[u8]) -> Result<Vec<u8>> {{\n        match method {{\",\n    )\n    .unwrap();\n    for method in &service.trait_methods {\n        render_method_in_match_expression(method, &service.name, buf);\n    }\n    buf.push_str(\n        r#\"\n            _ => Err(crate::error::AnkiError::InvalidMethodIndex),\n        }\n} }\n\"#,\n    );\n}\n\nfn render_individual_service_run_method_for_backend(buf: &mut String, service: &BackendService) {\n    let service_name = &service.name.to_snake_case();\n    writeln!(\n        buf,\n        \"#[allow(unused_variables, clippy::match_single_binding)]\n        impl crate::backend::Backend {{ pub(crate) fn run_{service_name}_method(&self,\n        method: u32, input: &[u8]) -> Result<Vec<u8>> {{\n        match method {{\",\n    )\n    .unwrap();\n    for method in &service.trait_methods {\n        render_method_in_match_expression(method, &service.name, buf);\n    }\n    for method in &service.delegating_methods {\n        render_method_in_match_expression(method, \"crate::backend::Backend\", buf);\n    }\n    buf.push_str(\n        r#\"\n            _ => Err(crate::error::AnkiError::InvalidMethodIndex),\n        }\n} }\n\"#,\n    );\n}\n\nfn render_method_in_match_expression(method: &Method, method_qualifier: &str, buf: &mut String) {\n    let decode_input =\n        method.text_if_input_not_empty(|kind| format!(\"let input = {kind}::decode(input)?;\"));\n    let rust_method = &method.name;\n    let input = method.text_if_input_not_empty(|_| \"input\".into());\n    let output_assign = method.text_if_output_not_empty(|_| \"let output = \".into());\n    let idx = method.index;\n    let output = if method.output().is_none() {\n        \"Vec::new()\"\n    } else {\n        \"{ let mut out_bytes = Vec::new();\n            output.encode(&mut out_bytes)?;\n            out_bytes }\"\n    };\n    writeln!(\n        buf,\n        \"{idx} => {{ {decode_input}\n                {output_assign} {method_qualifier}::{rust_method}(self, {input})?;\n                Ok({output}) }},\",\n    )\n    .unwrap();\n}\n\ntrait MethodHelpers {\n    fn input_type(&self) -> Option<String>;\n    fn output_type(&self) -> Option<String>;\n    fn text_if_input_not_empty(&self, text: impl Fn(&String) -> String) -> String;\n    fn get_input_arg_with_label(&self) -> String;\n    fn get_output_type(&self) -> String;\n    fn text_if_output_not_empty(&self, text: impl Fn(&String) -> String) -> String;\n}\n\nimpl MethodHelpers for Method {\n    fn input_type(&self) -> Option<String> {\n        self.input().map(|t| rust_type(t.full_name()))\n    }\n\n    fn output_type(&self) -> Option<String> {\n        self.output().map(|t| rust_type(t.full_name()))\n    }\n    /// No text if generic::Empty\n    fn text_if_input_not_empty(&self, text: impl Fn(&String) -> String) -> String {\n        self.input_type().as_ref().map(text).unwrap_or_default()\n    }\n\n    /// No text if generic::Empty\n    fn get_input_arg_with_label(&self) -> String {\n        self.input_type()\n            .as_ref()\n            .map(|t| format!(\"input: {t}\"))\n            .unwrap_or_default()\n    }\n\n    /// () if generic::Empty\n    fn get_output_type(&self) -> String {\n        self.output_type().as_deref().unwrap_or(\"()\").into()\n    }\n\n    fn text_if_output_not_empty(&self, text: impl Fn(&String) -> String) -> String {\n        self.output_type().as_ref().map(text).unwrap_or_default()\n    }\n}\n\nfn rust_type(name: &str) -> String {\n    let Some((head, tail)) = name.rsplit_once('.') else {\n        panic!()\n    };\n    format!(\n        \"{}::{}\",\n        head.to_snake_case()\n            .replace('.', \"::\")\n            .replace(\"anki::\", \"anki_proto::\"),\n        tail\n    )\n}\n"
  },
  {
    "path": "rslib/src/adding.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::Arc;\n\nuse crate::prelude::*;\n\npub struct DeckAndNotetype {\n    pub deck_id: DeckId,\n    pub notetype_id: NotetypeId,\n}\n\nimpl Collection {\n    /// An option in the preferences screen governs the behaviour here.\n    ///\n    /// - When 'default to the current deck' is enabled, we use the current deck\n    ///   if it's normal, the provided reviewer card's deck as a fallback, and\n    ///   Default as a final fallback. We then fetch the last used notetype\n    ///   stored in the deck, falling back to the global notetype, or the first\n    ///   available one.\n    ///\n    /// - Otherwise, each note type remembers the last deck cards were added to,\n    ///   and we use that, defaulting to the current deck if missing, and\n    ///   Default otherwise.\n    pub fn defaults_for_adding(\n        &mut self,\n        home_deck_of_reviewer_card: DeckId,\n    ) -> Result<DeckAndNotetype> {\n        let deck_id;\n        let notetype_id;\n        if self.get_config_bool(BoolKey::AddingDefaultsToCurrentDeck) {\n            deck_id = self\n                .get_current_deck_for_adding(home_deck_of_reviewer_card)?\n                .id;\n            notetype_id = self.default_notetype_for_deck(deck_id)?.id;\n        } else {\n            notetype_id = self.get_current_notetype_for_adding()?.id;\n            deck_id = if let Some(deck_id) = self.default_deck_for_notetype(notetype_id)? {\n                deck_id\n            } else {\n                // default not set in notetype; fall back to current deck\n                self.get_current_deck_for_adding(home_deck_of_reviewer_card)?\n                    .id\n            };\n        }\n\n        Ok(DeckAndNotetype {\n            deck_id,\n            notetype_id,\n        })\n    }\n\n    /// The currently selected deck, the home deck of the provided card if\n    /// current deck is filtered, or the default deck.\n    fn get_current_deck_for_adding(\n        &mut self,\n        home_deck_of_reviewer_card: DeckId,\n    ) -> Result<Arc<Deck>> {\n        // current deck, if not filtered\n        if let Some(current) = self.get_deck(self.get_current_deck_id())? {\n            if !current.is_filtered() {\n                return Ok(current);\n            }\n        }\n        // provided reviewer card's home deck\n        if let Some(home_deck) = self.get_deck(home_deck_of_reviewer_card)? {\n            return Ok(home_deck);\n        }\n        // default deck\n        self.get_deck(DeckId(1))?.or_not_found(DeckId(1))\n    }\n\n    fn get_current_notetype_for_adding(&mut self) -> Result<Arc<Notetype>> {\n        // try global 'current' notetype\n        if let Some(ntid) = self.get_current_notetype_id() {\n            if let Some(nt) = self.get_notetype(ntid)? {\n                return Ok(nt);\n            }\n        }\n        // try first available notetype\n        if let Some((ntid, _)) = self.storage.get_all_notetype_names()?.first() {\n            Ok(self.get_notetype(*ntid)?.unwrap())\n        } else {\n            invalid_input!(\"collection has no notetypes\");\n        }\n    }\n\n    fn default_notetype_for_deck(&mut self, deck: DeckId) -> Result<Arc<Notetype>> {\n        // try last notetype used by deck\n        if let Some(ntid) = self.get_last_notetype_for_deck(deck) {\n            if let Some(nt) = self.get_notetype(ntid)? {\n                return Ok(nt);\n            }\n        }\n\n        // fall back\n        self.get_current_notetype_for_adding()\n    }\n\n    /// Returns the last deck added to with this notetype, provided it is valid.\n    /// This is optional due to the inconsistent handling, where changes in\n    /// notetype may need to update the current deck, but not vice versa. If\n    /// a previous deck is not set, we want to keep the current selection,\n    /// instead of resetting it.\n    pub(crate) fn default_deck_for_notetype(&mut self, ntid: NotetypeId) -> Result<Option<DeckId>> {\n        if let Some(last_deck_id) = self.get_last_deck_added_to_for_notetype(ntid) {\n            if let Some(deck) = self.get_deck(last_deck_id)? {\n                if !deck.is_filtered() {\n                    return Ok(Some(deck.id));\n                }\n            }\n        }\n\n        Ok(None)\n    }\n}\n"
  },
  {
    "path": "rslib/src/ankidroid/db.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::mem::size_of;\nuse std::sync::atomic::AtomicI32;\nuse std::sync::atomic::Ordering;\nuse std::sync::LazyLock;\nuse std::sync::Mutex;\n\nuse anki_proto::ankidroid::sql_value::Data;\nuse anki_proto::ankidroid::DbResponse;\nuse anki_proto::ankidroid::DbResult;\nuse anki_proto::ankidroid::Row;\nuse anki_proto::ankidroid::SqlValue;\nuse itertools::FoldWhile;\nuse itertools::FoldWhile::Continue;\nuse itertools::FoldWhile::Done;\nuse itertools::Itertools;\nuse rusqlite::ToSql;\nuse serde::Deserialize;\n\nuse crate::collection::Collection;\nuse crate::error::Result;\n\n/// A pointer to the SqliteStorage object stored in a collection, used to\n/// uniquely index results from multiple open collections at once.\nimpl Collection {\n    fn id_for_db_cache(&self) -> CollectionId {\n        CollectionId((&self.storage as *const _) as i64)\n    }\n}\n\n#[derive(Hash, PartialEq, Eq)]\nstruct CollectionId(i64);\n\n#[derive(Deserialize)]\nstruct DBArgs {\n    sql: String,\n    args: Vec<crate::backend::dbproxy::SqlValue>,\n}\n\npub trait Sizable {\n    /** Estimates the heap size of the value, in bytes */\n    fn estimate_size(&self) -> usize;\n}\n\nimpl Sizable for Data {\n    fn estimate_size(&self) -> usize {\n        match self {\n            Data::StringValue(s) => s.len(),\n            Data::LongValue(_) => size_of::<i64>(),\n            Data::DoubleValue(_) => size_of::<f64>(),\n            Data::BlobValue(b) => b.len(),\n        }\n    }\n}\n\nimpl Sizable for SqlValue {\n    fn estimate_size(&self) -> usize {\n        // Add a byte for the optional\n        self.data\n            .as_ref()\n            .map(|f| f.estimate_size() + 1)\n            .unwrap_or(1)\n    }\n}\n\nimpl Sizable for Row {\n    fn estimate_size(&self) -> usize {\n        self.fields.iter().map(|x| x.estimate_size()).sum()\n    }\n}\n\nimpl Sizable for DbResult {\n    fn estimate_size(&self) -> usize {\n        // Performance: It might be best to take the first x rows and determine the data\n        // types If we have floats or longs, they'll be a fixed size (excluding\n        // nulls) and should speed up the calculation as we'll only calculate a\n        // subset of the columns.\n        self.rows.iter().map(|x| x.estimate_size()).sum()\n    }\n}\n\npub(crate) fn select_next_slice<'a>(rows: impl Iterator<Item = &'a Row>) -> Vec<Row> {\n    select_slice_of_size(rows, get_max_page_size())\n        .into_inner()\n        .1\n}\n\nfn select_slice_of_size<'a>(\n    mut rows: impl Iterator<Item = &'a Row>,\n    max_size: usize,\n) -> FoldWhile<(usize, Vec<Row>)> {\n    let init: Vec<Row> = Vec::new();\n    rows.fold_while((0, init), |mut acc, x| {\n        let new_size = acc.0 + x.estimate_size();\n        // If the accumulator is 0, but we're over the size: return a single result so\n        // we don't loop forever. Theoretically, this shouldn't happen as data\n        // should be reasonably sized\n        if new_size > max_size && acc.0 > 0 {\n            Done(acc)\n        } else {\n            // PERF: should be faster to return (size, numElements) then bulk copy/slice\n            acc.1.push(x.to_owned());\n            Continue((new_size, acc.1))\n        }\n    })\n}\n\ntype SequenceNumber = i32;\n\nstatic HASHMAP: LazyLock<Mutex<HashMap<CollectionId, HashMap<SequenceNumber, DbResponse>>>> =\n    LazyLock::new(|| Mutex::new(HashMap::new()));\n\npub(crate) fn flush_single_result(col: &Collection, sequence_number: i32) {\n    HASHMAP\n        .lock()\n        .unwrap()\n        .get_mut(&col.id_for_db_cache())\n        .map(|storage| storage.remove(&sequence_number));\n}\n\npub(crate) fn flush_collection(col: &Collection) {\n    HASHMAP.lock().unwrap().remove(&col.id_for_db_cache());\n}\n\npub(crate) fn active_sequences(col: &Collection) -> Vec<i32> {\n    HASHMAP\n        .lock()\n        .unwrap()\n        .get(&col.id_for_db_cache())\n        .map(|h| h.keys().copied().collect())\n        .unwrap_or_default()\n}\n\n/**\nStore the data in the cache if larger than than the page size.<br/>\nReturns: The data capped to the page size\n*/\npub(crate) fn trim_and_cache_remaining(\n    col: &Collection,\n    values: DbResult,\n    sequence_number: i32,\n) -> DbResponse {\n    let start_index = 0;\n\n    // PERF: Could speed this up by not creating the vector and just calculating the\n    // count\n    let first_result = select_next_slice(values.rows.iter());\n\n    let row_count = values.rows.len() as i32;\n    if first_result.len() < values.rows.len() {\n        let to_store = DbResponse {\n            result: Some(values),\n            sequence_number,\n            row_count,\n            start_index,\n        };\n        insert_cache(col, to_store);\n\n        DbResponse {\n            result: Some(DbResult { rows: first_result }),\n            sequence_number,\n            row_count,\n            start_index,\n        }\n    } else {\n        DbResponse {\n            result: Some(values),\n            sequence_number,\n            row_count,\n            start_index,\n        }\n    }\n}\n\nfn insert_cache(col: &Collection, result: DbResponse) {\n    HASHMAP\n        .lock()\n        .unwrap()\n        .entry(col.id_for_db_cache())\n        .or_default()\n        .insert(result.sequence_number, result);\n}\n\npub(crate) fn get_next(\n    col: &Collection,\n    sequence_number: i32,\n    start_index: i64,\n) -> Option<DbResponse> {\n    let result = get_next_result(col, &sequence_number, start_index);\n\n    if let Some(resp) = result.as_ref() {\n        if resp.result.is_none() || resp.result.as_ref().unwrap().rows.is_empty() {\n            flush_single_result(col, sequence_number)\n        }\n    }\n\n    result\n}\n\nfn get_next_result(\n    col: &Collection,\n    sequence_number: &i32,\n    start_index: i64,\n) -> Option<DbResponse> {\n    let map = HASHMAP.lock().unwrap();\n    let result_map = map.get(&col.id_for_db_cache())?;\n    let current_result = result_map.get(sequence_number)?;\n\n    // TODO: This shouldn't need to exist\n    let tmp: Vec<Row> = Vec::new();\n    let next_rows = current_result\n        .result\n        .as_ref()\n        .map(|x| x.rows.iter())\n        .unwrap_or_else(|| tmp.iter());\n\n    let skipped_rows = next_rows.clone().skip(start_index as usize).collect_vec();\n    println!(\"{}\", skipped_rows.len());\n\n    let filtered_rows = select_next_slice(next_rows.skip(start_index as usize));\n\n    let result = DbResult {\n        rows: filtered_rows,\n    };\n\n    let trimmed_result = DbResponse {\n        result: Some(result),\n        sequence_number: current_result.sequence_number,\n        row_count: current_result.row_count,\n        start_index,\n    };\n\n    Some(trimmed_result)\n}\n\nstatic SEQUENCE_NUMBER: AtomicI32 = AtomicI32::new(0);\n\npub(crate) fn next_sequence_number() -> i32 {\n    SEQUENCE_NUMBER.fetch_add(1, Ordering::SeqCst)\n}\n\n// same as we get from\n// io.requery.android.database.CursorWindow.sCursorWindowSize\nstatic DB_COMMAND_PAGE_SIZE: LazyLock<Mutex<usize>> = LazyLock::new(|| Mutex::new(1024 * 1024 * 2));\n\npub(crate) fn set_max_page_size(size: usize) {\n    let mut state = DB_COMMAND_PAGE_SIZE.lock().expect(\"Could not lock mutex\");\n    *state = size;\n}\n\nfn get_max_page_size() -> usize {\n    *DB_COMMAND_PAGE_SIZE.lock().unwrap()\n}\n\nfn get_args(in_bytes: &[u8]) -> Result<DBArgs> {\n    let ret: DBArgs = serde_json::from_slice(in_bytes)?;\n    Ok(ret)\n}\n\npub(crate) fn insert_for_id(col: &Collection, json: &[u8]) -> Result<i64> {\n    let req = get_args(json)?;\n    let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();\n    col.storage.db.execute(&req.sql, &args[..])?;\n    Ok(col.storage.db.last_insert_rowid())\n}\n\npub(crate) fn execute_for_row_count(col: &Collection, req: &[u8]) -> Result<i64> {\n    let req = get_args(req)?;\n    let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();\n    let count = col.storage.db.execute(&req.sql, &args[..])?;\n    Ok(count as i64)\n}\n\n#[cfg(test)]\nmod tests {\n    use anki_proto::ankidroid::sql_value;\n    use anki_proto::ankidroid::Row;\n    use anki_proto::ankidroid::SqlValue;\n\n    use super::*;\n    use crate::ankidroid::db::select_slice_of_size;\n    use crate::ankidroid::db::Sizable;\n\n    fn gen_data() -> Vec<SqlValue> {\n        vec![\n            SqlValue {\n                data: Some(sql_value::Data::DoubleValue(12.0)),\n            },\n            SqlValue {\n                data: Some(sql_value::Data::LongValue(12)),\n            },\n            SqlValue {\n                data: Some(sql_value::Data::StringValue(\n                    \"Hellooooooo World\".to_string(),\n                )),\n            },\n            SqlValue {\n                data: Some(sql_value::Data::BlobValue(vec![])),\n            },\n        ]\n    }\n\n    #[test]\n    fn test_size_estimate() {\n        let row = Row { fields: gen_data() };\n        let result = DbResult {\n            rows: vec![row.clone(), row],\n        };\n\n        let actual_size = result.estimate_size();\n\n        let expected_size = (17 + 8 + 8) * 2; // 1 variable string, 1 long, 1 float\n        let expected_overhead = 4 * 2; // 4 optional columns\n\n        assert_eq!(actual_size, expected_overhead + expected_size);\n    }\n\n    #[test]\n    fn test_stream_size() {\n        let row = Row { fields: gen_data() };\n        let result = DbResult {\n            rows: vec![row.clone(), row.clone(), row],\n        };\n        let limit = 74 + 1; // two rows are 74\n\n        let result = select_slice_of_size(result.rows.iter(), limit).into_inner();\n\n        assert_eq!(\n            2,\n            result.1.len(),\n            \"The final element should not be included\"\n        );\n        assert_eq!(\n            74, result.0,\n            \"The size should be the size of the first two objects\"\n        );\n    }\n\n    #[test]\n    fn test_stream_size_too_small() {\n        let row = Row { fields: gen_data() };\n        let result = DbResult { rows: vec![row] };\n        let limit = 1;\n\n        let result = select_slice_of_size(result.rows.iter(), limit).into_inner();\n\n        assert_eq!(\n            1,\n            result.1.len(),\n            \"If the limit is too small, a result is still returned\"\n        );\n        assert_eq!(\n            37, result.0,\n            \"The size should be the size of the first objects\"\n        );\n    }\n\n    const SEQUENCE_NUMBER: i32 = 1;\n\n    fn get(col: &Collection, index: i64) -> Option<DbResponse> {\n        get_next(col, SEQUENCE_NUMBER, index)\n    }\n\n    fn get_first(col: &Collection, result: DbResult) -> DbResponse {\n        trim_and_cache_remaining(col, result, SEQUENCE_NUMBER)\n    }\n\n    fn seq_number_used(col: &Collection) -> bool {\n        HASHMAP\n            .lock()\n            .unwrap()\n            .get(&col.id_for_db_cache())\n            .unwrap()\n            .contains_key(&SEQUENCE_NUMBER)\n    }\n\n    #[test]\n    fn integration_test() {\n        let col = Collection::new();\n\n        let row = Row { fields: gen_data() };\n\n        // return one row at a time\n        set_max_page_size(row.estimate_size() - 1);\n\n        let db_query_result = DbResult {\n            rows: vec![row.clone(), row],\n        };\n\n        let first_jni_response = get_first(&col, db_query_result);\n\n        assert_eq!(\n            row_count(&first_jni_response),\n            1,\n            \"The first call should only return one row\"\n        );\n\n        let next_index = first_jni_response.start_index + row_count(&first_jni_response);\n\n        let second_response = get(&col, next_index);\n\n        assert!(\n            second_response.is_some(),\n            \"The second response should return a value\"\n        );\n        let valid_second_response = second_response.unwrap();\n        assert_eq!(row_count(&valid_second_response), 1);\n\n        let final_index = valid_second_response.start_index + row_count(&valid_second_response);\n\n        assert!(seq_number_used(&col), \"The sequence number is assigned\");\n\n        let final_response = get(&col, final_index);\n        assert!(\n            final_response.is_some(),\n            \"The third call should return something with no rows\"\n        );\n        assert_eq!(\n            row_count(&final_response.unwrap()),\n            0,\n            \"The third call should return something with no rows\"\n        );\n        assert!(\n            !seq_number_used(&col),\n            \"Sequence number data has been cleared\"\n        );\n    }\n\n    fn row_count(resp: &DbResponse) -> i64 {\n        resp.result.as_ref().map(|x| x.rows.len()).unwrap_or(0) as i64\n    }\n}\n"
  },
  {
    "path": "rslib/src/ankidroid/error.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::error::DbError;\nuse crate::error::DbErrorKind as DB;\nuse crate::error::FilteredDeckError;\nuse crate::error::InvalidInputError;\nuse crate::error::NetworkError;\nuse crate::error::NetworkErrorKind as Net;\nuse crate::error::NotFoundError;\nuse crate::error::SearchErrorKind;\nuse crate::error::SyncError;\nuse crate::error::SyncErrorKind as Sync;\nuse crate::prelude::AnkiError;\n\npub(crate) fn debug_produce_error(s: &str) -> AnkiError {\n    let info = \"error_value\".to_string();\n    match s {\n        \"TemplateError\" => AnkiError::TemplateError { info },\n        \"DbErrorFileTooNew\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::FileTooNew,\n            },\n        },\n        \"DbErrorFileTooOld\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::FileTooOld,\n            },\n        },\n        \"DbErrorMissingEntity\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::MissingEntity,\n            },\n        },\n        \"DbErrorCorrupt\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::Corrupt,\n            },\n        },\n        \"DbErrorLocked\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::Locked,\n            },\n        },\n        \"DbErrorOther\" => AnkiError::DbError {\n            source: DbError {\n                info,\n                kind: DB::Other,\n            },\n        },\n        \"NetworkError\" => AnkiError::NetworkError {\n            source: NetworkError {\n                info,\n                kind: Net::Offline,\n            },\n        },\n        \"SyncErrorConflict\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::Conflict,\n            },\n        },\n        \"SyncErrorServerError\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::ServerError,\n            },\n        },\n        \"SyncErrorClientTooOld\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::ClientTooOld,\n            },\n        },\n        \"SyncErrorAuthFailed\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::AuthFailed,\n            },\n        },\n        \"SyncErrorServerMessage\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::ServerMessage,\n            },\n        },\n        \"SyncErrorClockIncorrect\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::ClockIncorrect,\n            },\n        },\n        \"SyncErrorOther\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::Other,\n            },\n        },\n        \"SyncErrorResyncRequired\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::ResyncRequired,\n            },\n        },\n        \"SyncErrorDatabaseCheckRequired\" => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: Sync::DatabaseCheckRequired,\n            },\n        },\n        \"JSONError\" => AnkiError::JsonError { info },\n        \"ProtoError\" => AnkiError::ProtoError { info },\n        \"Interrupted\" => AnkiError::Interrupted,\n        \"CollectionNotOpen\" => AnkiError::CollectionNotOpen,\n        \"CollectionAlreadyOpen\" => AnkiError::CollectionAlreadyOpen,\n        \"NotFound\" => AnkiError::NotFound {\n            source: NotFoundError {\n                type_name: \"\".to_string(),\n                identifier: \"\".to_string(),\n                backtrace: None,\n            },\n        },\n        \"Existing\" => AnkiError::Existing,\n        \"FilteredDeckError\" => AnkiError::FilteredDeckError {\n            source: FilteredDeckError::FilteredDeckRequired,\n        },\n        \"SearchError\" => AnkiError::SearchError {\n            source: SearchErrorKind::EmptyGroup,\n        },\n        _ => AnkiError::InvalidInput {\n            source: InvalidInputError {\n                message: info,\n                source: None,\n                backtrace: None,\n            },\n        },\n    }\n}\n"
  },
  {
    "path": "rslib/src/ankidroid/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\npub(crate) mod db;\npub(crate) mod error;\npub mod service;\n"
  },
  {
    "path": "rslib/src/ankidroid/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::ankidroid::DbResponse;\nuse anki_proto::ankidroid::GetActiveSequenceNumbersResponse;\nuse anki_proto::ankidroid::GetNextResultPageRequest;\nuse anki_proto::generic;\n\nuse crate::ankidroid::db;\nuse crate::ankidroid::db::active_sequences;\nuse crate::ankidroid::db::execute_for_row_count;\nuse crate::ankidroid::db::insert_for_id;\nuse crate::backend::dbproxy::db_command_bytes;\nuse crate::backend::dbproxy::db_command_proto;\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::error::OrInvalid;\n\nimpl crate::services::AnkidroidService for Collection {\n    fn run_db_command(&mut self, input: generic::Json) -> error::Result<generic::Json> {\n        db_command_bytes(self, &input.json).map(|json| generic::Json { json })\n    }\n\n    fn run_db_command_proto(&mut self, input: generic::Json) -> error::Result<DbResponse> {\n        db_command_proto(self, &input.json)\n    }\n\n    fn run_db_command_for_row_count(\n        &mut self,\n        input: generic::Json,\n    ) -> error::Result<generic::Int64> {\n        execute_for_row_count(self, &input.json).map(|val| generic::Int64 { val })\n    }\n\n    fn flush_all_queries(&mut self) -> error::Result<()> {\n        db::flush_collection(self);\n        Ok(())\n    }\n\n    fn flush_query(&mut self, input: generic::Int32) -> error::Result<()> {\n        db::flush_single_result(self, input.val);\n        Ok(())\n    }\n\n    fn get_next_result_page(\n        &mut self,\n        input: GetNextResultPageRequest,\n    ) -> error::Result<DbResponse> {\n        db::get_next(self, input.sequence, input.index).or_invalid(\"missing result page\")\n    }\n\n    fn insert_for_id(&mut self, input: generic::Json) -> error::Result<generic::Int64> {\n        insert_for_id(self, &input.json).map(Into::into)\n    }\n\n    fn get_column_names_from_query(\n        &mut self,\n        input: generic::String,\n    ) -> error::Result<generic::StringList> {\n        let stmt = self.storage.db.prepare(&input.val)?;\n        let names = stmt.column_names();\n        let names: Vec<_> = names.iter().map(ToString::to_string).collect();\n        Ok(names.into())\n    }\n\n    fn get_active_sequence_numbers(&mut self) -> error::Result<GetActiveSequenceNumbersResponse> {\n        Ok(GetActiveSequenceNumbersResponse {\n            numbers: active_sequences(self),\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/ankihub/http_client/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\n\nuse reqwest::Client;\nuse reqwest::Response;\nuse reqwest::Result;\nuse reqwest::Url;\nuse serde::Serialize;\n\nuse crate::ankihub::login::LoginRequest;\n\nstatic API_VERSION: &str = \"19.0\";\nstatic DEFAULT_API_URL: &str = \"https://app.ankihub.net/api/\";\n\n#[derive(Clone)]\npub struct HttpAnkiHubClient {\n    pub token: String,\n    pub endpoint: Url,\n    client: Client,\n}\n\nimpl HttpAnkiHubClient {\n    pub fn new<S: Into<String>>(token: S, client: Client) -> HttpAnkiHubClient {\n        let endpoint = match env::var(\"ANKIHUB_APP_URL\") {\n            Ok(url) => {\n                if let Ok(u) = Url::try_from(url.as_str()) {\n                    u.join(\"api/\").unwrap().to_string()\n                } else {\n                    DEFAULT_API_URL.to_string()\n                }\n            }\n            Err(_) => DEFAULT_API_URL.to_string(),\n        };\n        HttpAnkiHubClient {\n            token: token.into(),\n            endpoint: Url::try_from(endpoint.as_str()).unwrap(),\n            client,\n        }\n    }\n\n    async fn request<T: Serialize + ?Sized>(&self, method: &str, data: &T) -> Result<Response> {\n        let url = self.endpoint.join(method).unwrap();\n        let mut builder = self.client.post(url).header(\n            reqwest::header::ACCEPT,\n            format!(\"application/json; version={API_VERSION}\"),\n        );\n        if !self.token.is_empty() {\n            builder = builder.header(\"Authorization\", format!(\"Token {}\", self.token));\n        }\n        builder.json(&data).send().await\n    }\n\n    pub async fn login(&self, data: LoginRequest) -> Result<Response> {\n        self.request(\"login/\", &data).await\n    }\n\n    pub async fn logout(&self) -> Result<Response> {\n        self.request(\"logout/\", \"\").await\n    }\n}\n"
  },
  {
    "path": "rslib/src/ankihub/login.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::LazyLock;\n\nuse regex::Regex;\nuse reqwest::Client;\nuse serde;\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::ankihub::http_client::HttpAnkiHubClient;\nuse crate::prelude::*;\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct LoginRequest {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub username: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub email: Option<String>,\n    pub password: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct LoginResponse {\n    pub token: Option<String>,\n}\n\npub async fn ankihub_login<S: Into<String>>(\n    id: S,\n    password: S,\n    client: Client,\n) -> Result<LoginResponse> {\n    let client = HttpAnkiHubClient::new(\"\", client);\n    static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(r\"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$\").unwrap()\n    });\n    let mut request = LoginRequest {\n        username: None,\n        email: None,\n        password: password.into(),\n    };\n    let id: String = id.into();\n    if EMAIL_RE.is_match(&id) {\n        request.email = Some(id);\n    } else {\n        request.username = Some(id);\n    }\n    client\n        .login(request)\n        .await?\n        .json::<LoginResponse>()\n        .await\n        .map_err(|e| e.into())\n}\n\npub async fn ankihub_logout<S: Into<String>>(token: S, client: Client) -> Result<()> {\n    let client = HttpAnkiHubClient::new(token, client);\n    client.logout().await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/src/ankihub/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod http_client;\npub mod login;\n"
  },
  {
    "path": "rslib/src/backend/adding.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::notes::DeckAndNotetype as DeckAndNotetypeProto;\n\nuse crate::adding::DeckAndNotetype;\n\nimpl From<DeckAndNotetype> for DeckAndNotetypeProto {\n    fn from(s: DeckAndNotetype) -> Self {\n        DeckAndNotetypeProto {\n            deck_id: s.deck_id.0,\n            notetype_id: s.notetype_id.0,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/ankidroid.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::generic;\n\nuse super::Backend;\nuse crate::ankidroid::db;\nuse crate::ankidroid::error::debug_produce_error;\nuse crate::prelude::*;\nuse crate::scheduler::timing;\nuse crate::scheduler::timing::fixed_offset_from_minutes;\nuse crate::services::BackendAnkidroidService;\n\nimpl BackendAnkidroidService for Backend {\n    fn sched_timing_today_legacy(\n        &self,\n        input: anki_proto::ankidroid::SchedTimingTodayLegacyRequest,\n    ) -> Result<anki_proto::scheduler::SchedTimingTodayResponse> {\n        let result = timing::sched_timing_today(\n            TimestampSecs::from(input.created_secs),\n            TimestampSecs::from(input.now_secs),\n            input.created_mins_west.map(fixed_offset_from_minutes),\n            fixed_offset_from_minutes(input.now_mins_west),\n            Some(input.rollover_hour as u8),\n        )?;\n        Ok(anki_proto::scheduler::SchedTimingTodayResponse::from(\n            result,\n        ))\n    }\n\n    fn local_minutes_west_legacy(&self, input: generic::Int64) -> Result<generic::Int32> {\n        Ok(generic::Int32 {\n            val: timing::local_minutes_west_for_stamp(input.val.into())?,\n        })\n    }\n\n    fn set_page_size(&self, input: generic::Int64) -> Result<()> {\n        // we don't require an open collection, but should avoid modifying this\n        // concurrently\n        let _guard = self.col.lock();\n        db::set_max_page_size(input.val as usize);\n        Ok(())\n    }\n\n    fn debug_produce_error(&self, input: generic::String) -> Result<()> {\n        Err(debug_produce_error(&input.val))\n    }\n}\n\nimpl From<crate::scheduler::timing::SchedTimingToday>\n    for anki_proto::scheduler::SchedTimingTodayResponse\n{\n    fn from(\n        t: crate::scheduler::timing::SchedTimingToday,\n    ) -> anki_proto::scheduler::SchedTimingTodayResponse {\n        anki_proto::scheduler::SchedTimingTodayResponse {\n            days_elapsed: t.days_elapsed,\n            next_day_at: t.next_day_at.0,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/ankihub.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::Backend;\nuse crate::ankihub::login::ankihub_login;\nuse crate::ankihub::login::ankihub_logout;\nuse crate::ankihub::login::LoginResponse;\nuse crate::prelude::*;\n\nimpl From<LoginResponse> for anki_proto::ankihub::LoginResponse {\n    fn from(value: LoginResponse) -> Self {\n        anki_proto::ankihub::LoginResponse {\n            token: value.token.unwrap_or_default(),\n        }\n    }\n}\n\nimpl crate::services::BackendAnkiHubService for Backend {\n    fn ankihub_login(\n        &self,\n        input: anki_proto::ankihub::LoginRequest,\n    ) -> Result<anki_proto::ankihub::LoginResponse> {\n        let rt = self.runtime_handle();\n        let fut = ankihub_login(input.id, input.password, self.web_client());\n\n        rt.block_on(fut).map(|a| a.into())\n    }\n\n    fn ankihub_logout(&self, input: anki_proto::ankihub::LogoutRequest) -> Result<()> {\n        let rt = self.runtime_handle();\n        let fut = ankihub_logout(input.token, self.web_client());\n        rt.block_on(fut)\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/ankiweb.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::time::Duration;\n\nuse anki_proto::ankiweb::CheckForUpdateRequest;\nuse anki_proto::ankiweb::CheckForUpdateResponse;\nuse anki_proto::ankiweb::GetAddonInfoRequest;\nuse anki_proto::ankiweb::GetAddonInfoResponse;\nuse prost::Message;\n\nuse super::Backend;\nuse crate::prelude::*;\nuse crate::services::BackendAnkiwebService;\n\nfn service_url(service: &str) -> String {\n    format!(\"https://ankiweb.net/svc/{service}\")\n}\n\nimpl Backend {\n    fn post<I, O>(&self, service: &str, input: I) -> Result<O>\n    where\n        I: Message,\n        O: Message + Default,\n    {\n        self.runtime_handle().block_on(async move {\n            let out = self\n                .web_client()\n                .post(service_url(service))\n                .body(input.encode_to_vec())\n                .timeout(Duration::from_secs(60))\n                .send()\n                .await?\n                .error_for_status()?\n                .bytes()\n                .await?;\n            let out: O = O::decode(&out[..])?;\n            Ok(out)\n        })\n    }\n}\n\nimpl BackendAnkiwebService for Backend {\n    fn get_addon_info(&self, input: GetAddonInfoRequest) -> Result<GetAddonInfoResponse> {\n        self.post(\"desktop/addon-info\", input)\n    }\n\n    fn check_for_update(&self, input: CheckForUpdateRequest) -> Result<CheckForUpdateResponse> {\n        self.post(\"desktop/check-for-update\", input)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn addon_info() -> Result<()> {\n        if std::env::var(\"ONLINE_TESTS\").is_err() {\n            println!(\"test disabled; ONLINE_TESTS not set\");\n            return Ok(());\n        }\n        let backend = Backend::new(I18n::template_only(), false);\n        let info = backend.get_addon_info(GetAddonInfoRequest {\n            client_version: 30,\n            addon_ids: vec![3918629684],\n        })?;\n        assert_eq!(info.info[0].min_version, 0);\n        assert_eq!(info.info[0].max_version, 49);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/card_rendering.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::card_rendering::StripHtmlRequest;\n\nuse crate::backend::Backend;\nuse crate::card_rendering::service::strip_html_proto;\nuse crate::card_rendering::tts;\nuse crate::prelude::*;\nuse crate::services::BackendCardRenderingService;\n\nimpl BackendCardRenderingService for Backend {\n    fn strip_html(\n        &self,\n        input: StripHtmlRequest,\n    ) -> crate::error::Result<anki_proto::generic::String> {\n        strip_html_proto(input)\n    }\n\n    fn all_tts_voices(\n        &self,\n        input: anki_proto::card_rendering::AllTtsVoicesRequest,\n    ) -> Result<anki_proto::card_rendering::AllTtsVoicesResponse> {\n        tts::all_voices(input.validate)\n            .map(|voices| anki_proto::card_rendering::AllTtsVoicesResponse { voices })\n    }\n\n    fn write_tts_stream(\n        &self,\n        request: anki_proto::card_rendering::WriteTtsStreamRequest,\n    ) -> Result<()> {\n        tts::write_stream(\n            &request.path,\n            &request.voice_id,\n            request.speed,\n            &request.text,\n        )\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/collection.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::MutexGuard;\n\nuse anki_proto::generic;\nuse tracing::error;\n\nuse super::Backend;\nuse crate::collection::CollectionBuilder;\nuse crate::prelude::*;\nuse crate::progress::progress_to_proto;\nuse crate::services::BackendCollectionService;\nuse crate::storage::SchemaVersion;\n\nimpl BackendCollectionService for Backend {\n    fn open_collection(&self, input: anki_proto::collection::OpenCollectionRequest) -> Result<()> {\n        let mut guard = self.lock_closed_collection()?;\n\n        let mut builder = CollectionBuilder::new(input.collection_path);\n        builder\n            .set_media_paths(input.media_folder_path, input.media_db_path)\n            .set_server(self.server)\n            .set_tr(self.tr.clone())\n            .set_shared_progress_state(self.progress_state.clone());\n\n        *guard = Some(builder.build()?);\n\n        Ok(())\n    }\n\n    fn close_collection(\n        &self,\n        input: anki_proto::collection::CloseCollectionRequest,\n    ) -> Result<()> {\n        let desired_version = if input.downgrade_to_schema11 {\n            Some(SchemaVersion::V11)\n        } else {\n            None\n        };\n\n        self.abort_media_sync_and_wait();\n        let mut guard = self.lock_open_collection()?;\n        let col_inner = guard.take().unwrap();\n\n        if let Err(e) = col_inner.close(desired_version) {\n            error!(\" failed: {:?}\", e);\n        }\n\n        Ok(())\n    }\n\n    fn create_backup(\n        &self,\n        input: anki_proto::collection::CreateBackupRequest,\n    ) -> Result<generic::Bool> {\n        // lock collection\n        let mut col_lock = self.lock_open_collection()?;\n        let col = col_lock.as_mut().unwrap();\n        // await any previous backup first\n        let mut task_lock = self.backup_task.lock().unwrap();\n        if let Some(task) = task_lock.take() {\n            task.join().unwrap()?;\n        }\n        // start the new backup\n        let created = if let Some(task) = col.maybe_backup(input.backup_folder, input.force)? {\n            if input.wait_for_completion {\n                drop(col_lock);\n                task.join().unwrap()?;\n            } else {\n                *task_lock = Some(task);\n            }\n            true\n        } else {\n            false\n        };\n        Ok(created.into())\n    }\n\n    fn await_backup_completion(&self) -> Result<()> {\n        self.await_backup_completion()?;\n        Ok(())\n    }\n\n    fn latest_progress(&self) -> Result<anki_proto::collection::Progress> {\n        let progress = self.progress_state.lock().unwrap().last_progress;\n        Ok(progress_to_proto(progress, &self.tr))\n    }\n\n    fn set_wants_abort(&self) -> Result<()> {\n        self.progress_state.lock().unwrap().want_abort = true;\n        Ok(())\n    }\n}\n\nimpl Backend {\n    pub(super) fn lock_open_collection(&self) -> Result<MutexGuard<'_, Option<Collection>>> {\n        let guard = self.col.lock().unwrap();\n        guard\n            .is_some()\n            .then_some(guard)\n            .ok_or(AnkiError::CollectionNotOpen)\n    }\n\n    pub(super) fn lock_closed_collection(&self) -> Result<MutexGuard<'_, Option<Collection>>> {\n        let guard = self.col.lock().unwrap();\n        guard\n            .is_none()\n            .then_some(guard)\n            .ok_or(AnkiError::CollectionAlreadyOpen)\n    }\n\n    fn await_backup_completion(&self) -> Result<()> {\n        if let Some(task) = self.backup_task.lock().unwrap().take() {\n            task.join().unwrap()?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/config.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::config::config_key::Bool as BoolKeyProto;\nuse anki_proto::config::config_key::String as StringKeyProto;\nuse anki_proto::generic;\nuse serde_json::Value;\n\nuse crate::config::BoolKey;\nuse crate::config::StringKey;\nuse crate::prelude::*;\n\nimpl From<BoolKeyProto> for BoolKey {\n    fn from(k: BoolKeyProto) -> Self {\n        match k {\n            BoolKeyProto::BrowserTableShowNotesMode => BoolKey::BrowserTableShowNotesMode,\n            BoolKeyProto::PreviewBothSides => BoolKey::PreviewBothSides,\n            BoolKeyProto::CollapseTags => BoolKey::CollapseTags,\n            BoolKeyProto::CollapseNotetypes => BoolKey::CollapseNotetypes,\n            BoolKeyProto::CollapseDecks => BoolKey::CollapseDecks,\n            BoolKeyProto::CollapseSavedSearches => BoolKey::CollapseSavedSearches,\n            BoolKeyProto::CollapseToday => BoolKey::CollapseToday,\n            BoolKeyProto::CollapseCardState => BoolKey::CollapseCardState,\n            BoolKeyProto::CollapseFlags => BoolKey::CollapseFlags,\n            BoolKeyProto::Sched2021 => BoolKey::Sched2021,\n            BoolKeyProto::AddingDefaultsToCurrentDeck => BoolKey::AddingDefaultsToCurrentDeck,\n            BoolKeyProto::HideAudioPlayButtons => BoolKey::HideAudioPlayButtons,\n            BoolKeyProto::InterruptAudioWhenAnswering => BoolKey::InterruptAudioWhenAnswering,\n            BoolKeyProto::PasteImagesAsPng => BoolKey::PasteImagesAsPng,\n            BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting,\n            BoolKeyProto::NormalizeNoteText => BoolKey::NormalizeNoteText,\n            BoolKeyProto::IgnoreAccentsInSearch => BoolKey::IgnoreAccentsInSearch,\n            BoolKeyProto::RestorePositionBrowser => BoolKey::RestorePositionBrowser,\n            BoolKeyProto::RestorePositionReviewer => BoolKey::RestorePositionReviewer,\n            BoolKeyProto::ResetCountsBrowser => BoolKey::ResetCountsBrowser,\n            BoolKeyProto::ResetCountsReviewer => BoolKey::ResetCountsReviewer,\n            BoolKeyProto::RandomOrderReposition => BoolKey::RandomOrderReposition,\n            BoolKeyProto::ShiftPositionOfExistingCards => BoolKey::ShiftPositionOfExistingCards,\n            BoolKeyProto::RenderLatex => BoolKey::RenderLatex,\n            BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled,\n            BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled,\n            BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate,\n        }\n    }\n}\n\nimpl From<StringKeyProto> for StringKey {\n    fn from(k: StringKeyProto) -> Self {\n        match k {\n            StringKeyProto::SetDueBrowser => StringKey::SetDueBrowser,\n            StringKeyProto::SetDueReviewer => StringKey::SetDueReviewer,\n            StringKeyProto::DefaultSearchText => StringKey::DefaultSearchText,\n            StringKeyProto::CardStateCustomizer => StringKey::CardStateCustomizer,\n        }\n    }\n}\n\nimpl crate::services::ConfigService for Collection {\n    fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> {\n        let val: Option<Value> = self.get_config_optional(input.val.as_str());\n        val.or_not_found(input.val)\n            .and_then(|v| serde_json::to_vec(&v).map_err(Into::into))\n            .map(Into::into)\n    }\n\n    fn set_config_json(\n        &mut self,\n        input: anki_proto::config::SetConfigJsonRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        let val: Value = serde_json::from_slice(&input.value_json)?;\n        self.set_config_json(input.key.as_str(), &val, input.undoable)\n            .map(Into::into)\n    }\n\n    fn set_config_json_no_undo(\n        &mut self,\n        input: anki_proto::config::SetConfigJsonRequest,\n    ) -> Result<()> {\n        let val: Value = serde_json::from_slice(&input.value_json)?;\n        self.transact_no_undo(|col| col.set_config(input.key.as_str(), &val).map(|_| ()))\n    }\n\n    fn remove_config(\n        &mut self,\n        input: generic::String,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.remove_config(input.val.as_str()).map(Into::into)\n    }\n\n    fn get_all_config(&mut self) -> Result<generic::Json> {\n        let conf = self.storage.get_all_config()?;\n        serde_json::to_vec(&conf)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_config_bool(\n        &mut self,\n        input: anki_proto::config::GetConfigBoolRequest,\n    ) -> Result<generic::Bool> {\n        Ok(generic::Bool {\n            val: Collection::get_config_bool(self, input.key().into()),\n        })\n    }\n\n    fn set_config_bool(\n        &mut self,\n        input: anki_proto::config::SetConfigBoolRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.set_config_bool(input.key().into(), input.value, input.undoable)\n            .map(Into::into)\n    }\n\n    fn get_config_string(\n        &mut self,\n        input: anki_proto::config::GetConfigStringRequest,\n    ) -> Result<generic::String> {\n        Ok(generic::String {\n            val: Collection::get_config_string(self, input.key().into()),\n        })\n    }\n\n    fn set_config_string(\n        &mut self,\n        input: anki_proto::config::SetConfigStringRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.set_config_string(input.key().into(), &input.value, input.undoable)\n            .map(Into::into)\n    }\n\n    fn get_preferences(&mut self) -> Result<anki_proto::config::Preferences> {\n        Collection::get_preferences(self)\n    }\n\n    fn set_preferences(\n        &mut self,\n        input: anki_proto::config::Preferences,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.set_preferences(input).map(Into::into)\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/dbproxy.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::ankidroid::sql_value::Data;\nuse anki_proto::ankidroid::DbResponse;\nuse anki_proto::ankidroid::DbResult as ProtoDbResult;\nuse anki_proto::ankidroid::SqlValue as pb_SqlValue;\nuse rusqlite::params_from_iter;\nuse rusqlite::types::FromSql;\nuse rusqlite::types::FromSqlError;\nuse rusqlite::types::ToSql;\nuse rusqlite::types::ToSqlOutput;\nuse rusqlite::types::ValueRef;\nuse rusqlite::OptionalExtension;\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::ankidroid::db::next_sequence_number;\nuse crate::ankidroid::db::trim_and_cache_remaining;\nuse crate::prelude::*;\nuse crate::storage::SqliteStorage;\n\n#[derive(Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"lowercase\")]\npub(super) enum DbRequest {\n    Query {\n        sql: String,\n        args: Vec<SqlValue>,\n        first_row_only: bool,\n    },\n    Begin,\n    Commit,\n    Rollback,\n    ExecuteMany {\n        sql: String,\n        args: Vec<Vec<SqlValue>>,\n    },\n}\n\n#[derive(Serialize)]\n#[serde(untagged)]\npub(super) enum DbResult {\n    Rows(Vec<Vec<SqlValue>>),\n    None,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(untagged)]\npub(crate) enum SqlValue {\n    Null,\n    String(String),\n    Int(i64),\n    Double(f64),\n    Blob(Vec<u8>),\n}\n\nimpl ToSql for SqlValue {\n    fn to_sql(&self) -> std::result::Result<ToSqlOutput<'_>, rusqlite::Error> {\n        let val = match self {\n            SqlValue::Null => ValueRef::Null,\n            SqlValue::String(v) => ValueRef::Text(v.as_bytes()),\n            SqlValue::Int(v) => ValueRef::Integer(*v),\n            SqlValue::Double(v) => ValueRef::Real(*v),\n            SqlValue::Blob(v) => ValueRef::Blob(v),\n        };\n        Ok(ToSqlOutput::Borrowed(val))\n    }\n}\n\nimpl From<&SqlValue> for anki_proto::ankidroid::SqlValue {\n    fn from(item: &SqlValue) -> Self {\n        match item {\n            SqlValue::Null => pb_SqlValue { data: Option::None },\n            SqlValue::String(s) => pb_SqlValue {\n                data: Some(Data::StringValue(s.to_string())),\n            },\n            SqlValue::Int(i) => pb_SqlValue {\n                data: Some(Data::LongValue(*i)),\n            },\n            SqlValue::Double(d) => pb_SqlValue {\n                data: Some(Data::DoubleValue(*d)),\n            },\n            SqlValue::Blob(b) => pb_SqlValue {\n                data: Some(Data::BlobValue(b.clone())),\n            },\n        }\n    }\n}\n\nfn row_to_proto(row: &[SqlValue]) -> anki_proto::ankidroid::Row {\n    anki_proto::ankidroid::Row {\n        fields: row\n            .iter()\n            .map(anki_proto::ankidroid::SqlValue::from)\n            .collect(),\n    }\n}\n\nfn rows_to_proto(rows: &[Vec<SqlValue>]) -> anki_proto::ankidroid::DbResult {\n    anki_proto::ankidroid::DbResult {\n        rows: rows.iter().map(|r| row_to_proto(r)).collect(),\n    }\n}\n\nimpl FromSql for SqlValue {\n    fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {\n        let val = match value {\n            ValueRef::Null => SqlValue::Null,\n            ValueRef::Integer(i) => SqlValue::Int(i),\n            ValueRef::Real(v) => SqlValue::Double(v),\n            ValueRef::Text(v) => SqlValue::String(String::from_utf8_lossy(v).to_string()),\n            ValueRef::Blob(v) => SqlValue::Blob(v.to_vec()),\n        };\n        Ok(val)\n    }\n}\n\npub(crate) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec<u8>> {\n    serde_json::to_vec(&db_command_bytes_inner(col, input)?).map_err(Into::into)\n}\n\npub(super) fn db_command_bytes_inner(col: &mut Collection, input: &[u8]) -> Result<DbResult> {\n    let req: DbRequest = serde_json::from_slice(input)?;\n    let resp = match req {\n        DbRequest::Query {\n            sql,\n            args,\n            first_row_only,\n        } => {\n            update_state_after_modification(col, &sql);\n            if first_row_only {\n                db_query_row(&col.storage, &sql, &args)?\n            } else {\n                db_query(&col.storage, &sql, &args)?\n            }\n        }\n        DbRequest::Begin => {\n            col.storage.begin_trx()?;\n            DbResult::None\n        }\n        DbRequest::Commit => {\n            if col.state.modified_by_dbproxy {\n                col.storage.set_modified_time(TimestampMillis::now())?;\n                col.state.modified_by_dbproxy = false;\n            }\n            col.storage.commit_trx()?;\n            DbResult::None\n        }\n        DbRequest::Rollback => {\n            col.clear_caches();\n            col.storage.rollback_trx()?;\n            DbResult::None\n        }\n        DbRequest::ExecuteMany { sql, args } => {\n            update_state_after_modification(col, &sql);\n            db_execute_many(&col.storage, &sql, &args)?\n        }\n    };\n    Ok(resp)\n}\n\nfn update_state_after_modification(col: &mut Collection, sql: &str) {\n    if !is_dql(sql) {\n        // println!(\"clearing undo+study due to {}\", sql);\n        col.update_state_after_dbproxy_modification();\n    }\n}\n\n/// Anything other than a select statement is false.\nfn is_dql(sql: &str) -> bool {\n    let head: String = sql\n        .trim_start()\n        .chars()\n        .take(10)\n        .map(|c| c.to_ascii_lowercase())\n        .collect();\n    head.starts_with(\"select\")\n}\n\npub(crate) fn db_command_proto(col: &mut Collection, input: &[u8]) -> Result<DbResponse> {\n    let result = db_command_bytes_inner(col, input)?;\n    let proto_resp = match result {\n        DbResult::None => ProtoDbResult { rows: Vec::new() },\n        DbResult::Rows(rows) => rows_to_proto(&rows),\n    };\n    let trimmed = trim_and_cache_remaining(col, proto_resp, next_sequence_number());\n    Ok(trimmed)\n}\n\npub(super) fn db_query_row(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result<DbResult> {\n    let mut stmt = ctx.db.prepare_cached(sql)?;\n    let columns = stmt.column_count();\n\n    let row = stmt\n        .query_row(params_from_iter(args), |row| {\n            let mut orow = Vec::with_capacity(columns);\n            for i in 0..columns {\n                let v: SqlValue = row.get(i)?;\n                orow.push(v);\n            }\n            Ok(orow)\n        })\n        .optional()?;\n\n    let rows = if let Some(row) = row {\n        vec![row]\n    } else {\n        vec![]\n    };\n\n    Ok(DbResult::Rows(rows))\n}\n\npub(super) fn db_query(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result<DbResult> {\n    let mut stmt = ctx.db.prepare_cached(sql)?;\n    let columns = stmt.column_count();\n\n    let res: std::result::Result<Vec<Vec<_>>, rusqlite::Error> = stmt\n        .query_map(params_from_iter(args), |row| {\n            let mut orow = Vec::with_capacity(columns);\n            for i in 0..columns {\n                let v: SqlValue = row.get(i)?;\n                orow.push(v);\n            }\n            Ok(orow)\n        })?\n        .collect();\n\n    Ok(DbResult::Rows(res?))\n}\n\npub(super) fn db_execute_many(\n    ctx: &SqliteStorage,\n    sql: &str,\n    args: &[Vec<SqlValue>],\n) -> Result<DbResult> {\n    let mut stmt = ctx.db.prepare_cached(sql)?;\n\n    for params in args {\n        stmt.execute(params_from_iter(params))?;\n    }\n\n    Ok(DbResult::None)\n}\n"
  },
  {
    "path": "rslib/src/backend/error.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::backend::backend_error::Kind;\n\nuse crate::error::AnkiError;\nuse crate::error::SyncErrorKind;\nuse crate::prelude::*;\n\nimpl AnkiError {\n    pub fn into_protobuf(self, tr: &I18n) -> anki_proto::backend::BackendError {\n        let message = self.message(tr);\n        let help_page = self.help_page().map(|page| page as i32);\n        let context = self.context();\n        let backtrace = self.backtrace();\n        let kind = match self {\n            AnkiError::InvalidInput { .. } => Kind::InvalidInput,\n            AnkiError::TemplateError { .. } => Kind::TemplateParse,\n            AnkiError::DbError { .. } => Kind::DbError,\n            AnkiError::NetworkError { .. } => Kind::NetworkError,\n            AnkiError::SyncError { source } => source.kind.into(),\n            AnkiError::Interrupted => Kind::Interrupted,\n            AnkiError::CollectionNotOpen => Kind::InvalidInput,\n            AnkiError::CollectionAlreadyOpen => Kind::InvalidInput,\n            AnkiError::JsonError { .. } => Kind::JsonError,\n            AnkiError::ProtoError { .. } => Kind::ProtoError,\n            AnkiError::NotFound { .. } => Kind::NotFoundError,\n            AnkiError::Deleted => Kind::Deleted,\n            AnkiError::Existing => Kind::Exists,\n            AnkiError::FilteredDeckError { .. } => Kind::FilteredDeckError,\n            AnkiError::SearchError { .. } => Kind::SearchError,\n            AnkiError::CardTypeError { .. } => Kind::CardTypeError,\n            AnkiError::ParseNumError => Kind::InvalidInput,\n            AnkiError::InvalidRegex { .. } => Kind::InvalidInput,\n            AnkiError::UndoEmpty => Kind::UndoEmpty,\n            AnkiError::MultipleNotetypesSelected => Kind::InvalidInput,\n            AnkiError::DatabaseCheckRequired => Kind::InvalidInput,\n            AnkiError::CustomStudyError { .. } => Kind::CustomStudyError,\n            AnkiError::ImportError { .. } => Kind::ImportError,\n            AnkiError::FileIoError { .. } => Kind::IoError,\n            AnkiError::MediaCheckRequired => Kind::InvalidInput,\n            AnkiError::InvalidId => Kind::InvalidInput,\n            AnkiError::InvalidMethodIndex\n            | AnkiError::InvalidServiceIndex\n            | AnkiError::FsrsParamsInvalid\n            | AnkiError::FsrsUnableToDetermineDesiredRetention\n            | AnkiError::FsrsInsufficientData => Kind::InvalidInput,\n            #[cfg(windows)]\n            AnkiError::WindowsError { .. } => Kind::OsError,\n            AnkiError::SchedulerUpgradeRequired => Kind::SchedulerUpgradeRequired,\n            AnkiError::FsrsInsufficientReviews { .. } => Kind::InvalidInput,\n            AnkiError::InvalidCertificateFormat => Kind::InvalidCertificateFormat,\n        };\n\n        anki_proto::backend::BackendError {\n            kind: kind as i32,\n            message,\n            help_page,\n            context,\n            backtrace,\n        }\n    }\n}\n\nimpl From<SyncErrorKind> for Kind {\n    fn from(err: SyncErrorKind) -> Self {\n        match err {\n            SyncErrorKind::AuthFailed => Kind::SyncAuthError,\n            SyncErrorKind::ServerMessage => Kind::SyncServerMessage,\n            _ => Kind::SyncOtherError,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/i18n.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::generic;\nuse anki_proto::i18n::FormatTimespanRequest;\nuse anki_proto::i18n::I18nResourcesRequest;\nuse anki_proto::i18n::TranslateStringRequest;\n\nuse super::Backend;\nuse crate::i18n::service;\nuse crate::prelude::*;\n\n// We avoid delegating to collection for these, as tr doesn't require a\n// collection lock.\nimpl crate::services::BackendI18nService for Backend {\n    fn translate_string(&self, input: TranslateStringRequest) -> Result<generic::String> {\n        service::translate_string(&self.tr, input)\n    }\n\n    fn format_timespan(&self, input: FormatTimespanRequest) -> Result<generic::String> {\n        service::format_timespan(&self.tr, input)\n    }\n\n    fn i18n_resources(&self, input: I18nResourcesRequest) -> Result<generic::Json> {\n        service::i18n_resources(&self.tr, input)\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/import_export.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::path::Path;\n\nuse super::Backend;\nuse crate::import_export::package::import_colpkg;\nuse crate::prelude::*;\nuse crate::services::BackendImportExportService;\n\nimpl BackendImportExportService for Backend {\n    fn export_collection_package(\n        &self,\n        input: anki_proto::import_export::ExportCollectionPackageRequest,\n    ) -> Result<()> {\n        self.abort_media_sync_and_wait();\n\n        let mut guard = self.lock_open_collection()?;\n\n        let col_inner = guard.take().unwrap();\n        col_inner.export_colpkg(input.out_path, input.include_media, input.legacy)\n    }\n\n    fn import_collection_package(\n        &self,\n        input: anki_proto::import_export::ImportCollectionPackageRequest,\n    ) -> Result<()> {\n        let _guard = self.lock_closed_collection()?;\n\n        import_colpkg(\n            &input.backup_path,\n            &input.col_path,\n            Path::new(&input.media_folder),\n            Path::new(&input.media_db),\n            self.new_progress_handler(),\n        )\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod adding;\nmod ankidroid;\nmod ankihub;\nmod ankiweb;\nmod card_rendering;\nmod collection;\nmod config;\npub(crate) mod dbproxy;\nmod error;\nmod i18n;\nmod import_export;\nmod ops;\nmod sync;\n\nuse std::ops::Deref;\nuse std::result;\nuse std::sync::Arc;\nuse std::sync::Mutex;\nuse std::sync::OnceLock;\nuse std::thread::JoinHandle;\n\nuse futures::future::AbortHandle;\nuse prost::Message;\nuse reqwest::Client;\nuse tokio::runtime;\nuse tokio::runtime::Runtime;\n\nuse crate::backend::dbproxy::db_command_bytes;\nuse crate::backend::sync::SyncState;\nuse crate::prelude::*;\nuse crate::progress::Progress;\nuse crate::progress::ProgressState;\nuse crate::progress::ThrottlingProgressHandler;\n\n#[derive(Clone)]\n#[repr(transparent)]\npub struct Backend(Arc<BackendInner>);\n\nimpl Deref for Backend {\n    type Target = BackendInner;\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\npub struct BackendInner {\n    col: Mutex<Option<Collection>>,\n    pub(crate) tr: I18n,\n    server: bool,\n    sync_abort: Mutex<Option<AbortHandle>>,\n    progress_state: Arc<Mutex<ProgressState>>,\n    runtime: OnceLock<Runtime>,\n    state: Mutex<BackendState>,\n    backup_task: Mutex<Option<JoinHandle<Result<()>>>>,\n    media_sync_task: Mutex<Option<JoinHandle<Result<()>>>>,\n    web_client: Mutex<Option<Client>>,\n}\n\n#[derive(Default)]\nstruct BackendState {\n    sync: SyncState,\n}\n\npub fn init_backend(init_msg: &[u8]) -> result::Result<Backend, String> {\n    let input: anki_proto::backend::BackendInit =\n        match anki_proto::backend::BackendInit::decode(init_msg) {\n            Ok(req) => req,\n            Err(_) => return Err(\"couldn't decode init request\".into()),\n        };\n\n    let tr = I18n::new(&input.preferred_langs);\n\n    Ok(Backend::new(tr, input.server))\n}\n\nimpl Backend {\n    pub fn new(tr: I18n, server: bool) -> Backend {\n        Backend(Arc::new(BackendInner {\n            col: Mutex::new(None),\n            tr,\n            server,\n            sync_abort: Mutex::new(None),\n            progress_state: Arc::new(Mutex::new(ProgressState {\n                want_abort: false,\n                last_progress: None,\n            })),\n            runtime: OnceLock::new(),\n            state: Mutex::new(BackendState::default()),\n            backup_task: Mutex::new(None),\n            media_sync_task: Mutex::new(None),\n            web_client: Mutex::new(None),\n        }))\n    }\n\n    pub fn i18n(&self) -> &I18n {\n        &self.tr\n    }\n\n    pub fn run_db_command_bytes(&self, input: &[u8]) -> result::Result<Vec<u8>, Vec<u8>> {\n        self.db_command(input).map_err(|err| {\n            let backend_err = err.into_protobuf(&self.tr);\n            let mut bytes = Vec::new();\n            backend_err.encode(&mut bytes).unwrap();\n            bytes\n        })\n    }\n\n    /// If collection is open, run the provided closure while holding\n    /// the mutex.\n    /// If collection is not open, return an error.\n    pub(crate) fn with_col<F, T>(&self, func: F) -> Result<T>\n    where\n        F: FnOnce(&mut Collection) -> Result<T>,\n    {\n        func(\n            self.col\n                .lock()\n                .unwrap()\n                .as_mut()\n                .ok_or(AnkiError::CollectionNotOpen)?,\n        )\n    }\n\n    fn runtime_handle(&self) -> runtime::Handle {\n        self.runtime\n            .get_or_init(|| {\n                runtime::Builder::new_multi_thread()\n                    .worker_threads(1)\n                    .enable_all()\n                    .build()\n                    .unwrap()\n            })\n            .handle()\n            .clone()\n    }\n\n    #[cfg(feature = \"rustls\")]\n    fn set_custom_certificate_inner(&self, cert_str: String) -> Result<()> {\n        use std::io::Cursor;\n        use std::io::Read;\n\n        use reqwest::Certificate;\n\n        let mut web_client = self.web_client.lock().unwrap();\n\n        if cert_str.is_empty() {\n            let _ = web_client.insert(Client::builder().http1_only().build().unwrap());\n            return Ok(());\n        }\n\n        if rustls_pemfile::read_all(Cursor::new(cert_str.as_bytes()).by_ref()).count() != 1 {\n            return Err(AnkiError::InvalidCertificateFormat);\n        }\n\n        if let Ok(certificate) = Certificate::from_pem(cert_str.as_bytes()) {\n            if let Ok(new_client) = Client::builder()\n                .use_rustls_tls()\n                .add_root_certificate(certificate)\n                .http1_only()\n                .build()\n            {\n                let _ = web_client.insert(new_client);\n                return Ok(());\n            }\n        }\n\n        Err(AnkiError::InvalidCertificateFormat)\n    }\n\n    fn web_client(&self) -> Client {\n        // currently limited to http1, as nginx doesn't support http2 proxies\n        let mut web_client = self.web_client.lock().unwrap();\n\n        web_client\n            .get_or_insert_with(|| Client::builder().http1_only().build().unwrap())\n            .clone()\n    }\n\n    fn db_command(&self, input: &[u8]) -> Result<Vec<u8>> {\n        self.with_col(|col| db_command_bytes(col, input))\n    }\n\n    /// Useful for operations that function with a closed collection, such as\n    /// a colpkg import. For collection operations, you can use\n    /// [Collection::new_progress_handler] instead.\n    pub(crate) fn new_progress_handler<P: Into<Progress> + Default + Clone>(\n        &self,\n    ) -> ThrottlingProgressHandler<P> {\n        ThrottlingProgressHandler::new(self.progress_state.clone())\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/ops.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::ops::OpChanges;\nuse crate::prelude::*;\nuse crate::undo::UndoOutput;\nuse crate::undo::UndoStatus;\n\nimpl From<OpChanges> for anki_proto::collection::OpChanges {\n    fn from(c: OpChanges) -> Self {\n        anki_proto::collection::OpChanges {\n            card: c.changes.card,\n            note: c.changes.note,\n            deck: c.changes.deck,\n            tag: c.changes.tag,\n            notetype: c.changes.notetype,\n            config: c.changes.config,\n            deck_config: c.changes.deck_config,\n            mtime: c.changes.mtime,\n            browser_table: c.requires_browser_table_redraw(),\n            browser_sidebar: c.requires_browser_sidebar_redraw(),\n            note_text: c.requires_note_text_redraw(),\n            study_queues: c.requires_study_queue_rebuild(),\n        }\n    }\n}\n\nimpl UndoStatus {\n    pub(crate) fn into_protobuf(self, tr: &I18n) -> anki_proto::collection::UndoStatus {\n        anki_proto::collection::UndoStatus {\n            undo: self.undo.map(|op| op.describe(tr)).unwrap_or_default(),\n            redo: self.redo.map(|op| op.describe(tr)).unwrap_or_default(),\n            last_step: self.last_step as u32,\n        }\n    }\n}\n\nimpl From<OpOutput<()>> for anki_proto::collection::OpChanges {\n    fn from(o: OpOutput<()>) -> Self {\n        o.changes.into()\n    }\n}\n\nimpl From<OpOutput<usize>> for anki_proto::collection::OpChangesWithCount {\n    fn from(out: OpOutput<usize>) -> Self {\n        anki_proto::collection::OpChangesWithCount {\n            count: out.output as u32,\n            changes: Some(out.changes.into()),\n        }\n    }\n}\n\nimpl From<OpOutput<i64>> for anki_proto::collection::OpChangesWithId {\n    fn from(out: OpOutput<i64>) -> Self {\n        anki_proto::collection::OpChangesWithId {\n            id: out.output,\n            changes: Some(out.changes.into()),\n        }\n    }\n}\n\nimpl OpOutput<UndoOutput> {\n    pub(crate) fn into_protobuf(self, tr: &I18n) -> anki_proto::collection::OpChangesAfterUndo {\n        anki_proto::collection::OpChangesAfterUndo {\n            changes: Some(self.changes.into()),\n            operation: self.output.undone_op.describe(tr),\n            reverted_to_timestamp: self.output.reverted_to.0,\n            new_status: Some(self.output.new_undo_status.into_protobuf(tr)),\n            counter: self.output.counter as u32,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/backend/sync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::sync::sync_status_response::Required;\nuse anki_proto::sync::MediaSyncStatusResponse;\nuse anki_proto::sync::SyncStatusResponse;\nuse futures::future::AbortHandle;\nuse futures::future::AbortRegistration;\nuse futures::future::Abortable;\nuse reqwest::Url;\n\nuse super::Backend;\nuse crate::prelude::*;\nuse crate::services::BackendCollectionService;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::SyncActionRequired;\nuse crate::sync::collection::normal::SyncOutput;\nuse crate::sync::collection::progress::sync_abort;\nuse crate::sync::collection::status::online_sync_status_check;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::sync_login;\nuse crate::sync::login::SyncAuth;\n\n#[derive(Default)]\npub(super) struct SyncState {\n    remote_sync_status: RemoteSyncStatus,\n    media_sync_abort: Option<AbortHandle>,\n}\n\n#[derive(Default, Debug)]\npub(super) struct RemoteSyncStatus {\n    pub last_check: TimestampSecs,\n    pub last_response: Required,\n}\n\nimpl RemoteSyncStatus {\n    pub(super) fn update(&mut self, required: Required) {\n        self.last_check = TimestampSecs::now();\n        self.last_response = required\n    }\n}\n\nimpl From<SyncOutput> for anki_proto::sync::SyncCollectionResponse {\n    fn from(o: SyncOutput) -> Self {\n        anki_proto::sync::SyncCollectionResponse {\n            host_number: o.host_number,\n            server_message: o.server_message,\n            new_endpoint: o.new_endpoint,\n            required: match o.required {\n                SyncActionRequired::NoChanges => {\n                    anki_proto::sync::sync_collection_response::ChangesRequired::NoChanges as i32\n                }\n                SyncActionRequired::FullSyncRequired {\n                    upload_ok,\n                    download_ok,\n                } => {\n                    if !upload_ok {\n                        anki_proto::sync::sync_collection_response::ChangesRequired::FullDownload\n                            as i32\n                    } else if !download_ok {\n                        anki_proto::sync::sync_collection_response::ChangesRequired::FullUpload\n                            as i32\n                    } else {\n                        anki_proto::sync::sync_collection_response::ChangesRequired::FullSync as i32\n                    }\n                }\n                SyncActionRequired::NormalSyncRequired => {\n                    anki_proto::sync::sync_collection_response::ChangesRequired::NormalSync as i32\n                }\n            },\n            server_media_usn: o.server_media_usn.0,\n        }\n    }\n}\n\nimpl TryFrom<anki_proto::sync::SyncAuth> for SyncAuth {\n    type Error = AnkiError;\n\n    fn try_from(value: anki_proto::sync::SyncAuth) -> std::result::Result<Self, Self::Error> {\n        Ok(SyncAuth {\n            hkey: value.hkey,\n            endpoint: value\n                .endpoint\n                .map(|v| {\n                    Url::try_from(v.as_str())\n                        // Without the next line, incomplete URLs like computer.local without the http://\n                        // are detected but URLs like computer.local:8000 are not.\n                        // By calling join() now, these URLs are detected too and later code that\n                        // uses and unwraps the result of join() doesn't panic\n                        .and_then(|x| x.join(\"./\"))\n                        .or_invalid(\"Invalid sync server specified. Please check the preferences.\")\n                })\n                .transpose()?,\n            io_timeout_secs: value.io_timeout_secs,\n        })\n    }\n}\n\nimpl crate::services::BackendSyncService for Backend {\n    fn sync_media(&self, input: anki_proto::sync::SyncAuth) -> Result<()> {\n        let auth = input.try_into()?;\n        self.sync_media_in_background(auth, None)\n    }\n\n    fn media_sync_status(&self) -> Result<MediaSyncStatusResponse> {\n        self.get_media_sync_status()\n    }\n\n    fn abort_sync(&self) -> Result<()> {\n        if let Some(handle) = self.sync_abort.lock().unwrap().take() {\n            handle.abort();\n        }\n        Ok(())\n    }\n\n    /// Abort the media sync. Does not wait for completion.\n    fn abort_media_sync(&self) -> Result<()> {\n        let guard = self.state.lock().unwrap();\n        if let Some(handle) = &guard.sync.media_sync_abort {\n            handle.abort();\n        }\n        Ok(())\n    }\n\n    fn sync_login(\n        &self,\n        input: anki_proto::sync::SyncLoginRequest,\n    ) -> Result<anki_proto::sync::SyncAuth> {\n        self.sync_login_inner(input)\n    }\n\n    fn sync_status(\n        &self,\n        input: anki_proto::sync::SyncAuth,\n    ) -> Result<anki_proto::sync::SyncStatusResponse> {\n        self.sync_status_inner(input)\n    }\n\n    fn sync_collection(\n        &self,\n        input: anki_proto::sync::SyncCollectionRequest,\n    ) -> Result<anki_proto::sync::SyncCollectionResponse> {\n        self.sync_collection_inner(input)\n    }\n\n    fn full_upload_or_download(\n        &self,\n        input: anki_proto::sync::FullUploadOrDownloadRequest,\n    ) -> Result<()> {\n        self.full_sync_inner(\n            input.auth.or_invalid(\"missing auth\")?,\n            input.server_usn.map(Usn),\n            input.upload,\n        )?;\n        Ok(())\n    }\n\n    fn set_custom_certificate(\n        &self,\n        _input: anki_proto::generic::String,\n    ) -> Result<anki_proto::generic::Bool> {\n        #[cfg(feature = \"rustls\")]\n        return Ok(self.set_custom_certificate_inner(_input.val).is_ok().into());\n        #[cfg(not(feature = \"rustls\"))]\n        return Ok(false.into());\n    }\n}\n\nimpl Backend {\n    /// Return a handle for regular (non-media) syncing.\n    #[allow(clippy::type_complexity)]\n    fn sync_abort_handle(\n        &self,\n    ) -> Result<(\n        scopeguard::ScopeGuard<Backend, impl FnOnce(Backend)>,\n        AbortRegistration,\n    )> {\n        let (abort_handle, abort_reg) = AbortHandle::new_pair();\n        // Register the new abort_handle.\n        self.sync_abort.lock().unwrap().replace(abort_handle);\n        // Clear the abort handle after the caller is done and drops the guard.\n        let guard = scopeguard::guard(self.clone(), |backend| {\n            backend.sync_abort.lock().unwrap().take();\n        });\n        Ok((guard, abort_reg))\n    }\n\n    pub(super) fn sync_media_in_background(\n        &self,\n        auth: SyncAuth,\n        server_usn: Option<Usn>,\n    ) -> Result<()> {\n        let mut task = self.media_sync_task.lock().unwrap();\n        if let Some(handle) = &*task {\n            if !handle.is_finished() {\n                // already running\n                return Ok(());\n            } else {\n                // clean up\n                task.take();\n            }\n        }\n        let backend = self.clone();\n        *task = Some(std::thread::spawn(move || {\n            backend.sync_media_blocking(auth, server_usn)\n        }));\n        Ok(())\n    }\n\n    /// True if active. Will throw if terminated with error.\n    fn get_media_sync_status(&self) -> Result<MediaSyncStatusResponse> {\n        let mut task = self.media_sync_task.lock().unwrap();\n        let active = if let Some(handle) = &*task {\n            if !handle.is_finished() {\n                true\n            } else {\n                match task.take().unwrap().join() {\n                    Ok(inner_result) => inner_result?,\n                    Err(panic) => invalid_input!(\"{:?}\", panic),\n                };\n                false\n            }\n        } else {\n            false\n        };\n        let progress = self.latest_progress()?;\n        let progress = if let Some(anki_proto::collection::progress::Value::MediaSync(progress)) =\n            progress.value\n        {\n            Some(progress)\n        } else {\n            None\n        };\n        Ok(MediaSyncStatusResponse { active, progress })\n    }\n\n    pub(super) fn sync_media_blocking(\n        &self,\n        auth: SyncAuth,\n        server_usn: Option<Usn>,\n    ) -> Result<()> {\n        // abort handle\n        let (abort_handle, abort_reg) = AbortHandle::new_pair();\n        self.state.lock().unwrap().sync.media_sync_abort = Some(abort_handle);\n\n        // start the sync\n        let (mgr, progress) = {\n            let mut col = self.col.lock().unwrap();\n            let col = col.as_mut().unwrap();\n            (col.media()?, col.new_progress_handler())\n        };\n        let rt = self.runtime_handle();\n        let sync_fut = mgr.sync_media(progress, auth, self.web_client().clone(), server_usn);\n        let abortable_sync = Abortable::new(sync_fut, abort_reg);\n        let result = rt.block_on(abortable_sync);\n\n        // clean up the handle\n        self.state.lock().unwrap().sync.media_sync_abort.take();\n\n        // return result\n        match result {\n            Ok(sync_result) => sync_result,\n            Err(_) => {\n                // aborted sync\n                Err(AnkiError::Interrupted)\n            }\n        }\n    }\n\n    /// Abort the media sync. Won't return until aborted.\n    pub(super) fn abort_media_sync_and_wait(&self) {\n        let guard = self.state.lock().unwrap();\n        if let Some(handle) = &guard.sync.media_sync_abort {\n            handle.abort();\n            self.progress_state.lock().unwrap().want_abort = true;\n        }\n        drop(guard);\n\n        // block until it aborts\n\n        while self.state.lock().unwrap().sync.media_sync_abort.is_some() {\n            std::thread::sleep(std::time::Duration::from_millis(100));\n            self.progress_state.lock().unwrap().want_abort = true;\n        }\n    }\n\n    pub(super) fn sync_login_inner(\n        &self,\n        input: anki_proto::sync::SyncLoginRequest,\n    ) -> Result<anki_proto::sync::SyncAuth> {\n        let (_guard, abort_reg) = self.sync_abort_handle()?;\n\n        let rt = self.runtime_handle();\n        let sync_fut = sync_login(\n            input.username,\n            input.password,\n            input.endpoint.clone(),\n            self.web_client(),\n        );\n        let abortable_sync = Abortable::new(sync_fut, abort_reg);\n        let ret = match rt.block_on(abortable_sync) {\n            Ok(sync_result) => sync_result,\n            Err(_) => Err(AnkiError::Interrupted),\n        };\n        ret.map(|a| anki_proto::sync::SyncAuth {\n            hkey: a.hkey,\n            endpoint: input.endpoint,\n            io_timeout_secs: None,\n        })\n    }\n\n    pub(super) fn sync_status_inner(\n        &self,\n        input: anki_proto::sync::SyncAuth,\n    ) -> Result<anki_proto::sync::SyncStatusResponse> {\n        // any local changes mean we can skip the network round-trip\n        let req = self.with_col(|col| col.sync_status_offline())?;\n        if req != Required::NoChanges {\n            return Ok(status_response_from_required(req));\n        }\n\n        // return cached server response if only a short time has elapsed\n        {\n            let guard = self.state.lock().unwrap();\n            if guard.sync.remote_sync_status.last_check.elapsed_secs() < 300 {\n                return Ok(status_response_from_required(\n                    guard.sync.remote_sync_status.last_response,\n                ));\n            }\n        }\n\n        // fetch and cache result\n        let auth = input.try_into()?;\n        let rt = self.runtime_handle();\n        let time_at_check_begin = TimestampSecs::now();\n        let local = self.with_col(|col| col.sync_meta())?;\n        let mut client = HttpSyncClient::new(auth, self.web_client());\n        let state = rt.block_on(online_sync_status_check(local, &mut client))?;\n        {\n            let mut guard = self.state.lock().unwrap();\n            // On startup, the sync status check will block on network access, and then\n            // automatic syncing begins, taking hold of the mutex. By the time\n            // we reach here, our network status may be out of date,\n            // so we discard it if stale.\n            if guard.sync.remote_sync_status.last_check < time_at_check_begin {\n                guard.sync.remote_sync_status.last_check = time_at_check_begin;\n                guard.sync.remote_sync_status.last_response = state.required.into();\n            }\n        }\n\n        Ok(state.into())\n    }\n\n    pub(super) fn sync_collection_inner(\n        &self,\n        input: anki_proto::sync::SyncCollectionRequest,\n    ) -> Result<anki_proto::sync::SyncCollectionResponse> {\n        let auth: SyncAuth = input.auth.or_invalid(\"missing auth\")?.try_into()?;\n        let (_guard, abort_reg) = self.sync_abort_handle()?;\n\n        let rt = self.runtime_handle();\n        let client = self.web_client();\n        let auth2 = auth.clone();\n\n        let ret = self.with_col(|col| {\n            let sync_fut = col.normal_sync(auth.clone(), client.clone());\n            let abortable_sync = Abortable::new(sync_fut, abort_reg);\n\n            match rt.block_on(abortable_sync) {\n                Ok(sync_result) => sync_result,\n                Err(_) => {\n                    // if the user aborted, we'll need to clean up the transaction\n                    col.storage.rollback_trx()?;\n                    // and tell AnkiWeb to clean up\n                    let _handle = std::thread::spawn(move || {\n                        let _ = rt.block_on(sync_abort(auth, client));\n                    });\n\n                    Err(AnkiError::Interrupted)\n                }\n            }\n        });\n\n        let output: SyncOutput = ret?;\n\n        if input.sync_media\n            && !matches!(output.required, SyncActionRequired::FullSyncRequired { .. })\n        {\n            self.sync_media_in_background(auth2, Some(output.server_media_usn))?;\n        }\n\n        self.state\n            .lock()\n            .unwrap()\n            .sync\n            .remote_sync_status\n            .update(output.required.into());\n        Ok(output.into())\n    }\n\n    pub(super) fn full_sync_inner(\n        &self,\n        input: anki_proto::sync::SyncAuth,\n        server_usn: Option<Usn>,\n        upload: bool,\n    ) -> Result<()> {\n        let auth: SyncAuth = input.try_into()?;\n        let auth2 = auth.clone();\n        self.abort_media_sync_and_wait();\n\n        let rt = self.runtime_handle();\n\n        let mut col = self.col.lock().unwrap();\n        if col.is_none() {\n            return Err(AnkiError::CollectionNotOpen);\n        }\n\n        let col_inner = col.take().unwrap();\n\n        let (_guard, abort_reg) = self.sync_abort_handle()?;\n\n        let mut builder = col_inner.as_builder();\n\n        let result = if upload {\n            let sync_fut = col_inner.full_upload(auth, self.web_client().clone());\n            let abortable_sync = Abortable::new(sync_fut, abort_reg);\n            rt.block_on(abortable_sync)\n        } else {\n            let sync_fut = col_inner.full_download(auth, self.web_client().clone());\n            let abortable_sync = Abortable::new(sync_fut, abort_reg);\n            rt.block_on(abortable_sync)\n        };\n\n        // ensure re-opened regardless of outcome\n        col.replace(builder.build()?);\n\n        let result = match result {\n            Ok(sync_result) => {\n                if sync_result.is_ok() {\n                    self.state\n                        .lock()\n                        .unwrap()\n                        .sync\n                        .remote_sync_status\n                        .update(Required::NoChanges);\n                }\n                sync_result\n            }\n            Err(_) => Err(AnkiError::Interrupted),\n        };\n\n        if result.is_ok() && server_usn.is_some() {\n            self.sync_media_in_background(auth2, server_usn)?;\n        }\n\n        result\n    }\n}\n\nfn status_response_from_required(required: Required) -> SyncStatusResponse {\n    SyncStatusResponse {\n        required: required.into(),\n        new_endpoint: None,\n    }\n}\n\nimpl From<ClientSyncState> for SyncStatusResponse {\n    fn from(r: ClientSyncState) -> Self {\n        SyncStatusResponse {\n            required: Required::from(r.required).into(),\n            new_endpoint: r.new_endpoint,\n        }\n    }\n}\n\nimpl From<SyncActionRequired> for Required {\n    fn from(r: SyncActionRequired) -> Self {\n        match r {\n            SyncActionRequired::NoChanges => Required::NoChanges,\n            SyncActionRequired::FullSyncRequired { .. } => Required::FullSync,\n            SyncActionRequired::NormalSyncRequired => Required::NormalSync,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/browser_table.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::Arc;\n\nuse fsrs::FSRS;\nuse fsrs::FSRS5_DEFAULT_DECAY;\nuse itertools::Itertools;\nuse strum::Display;\nuse strum::EnumIter;\nuse strum::EnumString;\nuse strum::IntoEnumIterator;\n\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::card_rendering::prettify_av_tags;\nuse crate::notetype::CardTemplate;\nuse crate::notetype::NotetypeKind;\nuse crate::prelude::*;\nuse crate::scheduler::timespan::time_span;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::template::RenderedNode;\nuse crate::text::html_to_text_line;\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy, Display, EnumIter, EnumString)]\n#[strum(serialize_all = \"camelCase\")]\n#[derive(Default)]\npub enum Column {\n    #[strum(serialize = \"\")]\n    #[default]\n    Custom,\n    Answer,\n    CardMod,\n    #[strum(serialize = \"template\")]\n    Cards,\n    Deck,\n    #[strum(serialize = \"cardDue\")]\n    Due,\n    #[strum(serialize = \"cardEase\")]\n    Ease,\n    #[strum(serialize = \"cardLapses\")]\n    Lapses,\n    #[strum(serialize = \"cardIvl\")]\n    Interval,\n    #[strum(serialize = \"noteCrt\")]\n    NoteCreation,\n    NoteMod,\n    #[strum(serialize = \"note\")]\n    Notetype,\n    OriginalPosition,\n    Question,\n    #[strum(serialize = \"cardReps\")]\n    Reps,\n    #[strum(serialize = \"noteFld\")]\n    SortField,\n    #[strum(serialize = \"noteTags\")]\n    Tags,\n    Stability,\n    Difficulty,\n    Retrievability,\n}\n\nstruct RowContext {\n    notes_mode: bool,\n    cards: Vec<Card>,\n    note: Note,\n    notetype: Arc<Notetype>,\n    deck: Arc<Deck>,\n    original_deck: Option<Arc<Deck>>,\n    tr: I18n,\n    timing: SchedTimingToday,\n    render_context: RenderContext,\n}\n\nenum RenderContext {\n    // The answer string needs the question string, but not the other way around,\n    // so only build the answer string when needed.\n    Ok {\n        question: String,\n        answer_nodes: Vec<RenderedNode>,\n    },\n    Err(String),\n    Unset,\n}\n\nfn card_render_required(columns: &[Column]) -> bool {\n    columns\n        .iter()\n        .any(|c| matches!(c, Column::Question | Column::Answer))\n}\n\nimpl Card {\n    fn is_new_type_or_queue(&self) -> bool {\n        self.queue == CardQueue::New || self.ctype == CardType::New\n    }\n\n    fn is_filtered_deck(&self) -> bool {\n        self.original_deck_id != DeckId(0)\n    }\n\n    /// Returns true if the card can not be due as it's buried or suspended.\n    fn is_undue_queue(&self) -> bool {\n        (self.queue as i8) < 0\n    }\n\n    /// Returns true if the card has a due date in terms of days.\n    fn is_due_in_days(&self) -> bool {\n        self.ctype != CardType::New && self.original_or_current_due() <= 365_000 // keep consistent with SQL\n            || matches!(self.queue, CardQueue::DayLearn | CardQueue::Review)\n            || (self.ctype == CardType::Review && self.is_undue_queue())\n    }\n\n    /// Returns the card's due date as a timestamp if it has one.\n    fn due_time(&self, timing: &SchedTimingToday) -> Option<TimestampSecs> {\n        if self.queue == CardQueue::Learn {\n            Some(TimestampSecs(self.original_or_current_due() as i64))\n        } else if self.is_due_in_days() {\n            Some(\n                TimestampSecs::now().adding_secs(\n                    (self.original_or_current_due() as i64 - timing.days_elapsed as i64)\n                        .saturating_mul(86400),\n                ),\n            )\n        } else {\n            None\n        }\n    }\n\n    /// If last_review_date isn't stored in the card, this uses card.due and\n    /// card.ivl to infer the elapsed time, which won't be accurate if\n    /// 'set due date' or an add-on has changed the due date.\n    pub(crate) fn seconds_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> {\n        if let Some(last_review_time) = self.last_review_time {\n            Some(timing.now.elapsed_secs_since(last_review_time) as u32)\n        } else if self.is_due_in_days() {\n            self.due_time(timing).map(|due| {\n                (due.adding_secs(-86_400 * self.interval as i64)\n                    .elapsed_secs()) as u32\n            })\n        } else {\n            let last_review_time = TimestampSecs(self.original_or_current_due() as i64);\n            Some(timing.now.elapsed_secs_since(last_review_time) as u32)\n        }\n    }\n}\n\nimpl Note {\n    fn is_marked(&self) -> bool {\n        self.tags\n            .iter()\n            .any(|tag| tag.eq_ignore_ascii_case(\"marked\"))\n    }\n}\n\nimpl Column {\n    pub fn cards_mode_label(self, tr: &I18n) -> String {\n        match self {\n            Self::Answer => tr.browsing_answer(),\n            Self::CardMod => tr.search_card_modified(),\n            Self::Cards => tr.card_stats_card_template(),\n            Self::Deck => tr.decks_deck(),\n            Self::Due => tr.statistics_due_date(),\n            Self::Custom => tr.browsing_addon(),\n            Self::Ease => tr.browsing_ease(),\n            Self::Interval => tr.browsing_interval(),\n            Self::Lapses => tr.scheduling_lapses(),\n            Self::NoteCreation => tr.browsing_created(),\n            Self::NoteMod => tr.search_note_modified(),\n            Self::Notetype => tr.card_stats_note_type(),\n            Self::OriginalPosition => tr.card_stats_new_card_position(),\n            Self::Question => tr.browsing_question(),\n            Self::Reps => tr.scheduling_reviews(),\n            Self::SortField => tr.browsing_sort_field(),\n            Self::Tags => tr.editing_tags(),\n            Self::Stability => tr.card_stats_fsrs_stability(),\n            Self::Difficulty => tr.card_stats_fsrs_difficulty(),\n            Self::Retrievability => tr.card_stats_fsrs_retrievability(),\n        }\n        .into()\n    }\n\n    pub fn notes_mode_label(self, tr: &I18n) -> String {\n        match self {\n            Self::Cards => tr.editing_cards(),\n            Self::Ease => tr.browsing_average_ease(),\n            Self::Interval => tr.browsing_average_interval(),\n            _ => return self.cards_mode_label(tr),\n        }\n        .into()\n    }\n\n    pub fn cards_mode_tooltip(self, tr: &I18n) -> String {\n        match self {\n            Self::Answer => tr.browsing_tooltip_answer(),\n            Self::CardMod => tr.browsing_tooltip_card_modified(),\n            Self::Cards => tr.browsing_tooltip_card(),\n            Self::NoteMod => tr.browsing_tooltip_note_modified(),\n            Self::Notetype => tr.browsing_tooltip_notetype(),\n            Self::Question => tr.browsing_tooltip_question(),\n            _ => \"\".into(),\n        }\n        .into()\n    }\n\n    pub fn notes_mode_tooltip(self, tr: &I18n) -> String {\n        match self {\n            Self::Cards => tr.browsing_tooltip_cards(),\n            _ => return self.cards_mode_label(tr),\n        }\n        .into()\n    }\n\n    pub fn default_cards_order(self) -> anki_proto::search::browser_columns::Sorting {\n        self.default_order(false)\n    }\n\n    pub fn default_notes_order(self) -> anki_proto::search::browser_columns::Sorting {\n        self.default_order(true)\n    }\n\n    fn default_order(self, notes: bool) -> anki_proto::search::browser_columns::Sorting {\n        use anki_proto::search::browser_columns::Sorting;\n        match self {\n            Column::Question | Column::Answer | Column::Custom => Sorting::None,\n            Column::SortField | Column::Tags | Column::Notetype | Column::Deck => {\n                Sorting::Ascending\n            }\n            Column::CardMod\n            | Column::Cards\n            | Column::Due\n            | Column::Ease\n            | Column::Lapses\n            | Column::Interval\n            | Column::NoteCreation\n            | Column::NoteMod\n            | Column::OriginalPosition\n            | Column::Reps => Sorting::Descending,\n            Column::Stability | Column::Difficulty | Column::Retrievability => {\n                if notes {\n                    Sorting::None\n                } else {\n                    Sorting::Descending\n                }\n            }\n        }\n    }\n\n    pub fn uses_cell_font(self) -> bool {\n        matches!(self, Self::Question | Self::Answer | Self::SortField)\n    }\n\n    pub fn alignment(self) -> anki_proto::search::browser_columns::Alignment {\n        use anki_proto::search::browser_columns::Alignment;\n        match self {\n            Self::Question\n            | Self::Answer\n            | Self::Cards\n            | Self::Deck\n            | Self::SortField\n            | Self::Notetype\n            | Self::Tags => Alignment::Start,\n            _ => Alignment::Center,\n        }\n    }\n}\n\nimpl Collection {\n    pub fn all_browser_columns(&self) -> anki_proto::search::BrowserColumns {\n        let mut columns: Vec<anki_proto::search::browser_columns::Column> = Column::iter()\n            .filter(|&c| c != Column::Custom)\n            .map(|c| c.to_pb_column(&self.tr))\n            .collect();\n        columns.sort_by(|c1, c2| c1.cards_mode_label.cmp(&c2.cards_mode_label));\n        anki_proto::search::BrowserColumns { columns }\n    }\n\n    pub fn browser_row_for_id(&mut self, id: i64) -> Result<anki_proto::search::BrowserRow> {\n        let notes_mode = self.get_config_bool(BoolKey::BrowserTableShowNotesMode);\n        let columns = Arc::clone(\n            self.state\n                .active_browser_columns\n                .as_ref()\n                .or_invalid(\"Active browser columns not set.\")?,\n        );\n        RowContext::new(self, id, notes_mode, card_render_required(&columns))?.browser_row(&columns)\n    }\n\n    fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result<Note> {\n        // todo: After note.sort_field has been modified so it can be displayed in the\n        // browser, we can update note_field_str() and only load the note with\n        // fields if a card render is necessary (see #1082).\n        if true {\n            self.storage.get_note(id)?\n        } else {\n            self.storage.get_note_without_fields(id)?\n        }\n        .or_not_found(id)\n    }\n}\n\nimpl RenderContext {\n    fn new(col: &mut Collection, card: &Card, note: &Note, notetype: &Notetype) -> Self {\n        match notetype\n            .get_template(card.template_idx)\n            .and_then(|template| col.render_card(note, card, notetype, template, true, true))\n        {\n            Ok(render) => RenderContext::Ok {\n                question: rendered_nodes_to_str(&render.qnodes),\n                answer_nodes: render.anodes,\n            },\n            Err(err) => RenderContext::Err(err.message(&col.tr)),\n        }\n    }\n\n    fn side_str(&self, is_answer: bool) -> String {\n        let back;\n        let html = match self {\n            Self::Ok {\n                question,\n                answer_nodes,\n            } => {\n                if is_answer {\n                    back = rendered_nodes_to_str(answer_nodes);\n                    back.strip_prefix(question).unwrap_or(&back)\n                } else {\n                    question\n                }\n            }\n            Self::Err(err) => err,\n            Self::Unset => \"Invalid input: RenderContext unset\",\n        };\n        html_to_text_line(html, true).into()\n    }\n}\n\nfn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String {\n    let txt = nodes\n        .iter()\n        .map(|node| match node {\n            RenderedNode::Text { text } => text,\n            RenderedNode::Replacement { current_text, .. } => current_text,\n        })\n        .join(\"\");\n    prettify_av_tags(txt)\n}\n\nimpl RowContext {\n    fn new(\n        col: &mut Collection,\n        id: i64,\n        notes_mode: bool,\n        with_card_render: bool,\n    ) -> Result<Self> {\n        let cards;\n        let note;\n        if notes_mode {\n            note = col\n                .get_note_maybe_with_fields(NoteId(id), with_card_render)\n                .map_err(|e| match e {\n                    AnkiError::NotFound { .. } => AnkiError::Deleted,\n                    _ => e,\n                })?;\n            cards = col.storage.all_cards_of_note(note.id)?;\n            if cards.is_empty() {\n                return Err(AnkiError::DatabaseCheckRequired);\n            }\n        } else {\n            cards = vec![col\n                .storage\n                .get_card(CardId(id))?\n                .ok_or(AnkiError::Deleted)?];\n            note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?;\n        }\n        let notetype = col\n            .get_notetype(note.notetype_id)?\n            .or_not_found(note.notetype_id)?;\n        let deck = col\n            .get_deck(cards[0].deck_id)?\n            .or_not_found(cards[0].deck_id)?;\n        let original_deck = if cards[0].original_deck_id.0 != 0 {\n            Some(\n                col.get_deck(cards[0].original_deck_id)?\n                    .or_not_found(cards[0].original_deck_id)?,\n            )\n        } else {\n            None\n        };\n        let timing = col.timing_today()?;\n        let render_context = if with_card_render {\n            RenderContext::new(col, &cards[0], &note, &notetype)\n        } else {\n            RenderContext::Unset\n        };\n\n        Ok(RowContext {\n            notes_mode,\n            cards,\n            note,\n            notetype,\n            deck,\n            original_deck,\n            tr: col.tr.clone(),\n            timing,\n            render_context,\n        })\n    }\n\n    fn browser_row(&self, columns: &[Column]) -> Result<anki_proto::search::BrowserRow> {\n        Ok(anki_proto::search::BrowserRow {\n            cells: columns\n                .iter()\n                .map(|&column| self.get_cell(column))\n                .collect::<Result<_>>()?,\n            color: self.get_row_color() as i32,\n            font_name: self.get_row_font_name()?,\n            font_size: self.get_row_font_size()?,\n        })\n    }\n\n    fn get_cell(&self, column: Column) -> Result<anki_proto::search::browser_row::Cell> {\n        Ok(anki_proto::search::browser_row::Cell {\n            text: self.get_cell_text(column)?,\n            is_rtl: self.get_is_rtl(column),\n            elide_mode: self.get_elide_mode(column) as i32,\n        })\n    }\n\n    fn get_cell_text(&self, column: Column) -> Result<String> {\n        Ok(match column {\n            Column::Question => self.render_context.side_str(false),\n            Column::Answer => self.render_context.side_str(true),\n            Column::Deck => self.deck_str(),\n            Column::Due => self.due_str(),\n            Column::Ease => self.ease_str(),\n            Column::Interval => self.interval_str(),\n            Column::Lapses => self.cards.iter().map(|c| c.lapses).sum::<u32>().to_string(),\n            Column::CardMod => self.card_mod_str(),\n            Column::Reps => self.cards.iter().map(|c| c.reps).sum::<u32>().to_string(),\n            Column::Cards => self.cards_str()?,\n            Column::NoteCreation => self.note_creation_str(),\n            Column::SortField => self.note_field_str(),\n            Column::NoteMod => self.note.mtime.date_and_time_string(),\n            Column::OriginalPosition => self.card_original_position(),\n            Column::Tags => self.note.tags.join(\" \"),\n            Column::Notetype => self.notetype.name.to_owned(),\n            Column::Stability => self.fsrs_stability_str(),\n            Column::Difficulty => self.fsrs_difficulty_str(),\n            Column::Retrievability => self.fsrs_retrievability_str(),\n            Column::Custom => \"\".to_string(),\n        })\n    }\n\n    fn card_original_position(&self) -> String {\n        let card = &self.cards[0];\n        if let Some(pos) = &card.original_position {\n            pos.to_string()\n        } else if card.ctype == CardType::New {\n            card.due.to_string()\n        } else {\n            String::new()\n        }\n    }\n\n    fn note_creation_str(&self) -> String {\n        TimestampMillis(self.note.id.into())\n            .as_secs()\n            .date_and_time_string()\n    }\n\n    fn note_field_str(&self) -> String {\n        let index = self.notetype.config.sort_field_idx as usize;\n        html_to_text_line(&self.note.fields()[index], true).into()\n    }\n\n    fn get_is_rtl(&self, column: Column) -> bool {\n        match column {\n            Column::SortField => {\n                let index = self.notetype.config.sort_field_idx as usize;\n                self.notetype.fields[index].config.rtl\n            }\n            _ => false,\n        }\n    }\n\n    fn get_elide_mode(\n        &self,\n        column: Column,\n    ) -> anki_proto::search::browser_row::cell::TextElideMode {\n        use anki_proto::search::browser_row::cell::TextElideMode;\n        match column {\n            Column::Deck => TextElideMode::ElideMiddle,\n            _ => TextElideMode::ElideRight,\n        }\n    }\n\n    fn template(&self) -> Result<&CardTemplate> {\n        self.notetype.get_template(self.cards[0].template_idx)\n    }\n\n    fn due_str(&self) -> String {\n        if self.notes_mode {\n            self.note_due_str()\n        } else {\n            self.card_due_str()\n        }\n    }\n\n    fn card_due_str(&self) -> String {\n        let due = if self.cards[0].is_filtered_deck() {\n            self.tr.browsing_filtered()\n        } else if self.cards[0].is_new_type_or_queue() {\n            self.tr.statistics_due_for_new_card(self.cards[0].due)\n        } else if let Some(time) = self.cards[0].due_time(&self.timing) {\n            time.date_string().into()\n        } else {\n            return \"\".into();\n        };\n        if self.cards[0].is_undue_queue() {\n            format!(\"({due})\")\n        } else {\n            due.into()\n        }\n    }\n\n    fn fsrs_stability_str(&self) -> String {\n        self.cards[0]\n            .memory_state\n            .as_ref()\n            .map(|s| time_span(s.stability * 86400.0, &self.tr, false))\n            .unwrap_or_default()\n    }\n\n    fn fsrs_difficulty_str(&self) -> String {\n        self.cards[0]\n            .memory_state\n            .as_ref()\n            .map(|s| format!(\"{:.0}%\", s.difficulty() * 100.0))\n            .unwrap_or_default()\n    }\n\n    fn fsrs_retrievability_str(&self) -> String {\n        self.cards[0]\n            .memory_state\n            .as_ref()\n            .zip(self.cards[0].seconds_since_last_review(&self.timing))\n            .zip(Some(self.cards[0].decay.unwrap_or(FSRS5_DEFAULT_DECAY)))\n            .map(|((state, seconds), decay)| {\n                let r = FSRS::new(None).unwrap().current_retrievability_seconds(\n                    (*state).into(),\n                    seconds,\n                    decay,\n                );\n                format!(\"{:.0}%\", r * 100.)\n            })\n            .unwrap_or_default()\n    }\n\n    /// Returns the due date of the next due card that is not in a filtered\n    /// deck, new, suspended or buried or the empty string if there is no\n    /// such card.\n    fn note_due_str(&self) -> String {\n        self.cards\n            .iter()\n            .filter(|c| !(c.is_filtered_deck() || c.is_new_type_or_queue() || c.is_undue_queue()))\n            .filter_map(|c| c.due_time(&self.timing))\n            .min()\n            .map(|time| time.date_string())\n            .unwrap_or_else(|| \"\".into())\n    }\n\n    /// Returns the average ease of the non-new cards or a hint if there aren't\n    /// any.\n    fn ease_str(&self) -> String {\n        let eases: Vec<u16> = self\n            .cards\n            .iter()\n            .filter(|c| c.ctype != CardType::New)\n            .map(|c| c.ease_factor)\n            .collect();\n        if eases.is_empty() {\n            self.tr.browsing_new().into()\n        } else {\n            format!(\"{}%\", eases.iter().sum::<u16>() / eases.len() as u16 / 10)\n        }\n    }\n\n    /// Returns the average interval of the review and relearn cards if there\n    /// are any.\n    fn interval_str(&self) -> String {\n        if !self.notes_mode {\n            match self.cards[0].ctype {\n                CardType::New => return self.tr.browsing_new().into(),\n                CardType::Learn => return self.tr.browsing_learning().into(),\n                CardType::Review | CardType::Relearn => (),\n            }\n        }\n        let intervals: Vec<u32> = self\n            .cards\n            .iter()\n            .filter(|c| matches!(c.ctype, CardType::Review | CardType::Relearn))\n            .map(|c| c.interval)\n            .collect();\n        if intervals.is_empty() {\n            \"\".into()\n        } else {\n            time_span(\n                (intervals.iter().sum::<u32>() * 86400 / (intervals.len() as u32)) as f32,\n                &self.tr,\n                false,\n            )\n        }\n    }\n\n    fn card_mod_str(&self) -> String {\n        self.cards\n            .iter()\n            .map(|c| c.mtime)\n            .max()\n            .expect(\"cards missing from RowContext\")\n            .date_and_time_string()\n    }\n\n    fn deck_str(&self) -> String {\n        if self.notes_mode {\n            let decks = self.cards.iter().map(|c| c.deck_id).unique().count();\n            if decks > 1 {\n                return format!(\"({decks})\");\n            }\n        }\n        let deck_name = self.deck.human_name();\n        if let Some(original_deck) = &self.original_deck {\n            format!(\"{} ({})\", &deck_name, &original_deck.human_name())\n        } else {\n            deck_name\n        }\n    }\n\n    fn cards_str(&self) -> Result<String> {\n        Ok(if self.notes_mode {\n            self.cards.len().to_string()\n        } else {\n            let name = &self.template()?.name;\n            match self.notetype.config.kind() {\n                NotetypeKind::Normal => name.to_owned(),\n                NotetypeKind::Cloze => format!(\"{} {}\", name, self.cards[0].template_idx + 1),\n            }\n        })\n    }\n\n    fn get_row_font_name(&self) -> Result<String> {\n        Ok(self.template()?.config.browser_font_name.to_owned())\n    }\n\n    fn get_row_font_size(&self) -> Result<u32> {\n        Ok(self.template()?.config.browser_font_size)\n    }\n\n    fn get_row_color(&self) -> anki_proto::search::browser_row::Color {\n        use anki_proto::search::browser_row::Color;\n        if self.notes_mode {\n            if self.note.is_marked() {\n                Color::Marked\n            } else {\n                Color::Default\n            }\n        } else {\n            match self.cards[0].flags {\n                1 => Color::FlagRed,\n                2 => Color::FlagOrange,\n                3 => Color::FlagGreen,\n                4 => Color::FlagBlue,\n                5 => Color::FlagPink,\n                6 => Color::FlagTurquoise,\n                7 => Color::FlagPurple,\n                _ => {\n                    if self.note.is_marked() {\n                        Color::Marked\n                    } else {\n                        match self.cards[0].queue {\n                            CardQueue::Suspended => Color::Suspended,\n                            CardQueue::UserBuried | CardQueue::SchedBuried => Color::Buried,\n                            _ => Color::Default,\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/card/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod service;\npub(crate) mod undo;\n\nuse std::collections::hash_map::Entry;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse fsrs::MemoryState;\nuse num_enum::TryFromPrimitive;\nuse serde_repr::Deserialize_repr;\nuse serde_repr::Serialize_repr;\n\nuse crate::collection::Collection;\nuse crate::config::SchedulerVersion;\nuse crate::deckconfig::DeckConfig;\nuse crate::decks::DeckId;\nuse crate::define_newtype;\nuse crate::error::AnkiError;\nuse crate::error::FilteredDeckError;\nuse crate::error::Result;\nuse crate::notes::NoteId;\nuse crate::ops::StateChanges;\nuse crate::prelude::*;\nuse crate::timestamp::TimestampSecs;\nuse crate::types::Usn;\n\ndefine_newtype!(CardId, i64);\n\nimpl CardId {\n    pub fn as_secs(self) -> TimestampSecs {\n        TimestampSecs(self.0 / 1000)\n    }\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)]\n#[repr(u8)]\npub enum CardType {\n    New = 0,\n    Learn = 1,\n    Review = 2,\n    Relearn = 3,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)]\n#[repr(i8)]\npub enum CardQueue {\n    /// due is the order cards are shown in\n    New = 0,\n    /// due is a unix timestamp\n    Learn = 1,\n    /// due is days since creation date\n    Review = 2,\n    DayLearn = 3,\n    /// due is a unix timestamp.\n    /// preview cards only placed here when failed.\n    PreviewRepeat = 4,\n    /// cards are not due in these states\n    Suspended = -1,\n    SchedBuried = -2,\n    UserBuried = -3,\n}\n\n/// Which of the blue/red/green numbers this card maps to.\npub enum CardQueueNumber {\n    New,\n    Learning,\n    Review,\n    /// Suspended/buried cards should not be included.\n    Invalid,\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub struct Card {\n    pub(crate) id: CardId,\n    pub(crate) note_id: NoteId,\n    pub(crate) deck_id: DeckId,\n    pub(crate) template_idx: u16,\n    pub(crate) mtime: TimestampSecs,\n    pub(crate) usn: Usn,\n    pub(crate) ctype: CardType,\n    pub(crate) queue: CardQueue,\n    pub(crate) due: i32,\n    pub(crate) interval: u32,\n    pub(crate) ease_factor: u16,\n    pub(crate) reps: u32,\n    pub(crate) lapses: u32,\n    pub(crate) remaining_steps: u32,\n    pub(crate) original_due: i32,\n    pub(crate) original_deck_id: DeckId,\n    pub(crate) flags: u8,\n    /// The position in the new queue before leaving it.\n    pub(crate) original_position: Option<u32>,\n    pub(crate) memory_state: Option<FsrsMemoryState>,\n    pub(crate) desired_retention: Option<f32>,\n    pub(crate) decay: Option<f32>,\n    pub(crate) last_review_time: Option<TimestampSecs>,\n    /// JSON object or empty; exposed through the reviewer for persisting custom\n    /// state\n    pub(crate) custom_data: String,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct FsrsMemoryState {\n    /// The expected memory stability, in days.\n    pub stability: f32,\n    /// A number in the range 1.0-10.0. Use difficulty() for a normalized\n    /// number.\n    pub difficulty: f32,\n}\n\nimpl FsrsMemoryState {\n    /// Returns the difficulty normalized to a 0.0-1.0 range.\n    pub(crate) fn difficulty(&self) -> f32 {\n        (self.difficulty - 1.0) / 9.0\n    }\n\n    /// Returns the difficulty normalized to a 0.1-1.1 range,\n    /// which is used in revlog entries.\n    pub(crate) fn difficulty_shifted(&self) -> f32 {\n        self.difficulty() + 0.1\n    }\n}\n\nimpl Default for Card {\n    fn default() -> Self {\n        Self {\n            id: CardId(0),\n            note_id: NoteId(0),\n            deck_id: DeckId(0),\n            template_idx: 0,\n            mtime: TimestampSecs(0),\n            usn: Usn(0),\n            ctype: CardType::New,\n            queue: CardQueue::New,\n            due: 0,\n            interval: 0,\n            ease_factor: 0,\n            reps: 0,\n            lapses: 0,\n            remaining_steps: 0,\n            original_due: 0,\n            original_deck_id: DeckId(0),\n            flags: 0,\n            original_position: None,\n            memory_state: None,\n            desired_retention: None,\n            decay: None,\n            last_review_time: None,\n            custom_data: String::new(),\n        }\n    }\n}\n\nimpl Card {\n    pub fn id(&self) -> CardId {\n        self.id\n    }\n\n    pub fn note_id(&self) -> NoteId {\n        self.note_id\n    }\n\n    pub fn deck_id(&self) -> DeckId {\n        self.deck_id\n    }\n\n    pub fn template_idx(&self) -> u16 {\n        self.template_idx\n    }\n\n    pub fn queue_number(&self) -> CardQueueNumber {\n        match self.queue {\n            CardQueue::New => CardQueueNumber::New,\n            CardQueue::PreviewRepeat | CardQueue::Learn => CardQueueNumber::Learning,\n            CardQueue::DayLearn | CardQueue::Review => CardQueueNumber::Review,\n            CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => {\n                CardQueueNumber::Invalid\n            }\n        }\n    }\n\n    pub fn set_modified(&mut self, usn: Usn) {\n        self.mtime = TimestampSecs::now();\n        self.usn = usn;\n    }\n\n    pub fn clear_fsrs_data(&mut self) {\n        self.memory_state = None;\n        self.desired_retention = None;\n        self.decay = None;\n    }\n\n    /// Caller must ensure provided deck exists and is not filtered.\n    fn set_deck(&mut self, deck: DeckId) {\n        self.remove_from_filtered_deck_restoring_queue();\n        self.clear_fsrs_data();\n        self.deck_id = deck;\n    }\n\n    /// True if flag changed.\n    fn set_flag(&mut self, flag: u8) -> bool {\n        // The first 3 bits represent one of the 7 supported flags, the rest of\n        // the flag byte is preserved.\n        let updated_flags = (self.flags & !0b111) | flag;\n        if self.flags != updated_flags {\n            self.flags = updated_flags;\n            true\n        } else {\n            false\n        }\n    }\n\n    /// Return the total number of steps left to do, ignoring the\n    /// \"steps today\" number packed into the DB representation.\n    pub fn remaining_steps(&self) -> u32 {\n        self.remaining_steps % 1000\n    }\n\n    /// Return ease factor as a multiplier (eg 2.5)\n    pub fn ease_factor(&self) -> f32 {\n        (self.ease_factor as f32) / 1000.0\n    }\n\n    /// Don't use this in situations where you may be using original_due, since\n    /// it only applies to the current due date. You may want to use\n    /// is_unix_epoch_timestap() instead.\n    pub fn is_intraday_learning(&self) -> bool {\n        matches!(self.queue, CardQueue::Learn | CardQueue::PreviewRepeat)\n    }\n\n    pub fn new(note_id: NoteId, template_idx: u16, deck_id: DeckId, due: i32) -> Self {\n        Card {\n            note_id,\n            deck_id,\n            template_idx,\n            due,\n            ..Default::default()\n        }\n    }\n\n    /// Remaining steps after configured steps have changed, disregarding\n    /// \"remaining today\". [None] if same as before. A step counts as\n    /// remaining if the card has not passed a step with the same or a\n    /// greater delay, but output will be at least 1.\n    fn new_remaining_steps(&self, new_steps: &[f32], old_steps: &[f32]) -> Option<u32> {\n        let remaining = self.remaining_steps();\n\n        let new_remaining = if old_steps.is_empty() {\n            remaining\n        } else {\n            old_steps\n                .len()\n                .checked_sub(remaining as usize + 1)\n                .and_then(|last_index| {\n                    new_steps\n                        .iter()\n                        .rev()\n                        .position(|&step| step <= old_steps[last_index])\n                })\n                // no last delay or last delay is less than all new steps → all steps remain\n                .unwrap_or(new_steps.len())\n                // (re)learning card must have at least 1 step remaining\n                .max(1) as u32\n        };\n\n        (remaining != new_remaining).then_some(new_remaining)\n    }\n\n    /// Supposedly unique across all reviews in the collection.\n    pub fn review_seed(&self) -> u64 {\n        (self.id.0 as u64)\n            .rotate_left(8)\n            .wrapping_add(self.reps as u64)\n    }\n}\n\nimpl Collection {\n    pub(crate) fn update_cards_maybe_undoable(\n        &mut self,\n        cards: Vec<Card>,\n        undoable: bool,\n    ) -> Result<OpOutput<()>> {\n        if undoable {\n            self.transact(Op::UpdateCard, |col| {\n                for mut card in cards {\n                    let existing = col.storage.get_card(card.id)?.or_not_found(card.id)?;\n                    col.update_card_inner(&mut card, existing, col.usn()?)?\n                }\n                Ok(())\n            })\n        } else {\n            self.transact_no_undo(|col| {\n                for mut card in cards {\n                    let existing = col.storage.get_card(card.id)?.or_not_found(card.id)?;\n                    col.update_card_inner(&mut card, existing, col.usn()?)?;\n                }\n                Ok(OpOutput {\n                    output: (),\n                    changes: OpChanges {\n                        op: Op::UpdateCard,\n                        changes: StateChanges {\n                            card: true,\n                            ..Default::default()\n                        },\n                    },\n                })\n            })\n        }\n    }\n\n    #[cfg(test)]\n    pub(crate) fn get_and_update_card<F, T>(&mut self, cid: CardId, func: F) -> Result<Card>\n    where\n        F: FnOnce(&mut Card) -> Result<T>,\n    {\n        let orig = self.storage.get_card(cid)?.or_invalid(\"no such card\")?;\n        let mut card = orig.clone();\n        func(&mut card)?;\n        self.update_card_inner(&mut card, orig, self.usn()?)?;\n        Ok(card)\n    }\n\n    /// Marks the card as modified, then saves it.\n    pub(crate) fn update_card_inner(\n        &mut self,\n        card: &mut Card,\n        original: Card,\n        usn: Usn,\n    ) -> Result<()> {\n        card.set_modified(usn);\n        self.update_card_undoable(card, original)\n    }\n\n    pub(crate) fn add_card(&mut self, card: &mut Card) -> Result<()> {\n        require!(card.id.0 == 0, \"card id already set\");\n        card.mtime = TimestampSecs::now();\n        card.usn = self.usn()?;\n        self.add_card_undoable(card)\n    }\n\n    /// Remove cards and any resulting orphaned notes.\n    /// Expects a transaction.\n    pub(crate) fn remove_cards_and_orphaned_notes(&mut self, cids: &[CardId]) -> Result<usize> {\n        let usn = self.usn()?;\n        let mut nids = HashSet::new();\n        let mut card_count = 0;\n        for cid in cids {\n            if let Some(card) = self.storage.get_card(*cid)? {\n                nids.insert(card.note_id);\n                self.remove_card_and_add_grave_undoable(card, usn)?;\n                card_count += 1;\n            }\n        }\n        for nid in nids {\n            if self.storage.note_is_orphaned(nid)? {\n                self.remove_note_only_undoable(nid, usn)?;\n            }\n        }\n\n        Ok(card_count)\n    }\n\n    pub fn set_deck(&mut self, cards: &[CardId], deck_id: DeckId) -> Result<OpOutput<usize>> {\n        let sched = self.scheduler_version();\n        if sched == SchedulerVersion::V1 {\n            return Err(AnkiError::SchedulerUpgradeRequired);\n        }\n        let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?;\n        let config_id = deck.config_id().ok_or(AnkiError::FilteredDeckError {\n            source: FilteredDeckError::CanNotMoveCardsInto,\n        })?;\n        let config = self.get_deck_config(config_id, true)?.unwrap();\n        let mut steps_adjuster = RemainingStepsAdjuster::new(&config);\n        let usn = self.usn()?;\n        self.transact(Op::SetCardDeck, |col| {\n            let mut count = 0;\n            for mut card in col.all_cards_for_ids(cards, false)? {\n                if card.deck_id == deck_id {\n                    continue;\n                }\n                count += 1;\n                let original = card.clone();\n                steps_adjuster.adjust_remaining_steps(col, &mut card)?;\n                card.set_deck(deck_id);\n                col.update_card_inner(&mut card, original, usn)?;\n            }\n            Ok(count)\n        })\n    }\n\n    pub fn set_card_flag(&mut self, cards: &[CardId], flag: u32) -> Result<OpOutput<usize>> {\n        require!(flag < 8, \"invalid flag\");\n        let flag = flag as u8;\n\n        let usn = self.usn()?;\n        self.transact(Op::SetFlag, |col| {\n            let mut count = 0;\n            for mut card in col.all_cards_for_ids(cards, false)? {\n                let original = card.clone();\n                if card.set_flag(flag) {\n                    // To avoid having to rebuild the study queues, we mark the card as requiring\n                    // a sync, but do not change its modification time.\n                    card.usn = usn;\n                    col.update_card_undoable(&mut card, original)?;\n                    count += 1;\n                }\n            }\n            Ok(count)\n        })\n    }\n\n    /// Get deck config for the given card. If missing, return default values.\n    #[allow(dead_code)]\n    pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result<DeckConfig> {\n        if let Some(deck) = self.get_deck(card.original_or_current_deck_id())? {\n            if let Some(conf_id) = deck.config_id() {\n                return Ok(self.get_deck_config(conf_id, true)?.unwrap());\n            }\n        }\n\n        Ok(DeckConfig::default())\n    }\n\n    /// Adjust the remaining steps of the card according to the steps change.\n    /// Steps must be learning or relearning steps according to the card's type.\n    pub(crate) fn adjust_remaining_steps(\n        &mut self,\n        card: &mut Card,\n        old_steps: &[f32],\n        new_steps: &[f32],\n        usn: Usn,\n    ) -> Result<()> {\n        if let Some(new_remaining) = card.new_remaining_steps(new_steps, old_steps) {\n            let original = card.clone();\n            card.remaining_steps = new_remaining;\n            self.update_card_inner(card, original, usn)\n        } else {\n            Ok(())\n        }\n    }\n}\n\n/// Adjusts the remaining steps of cards after their deck config has changed.\nstruct RemainingStepsAdjuster<'a> {\n    learn_steps: &'a [f32],\n    relearn_steps: &'a [f32],\n    configs: HashMap<DeckId, DeckConfig>,\n}\n\nimpl<'a> RemainingStepsAdjuster<'a> {\n    fn new(new_config: &'a DeckConfig) -> Self {\n        RemainingStepsAdjuster {\n            learn_steps: &new_config.inner.learn_steps,\n            relearn_steps: &new_config.inner.relearn_steps,\n            configs: HashMap::new(),\n        }\n    }\n\n    fn adjust_remaining_steps(&mut self, col: &mut Collection, card: &mut Card) -> Result<()> {\n        if let Some(remaining) = match card.ctype {\n            CardType::Learn => card.new_remaining_steps(\n                self.learn_steps,\n                &self.config_for_card(col, card)?.inner.learn_steps,\n            ),\n            CardType::Relearn => card.new_remaining_steps(\n                self.relearn_steps,\n                &self.config_for_card(col, card)?.inner.relearn_steps,\n            ),\n            _ => None,\n        } {\n            card.remaining_steps = remaining;\n        }\n        Ok(())\n    }\n\n    fn config_for_card(&mut self, col: &mut Collection, card: &Card) -> Result<&mut DeckConfig> {\n        Ok(\n            match self.configs.entry(card.original_or_current_deck_id()) {\n                Entry::Occupied(e) => e.into_mut(),\n                Entry::Vacant(e) => e.insert(col.deck_config_for_card(card)?),\n            },\n        )\n    }\n}\n\nimpl From<FsrsMemoryState> for MemoryState {\n    fn from(value: FsrsMemoryState) -> Self {\n        MemoryState {\n            stability: value.stability,\n            difficulty: value.difficulty,\n        }\n    }\n}\n\nimpl From<MemoryState> for FsrsMemoryState {\n    fn from(value: MemoryState) -> Self {\n        FsrsMemoryState {\n            stability: value.stability,\n            difficulty: value.difficulty,\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::prelude::*;\n    use crate::tests::open_test_collection_with_learning_card;\n    use crate::tests::open_test_collection_with_relearning_card;\n    use crate::tests::DeckAdder;\n\n    #[test]\n    fn should_increase_remaining_learning_steps_if_new_deck_has_more_unpassed_ones() {\n        let mut col = open_test_collection_with_learning_card();\n        let deck = DeckAdder::new(\"target\")\n            .with_config(|config| config.inner.learn_steps.push(100.))\n            .add(&mut col);\n        let card_id = col.get_first_card().id;\n        col.set_deck(&[card_id], deck.id).unwrap();\n        assert_eq!(col.get_first_card().remaining_steps, 3);\n    }\n\n    #[test]\n    fn should_increase_remaining_relearning_steps_if_new_deck_has_more_unpassed_ones() {\n        let mut col = open_test_collection_with_relearning_card();\n        let deck = DeckAdder::new(\"target\")\n            .with_config(|config| config.inner.relearn_steps.push(100.))\n            .add(&mut col);\n        let card_id = col.get_first_card().id;\n        col.set_deck(&[card_id], deck.id).unwrap();\n        assert_eq!(col.get_first_card().remaining_steps, 2);\n    }\n\n    #[test]\n    fn should_not_recalculate_remaining_steps_if_there_are_no_old_steps() -> Result<(), AnkiError> {\n        let mut col = Collection::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        let card_id = col.get_first_card().id;\n        col.set_deck(&[card_id], DeckId(1))?;\n\n        col.set_default_learn_steps(vec![1., 10.]);\n\n        let _post_answer = col.answer_good();\n\n        col.set_default_learn_steps(vec![]);\n        col.set_default_learn_steps(vec![1., 10.]);\n\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/card/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse crate::card::Card;\nuse crate::card::CardId;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::card::FsrsMemoryState;\nuse crate::collection::Collection;\nuse crate::decks::DeckId;\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::OrInvalid;\nuse crate::error::OrNotFound;\nuse crate::notes::NoteId;\nuse crate::prelude::TimestampSecs;\nuse crate::prelude::Usn;\nuse crate::undo::Op;\n\nimpl crate::services::CardsService for Collection {\n    fn get_card(\n        &mut self,\n        input: anki_proto::cards::CardId,\n    ) -> error::Result<anki_proto::cards::Card> {\n        let cid = input.into();\n\n        self.storage\n            .get_card(cid)\n            .and_then(|opt| opt.or_not_found(cid))\n            .map(Into::into)\n    }\n\n    fn update_cards(\n        &mut self,\n        input: anki_proto::cards::UpdateCardsRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let cards = input\n            .cards\n            .into_iter()\n            .map(TryInto::try_into)\n            .collect::<error::Result<Vec<Card>, AnkiError>>()?;\n        for card in &cards {\n            card.validate_custom_data()?;\n        }\n        self.update_cards_maybe_undoable(cards, !input.skip_undo_entry)\n            .map(Into::into)\n    }\n\n    fn remove_cards(\n        &mut self,\n        input: anki_proto::cards::RemoveCardsRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.transact(Op::EmptyCards, |col| {\n            col.remove_cards_and_orphaned_notes(\n                &input\n                    .card_ids\n                    .into_iter()\n                    .map(Into::into)\n                    .collect::<Vec<_>>(),\n            )\n        })\n        .map(Into::into)\n    }\n\n    fn set_deck(\n        &mut self,\n        input: anki_proto::cards::SetDeckRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect();\n        let deck_id = input.deck_id.into();\n        self.set_deck(&cids, deck_id).map(Into::into)\n    }\n\n    fn set_flag(\n        &mut self,\n        input: anki_proto::cards::SetFlagRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.set_card_flag(&to_card_ids(input.card_ids), input.flag)\n            .map(Into::into)\n    }\n}\n\nimpl TryFrom<anki_proto::cards::Card> for Card {\n    type Error = AnkiError;\n\n    fn try_from(c: anki_proto::cards::Card) -> error::Result<Self, Self::Error> {\n        let ctype = CardType::try_from(c.ctype as u8).or_invalid(\"invalid card type\")?;\n        let queue = CardQueue::try_from(c.queue as i8).or_invalid(\"invalid card queue\")?;\n        Ok(Card {\n            id: CardId(c.id),\n            note_id: NoteId(c.note_id),\n            deck_id: DeckId(c.deck_id),\n            template_idx: c.template_idx as u16,\n            mtime: TimestampSecs(c.mtime_secs),\n            usn: Usn(c.usn),\n            ctype,\n            queue,\n            due: c.due,\n            interval: c.interval,\n            ease_factor: c.ease_factor as u16,\n            reps: c.reps,\n            lapses: c.lapses,\n            remaining_steps: c.remaining_steps,\n            original_due: c.original_due,\n            original_deck_id: DeckId(c.original_deck_id),\n            flags: c.flags as u8,\n            original_position: c.original_position,\n            memory_state: c.memory_state.map(Into::into),\n            desired_retention: c.desired_retention,\n            decay: c.decay,\n            last_review_time: c.last_review_time_secs.map(TimestampSecs),\n            custom_data: c.custom_data,\n        })\n    }\n}\n\nimpl From<Card> for anki_proto::cards::Card {\n    fn from(c: Card) -> Self {\n        anki_proto::cards::Card {\n            id: c.id.0,\n            note_id: c.note_id.0,\n            deck_id: c.deck_id.0,\n            template_idx: c.template_idx as u32,\n            mtime_secs: c.mtime.0,\n            usn: c.usn.0,\n            ctype: c.ctype as u32,\n            queue: c.queue as i32,\n            due: c.due,\n            interval: c.interval,\n            ease_factor: c.ease_factor as u32,\n            reps: c.reps,\n            lapses: c.lapses,\n            remaining_steps: c.remaining_steps,\n            original_due: c.original_due,\n            original_deck_id: c.original_deck_id.0,\n            flags: c.flags as u32,\n            original_position: c.original_position,\n            memory_state: c.memory_state.map(Into::into),\n            desired_retention: c.desired_retention,\n            decay: c.decay,\n            last_review_time_secs: c.last_review_time.map(|t| t.0),\n            custom_data: c.custom_data,\n        }\n    }\n}\n\nfn to_card_ids(v: Vec<i64>) -> Vec<CardId> {\n    v.into_iter().map(CardId).collect()\n}\n\nimpl From<anki_proto::cards::CardId> for CardId {\n    fn from(cid: anki_proto::cards::CardId) -> Self {\n        CardId(cid.cid)\n    }\n}\n\nimpl From<anki_proto::cards::FsrsMemoryState> for FsrsMemoryState {\n    fn from(value: anki_proto::cards::FsrsMemoryState) -> Self {\n        FsrsMemoryState {\n            stability: value.stability,\n            difficulty: value.difficulty,\n        }\n    }\n}\n\nimpl From<FsrsMemoryState> for anki_proto::cards::FsrsMemoryState {\n    fn from(value: FsrsMemoryState) -> Self {\n        anki_proto::cards::FsrsMemoryState {\n            stability: value.stability,\n            difficulty: value.difficulty,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/card/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableCardChange {\n    Added(Box<Card>),\n    Updated(Box<Card>),\n    Removed(Box<Card>),\n    GraveAdded(Box<(CardId, Usn)>),\n    GraveRemoved(Box<(CardId, Usn)>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_card_change(&mut self, change: UndoableCardChange) -> Result<()> {\n        match change {\n            UndoableCardChange::Added(card) => self.remove_card_only(*card),\n            UndoableCardChange::Updated(mut card) => {\n                let current = self\n                    .storage\n                    .get_card(card.id)?\n                    .or_invalid(\"card disappeared\")?;\n                self.update_card_undoable(&mut card, current)\n            }\n            UndoableCardChange::Removed(card) => self.restore_deleted_card(*card),\n            UndoableCardChange::GraveAdded(e) => self.remove_card_grave(e.0, e.1),\n            UndoableCardChange::GraveRemoved(e) => self.add_card_grave_undoable(e.0, e.1),\n        }\n    }\n\n    pub(super) fn add_card_undoable(&mut self, card: &mut Card) -> Result<(), AnkiError> {\n        self.storage.add_card(card)?;\n        self.save_undo(UndoableCardChange::Added(Box::new(card.clone())));\n        Ok(())\n    }\n\n    pub(crate) fn add_card_if_unique_undoable(&mut self, card: &Card) -> Result<bool> {\n        let added = self.storage.add_card_if_unique(card)?;\n        if added {\n            self.save_undo(UndoableCardChange::Added(Box::new(card.clone())));\n        }\n        Ok(added)\n    }\n\n    pub(super) fn update_card_undoable(&mut self, card: &mut Card, original: Card) -> Result<()> {\n        require!(card.id.0 != 0, \"card id not set\");\n        self.save_undo(UndoableCardChange::Updated(Box::new(original)));\n        self.storage.update_card(card)\n    }\n\n    pub(crate) fn remove_card_and_add_grave_undoable(\n        &mut self,\n        card: Card,\n        usn: Usn,\n    ) -> Result<()> {\n        self.add_card_grave_undoable(card.id, usn)?;\n        self.storage.remove_card(card.id)?;\n        self.save_undo(UndoableCardChange::Removed(Box::new(card)));\n        Ok(())\n    }\n\n    fn restore_deleted_card(&mut self, card: Card) -> Result<()> {\n        self.storage.add_or_update_card(&card)?;\n        self.save_undo(UndoableCardChange::Added(Box::new(card)));\n        Ok(())\n    }\n\n    fn remove_card_only(&mut self, card: Card) -> Result<()> {\n        self.storage.remove_card(card.id)?;\n        self.save_undo(UndoableCardChange::Removed(Box::new(card)));\n        Ok(())\n    }\n\n    fn add_card_grave_undoable(&mut self, cid: CardId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableCardChange::GraveAdded(Box::new((cid, usn))));\n        self.storage.add_card_grave(cid, usn)\n    }\n\n    fn remove_card_grave(&mut self, cid: CardId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableCardChange::GraveRemoved(Box::new((cid, usn))));\n        self.storage.remove_card_grave(cid)\n    }\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse crate::prelude::*;\n\nmod parser;\npub(crate) mod service;\npub mod tts;\nmod writer;\n\npub fn strip_av_tags<S: Into<String> + AsRef<str>>(txt: S) -> String {\n    nodes_or_text_only(txt.as_ref())\n        .map(|nodes| nodes.write_without_av_tags())\n        .unwrap_or_else(|| txt.into())\n}\n\npub fn extract_av_tags<S: Into<String> + AsRef<str>>(\n    txt: S,\n    question_side: bool,\n    tr: &I18n,\n) -> (String, Vec<anki_proto::card_rendering::AvTag>) {\n    nodes_or_text_only(txt.as_ref())\n        .map(|nodes| nodes.write_and_extract_av_tags(question_side, tr))\n        .unwrap_or_else(|| (txt.into(), vec![]))\n}\n\npub fn prettify_av_tags<S: Into<String> + AsRef<str>>(txt: S) -> String {\n    nodes_or_text_only(txt.as_ref())\n        .map(|nodes| nodes.write_with_pretty_av_tags())\n        .unwrap_or_else(|| txt.into())\n}\n\n/// Parse `txt` into [CardNodes] and return the result,\n/// or [None] if it only contains text nodes.\nfn nodes_or_text_only(txt: &str) -> Option<CardNodes<'_>> {\n    let nodes = CardNodes::parse(txt);\n    (!nodes.text_only).then_some(nodes)\n}\n\n#[derive(Debug, PartialEq)]\nstruct CardNodes<'a> {\n    nodes: Vec<Node<'a>>,\n    text_only: bool,\n}\n\nimpl<'iter, 'nodes> IntoIterator for &'iter CardNodes<'nodes> {\n    type Item = &'iter Node<'nodes>;\n    type IntoIter = std::slice::Iter<'iter, Node<'nodes>>;\n\n    fn into_iter(self) -> Self::IntoIter {\n        self.nodes.iter()\n    }\n}\n\n#[derive(Debug, PartialEq)]\nenum Node<'a> {\n    Text(&'a str),\n    SoundOrVideo(&'a str),\n    Directive(Directive<'a>),\n}\n\n#[derive(Debug, PartialEq)]\nenum Directive<'a> {\n    Tts(TtsDirective<'a>),\n    Other(OtherDirective<'a>),\n}\n\n#[derive(Debug, PartialEq)]\nstruct TtsDirective<'a> {\n    content: &'a str,\n    lang: &'a str,\n    voices: Vec<&'a str>,\n    speed: f32,\n    blank: Option<&'a str>,\n    options: HashMap<&'a str, &'a str>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\nstruct OtherDirective<'a> {\n    name: &'a str,\n    content: &'a str,\n    options: HashMap<&'a str, &'a str>,\n}\n\n#[cfg(feature = \"bench\")]\n#[inline]\npub fn anki_directive_benchmark() {\n    CardNodes::parse(\"[anki:foo bar=baz][/anki:foo][anki:tts lang=jp_JP voices=Alice,Bob speed=0.5 cloze_blank= bar=baz][/anki:tts]\");\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    /// Strip av tags and assert equality with input or separately passed\n    /// output.\n    macro_rules! assert_av_stripped {\n        ($input:expr) => {\n            assert_eq!($input, strip_av_tags($input));\n        };\n        ($input:expr, $output:expr) => {\n            assert_eq!(strip_av_tags($input), $output);\n        };\n    }\n\n    #[test]\n    fn av_stripping() {\n        assert_av_stripped!(\"foo [sound:bar] baz\", \"foo  baz\");\n        assert_av_stripped!(\"[anki:tts bar=baz]spam[/anki:tts]\", \"\");\n        assert_av_stripped!(\"[anki:foo bar=baz]spam[/anki:foo]\");\n    }\n\n    #[test]\n    fn av_extracting() {\n        let tr = I18n::template_only();\n        let (txt, tags) = extract_av_tags(\n            \"foo [sound:bar.mp3] baz [anki:tts lang=en_US][...][/anki:tts]\",\n            true,\n            &tr,\n        );\n        assert_eq!(\n            (txt.as_str(), tags),\n            (\n                \"foo [anki:play:q:0] baz [anki:play:q:1]\",\n                vec![\n                    anki_proto::card_rendering::AvTag {\n                        value: Some(anki_proto::card_rendering::av_tag::Value::SoundOrVideo(\n                            \"bar.mp3\".to_string()\n                        ))\n                    },\n                    anki_proto::card_rendering::AvTag {\n                        value: Some(anki_proto::card_rendering::av_tag::Value::Tts(\n                            anki_proto::card_rendering::TtsTag {\n                                field_text: tr.card_templates_blank().to_string(),\n                                lang: \"en_US\".to_string(),\n                                voices: vec![],\n                                speed: 1.0,\n                                other_args: vec![],\n                            }\n                        ))\n                    }\n                ],\n            ),\n        );\n\n        assert_eq!(\n            extract_av_tags(\"[anki:tts]foo[/anki:tts]\", true, &tr),\n            (\n                format!(\n                    \"[{}]\",\n                    tr.errors_bad_directive(\"anki:tts\", tr.errors_option_not_set(\"lang\"))\n                ),\n                vec![],\n            ),\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/parser.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse nom::branch::alt;\nuse nom::bytes::complete::is_not;\nuse nom::bytes::complete::tag;\nuse nom::character::complete::anychar;\nuse nom::character::complete::multispace0;\nuse nom::combinator::map;\nuse nom::combinator::not;\nuse nom::combinator::recognize;\nuse nom::combinator::rest;\nuse nom::combinator::success;\nuse nom::combinator::value;\nuse nom::multi::many0;\nuse nom::sequence::delimited;\nuse nom::sequence::pair;\nuse nom::sequence::preceded;\nuse nom::sequence::separated_pair;\nuse nom::sequence::terminated;\nuse nom::Input;\nuse nom::Parser;\n\nuse super::CardNodes;\nuse super::Directive;\nuse super::Node;\nuse super::OtherDirective;\nuse super::TtsDirective;\n\ntype IResult<'a, O> = nom::IResult<&'a str, O>;\n\nimpl<'a> CardNodes<'a> {\n    pub(super) fn parse(mut txt: &'a str) -> Self {\n        let mut nodes = Vec::new();\n        let mut text_only = true;\n        while let Ok((remaining, node)) = node(txt) {\n            text_only &= matches!(node, Node::Text(_));\n            txt = remaining;\n            nodes.push(node);\n        }\n\n        Self { nodes, text_only }\n    }\n}\n\nimpl<'a> Directive<'a> {\n    fn new(name: &'a str, options: Vec<(&'a str, &'a str)>, content: &'a str) -> Self {\n        match name {\n            \"tts\" => {\n                let mut lang = \"\";\n                let mut voices = vec![];\n                let mut speed = 1.0;\n                let mut blank = None;\n                let mut other_options = HashMap::new();\n\n                for option in options {\n                    match option.0 {\n                        \"lang\" => lang = option.1,\n                        \"voices\" => voices = option.1.split(',').collect(),\n                        \"speed\" => speed = option.1.parse().unwrap_or(1.0),\n                        \"cloze_blank\" => blank = Some(option.1),\n                        _ => {\n                            other_options.insert(option.0, option.1);\n                        }\n                    }\n                }\n\n                Self::Tts(TtsDirective {\n                    content,\n                    lang,\n                    voices,\n                    speed,\n                    blank,\n                    options: other_options,\n                })\n            }\n            _ => Self::Other(OtherDirective {\n                name,\n                content,\n                options: options.into_iter().collect(),\n            }),\n        }\n    }\n}\n\n/// Consume 0 or more of anything in \" \\t\\r\\n\" after `parser`.\nfn trailing_whitespace0<I, O, E, P>(parser: P) -> impl Parser<I, Output = O, Error = E>\nwhere\n    I: Input,\n    <I as Input>::Item: nom::AsChar,\n    E: nom::error::ParseError<I>,\n    P: Parser<I, Output = O, Error = E>,\n{\n    terminated(parser, multispace0)\n}\n\n/// Parse until char in `arr` is found. Always succeeds.\nfn is_not0<'parser, 'arr: 'parser, 's: 'parser>(\n    arr: &'arr str,\n) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser {\n    move |s| alt((is_not(arr), success(\"\"))).parse(s)\n}\n\nfn node(s: &str) -> IResult<'_, Node<'_>> {\n    alt((sound_node, tag_node, text_node)).parse(s)\n}\n\n/// A sound tag `[sound:resource]`, where `resource` is pointing to a sound or\n/// video file.\nfn sound_node(s: &str) -> IResult<'_, Node<'_>> {\n    map(\n        delimited(tag(\"[sound:\"), is_not(\"]\"), tag(\"]\")),\n        Node::SoundOrVideo,\n    )\n    .parse(s)\n}\n\nfn take_till_potential_tag_start(s: &str) -> IResult<'_, &str> {\n    // first char could be '[', but wasn't part of a node, so skip (eof ends parse)\n    let (after, offset) = anychar(s).map(|(s, c)| (s, c.len_utf8()))?;\n    Ok(match after.find('[') {\n        Some(pos) => s.take_split(offset + pos),\n        _ => rest(s)?,\n    })\n}\n\n/// An Anki tag `[anki:tag...]...[/anki:tag]`.\nfn tag_node(s: &str) -> IResult<'_, Node<'_>> {\n    /// Match the start of an opening tag and return its name.\n    fn name(s: &str) -> IResult<'_, &str> {\n        preceded(tag(\"[anki:\"), is_not(\"] \\t\\r\\n\")).parse(s)\n    }\n\n    /// Return a parser to match an opening `name` tag and return its options.\n    fn opening_parser<'name, 's: 'name>(\n        name: &'name str,\n    ) -> impl FnMut(&'s str) -> IResult<'s, Vec<(&'s str, &'s str)>> + 'name {\n        /// List of whitespace-separated `key=val` tuples, where `val` may be\n        /// empty.\n        fn options(s: &str) -> IResult<'_, Vec<(&str, &str)>> {\n            fn key(s: &str) -> IResult<'_, &str> {\n                is_not(\"] \\t\\r\\n=\").parse(s)\n            }\n\n            fn val(s: &str) -> IResult<'_, &str> {\n                alt((\n                    delimited(tag(\"\\\"\"), is_not0(\"\\\"\"), tag(\"\\\"\")),\n                    is_not0(\"] \\t\\r\\n\\\"\"),\n                ))\n                .parse(s)\n            }\n\n            many0(trailing_whitespace0(separated_pair(key, tag(\"=\"), val))).parse(s)\n        }\n\n        move |s| {\n            delimited(\n                pair(tag(\"[anki:\"), trailing_whitespace0(tag(name))),\n                options,\n                tag(\"]\"),\n            )\n            .parse(s)\n        }\n    }\n\n    /// Return a parser to match a closing `name` tag.\n    fn closing_parser<'parser, 'name: 'parser, 's: 'parser>(\n        name: &'name str,\n    ) -> impl FnMut(&'s str) -> IResult<'s, ()> + 'parser {\n        move |s| value((), (tag(\"[/anki:\"), tag(name), tag(\"]\"))).parse(s)\n    }\n\n    /// Return a parser to match and return anything until a closing `name` tag\n    /// is found.\n    fn content_parser<'parser, 'name: 'parser, 's: 'parser>(\n        name: &'name str,\n    ) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser {\n        move |s| {\n            recognize(many0(pair(\n                not(closing_parser(name)),\n                take_till_potential_tag_start,\n            )))\n            .parse(s)\n        }\n    }\n\n    let (_, tag_name) = name(s)?;\n    map(\n        terminated(\n            pair(opening_parser(tag_name), content_parser(tag_name)),\n            closing_parser(tag_name),\n        ),\n        |(options, content)| Node::Directive(Directive::new(tag_name, options, content)),\n    )\n    .parse(s)\n}\n\nfn text_node(s: &str) -> IResult<'_, Node<'_>> {\n    map(take_till_potential_tag_start, Node::Text).parse(s)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    macro_rules! assert_parsed_nodes {\n        ($txt:expr $(, $node:expr)*) => {\n            assert_eq!(CardNodes::parse($txt).nodes, vec![$($node),*]);\n        }\n    }\n\n    #[test]\n    fn parsing() {\n        use Node::*;\n\n        // empty\n        assert_parsed_nodes!(\"\");\n\n        // text\n        assert_parsed_nodes!(\"foo\", Text(\"foo\"));\n        // broken sound/tags are just text as well\n        assert_parsed_nodes!(\"[sound:]\", Text(\"[sound:]\"));\n        assert_parsed_nodes!(\"[anki:][/anki:]\", Text(\"[anki:]\"), Text(\"[/anki:]\"));\n        assert_parsed_nodes!(\n            \"[anki:foo][/anki:bar]\",\n            Text(\"[anki:foo]\"),\n            Text(\"[/anki:bar]\")\n        );\n        assert_parsed_nodes!(\n            \"abc[anki:foo]def[/anki:bar]ghi][[anki:bar][\",\n            Text(\"abc\"),\n            Text(\"[anki:foo]def\"),\n            Text(\"[/anki:bar]ghi]\"),\n            Text(\"[\"),\n            Text(\"[anki:bar]\"),\n            Text(\"[\")\n        );\n\n        // sound\n        assert_parsed_nodes!(\"[sound:foo]\", SoundOrVideo(\"foo\"));\n        assert_parsed_nodes!(\n            \"foo [sound:bar] baz\",\n            Text(\"foo \"),\n            SoundOrVideo(\"bar\"),\n            Text(\" baz\")\n        );\n        assert_parsed_nodes!(\n            \"[sound:foo][sound:bar]\",\n            SoundOrVideo(\"foo\"),\n            SoundOrVideo(\"bar\")\n        );\n\n        // tags\n        assert_parsed_nodes!(\n            \"[anki:foo]bar[/anki:foo]\",\n            Directive(super::Directive::Other(OtherDirective {\n                name: \"foo\",\n                content: \"bar\",\n                options: HashMap::new()\n            }))\n        );\n        assert_parsed_nodes!(\n            \"[anki:foo]]bar[[/anki:foo]\",\n            Directive(super::Directive::Other(OtherDirective {\n                name: \"foo\",\n                content: \"]bar[\",\n                options: HashMap::new()\n            }))\n        );\n        assert_parsed_nodes!(\n            \"[anki:foo bar=baz][/anki:foo]\",\n            Directive(super::Directive::Other(OtherDirective {\n                name: \"foo\",\n                content: \"\",\n                options: [(\"bar\", \"baz\")].into_iter().collect(),\n            }))\n        );\n        // unquoted white space separates options, \"]\" terminates\n        assert_parsed_nodes!(\n            \"[anki:foo\\na=b\\tc=d e=f][/anki:foo]\",\n            Directive(super::Directive::Other(OtherDirective {\n                name: \"foo\",\n                content: \"\",\n                options: [(\"a\", \"b\"), (\"c\", \"d\"), (\"e\", \"f\")].into_iter().collect(),\n            }))\n        );\n        assert_parsed_nodes!(\n            \"[anki:foo a=\\\"b \\t\\n c ]\\\"][/anki:foo]\",\n            Directive(super::Directive::Other(OtherDirective {\n                name: \"foo\",\n                content: \"\",\n                options: [(\"a\", \"b \\t\\n c ]\")].into_iter().collect(),\n            }))\n        );\n\n        // tts tags\n        assert_parsed_nodes!(\n            \"[anki:tts lang=jp_JP voices=Alice,Bob speed=0.5 cloze_blank= bar=baz][/anki:tts]\",\n            Directive(super::Directive::Tts(TtsDirective {\n                content: \"\",\n                lang: \"jp_JP\",\n                voices: vec![\"Alice\", \"Bob\"],\n                speed: 0.5,\n                blank: Some(\"\"),\n                options: [(\"bar\", \"baz\")].into_iter().collect(),\n            }))\n        );\n        assert_parsed_nodes!(\n            \"[anki:tts speed=foo][/anki:tts]\",\n            Directive(super::Directive::Tts(TtsDirective {\n                content: \"\",\n                lang: \"\",\n                voices: vec![],\n                speed: 1.0,\n                blank: None,\n                options: HashMap::new(),\n            }))\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::card_rendering::ExtractClozeForTypingRequest;\nuse anki_proto::generic;\n\nuse crate::card::CardId;\nuse crate::card_rendering::extract_av_tags;\nuse crate::card_rendering::strip_av_tags;\nuse crate::cloze::extract_cloze_for_typing;\nuse crate::collection::Collection;\nuse crate::error::OrInvalid;\nuse crate::error::Result;\nuse crate::latex::extract_latex;\nuse crate::latex::extract_latex_expanding_clozes;\nuse crate::latex::ExtractedLatex;\nuse crate::markdown::render_markdown;\nuse crate::notetype::CardTemplateSchema11;\nuse crate::notetype::RenderCardOutput;\nuse crate::template::RenderedNode;\nuse crate::text::decode_iri_paths;\nuse crate::text::encode_iri_paths;\nuse crate::text::html_to_text_line;\nuse crate::text::sanitize_html_no_images;\nuse crate::text::strip_html;\nuse crate::text::strip_html_preserving_media_filenames;\nuse crate::typeanswer::compare_answer;\n\n/// While the majority of these methods do not actually require a collection,\n/// they are unlikely to be executed without one, so we only bother implementing\n/// them for the collection.\nimpl crate::services::CardRenderingService for Collection {\n    fn extract_av_tags(\n        &mut self,\n        input: anki_proto::card_rendering::ExtractAvTagsRequest,\n    ) -> Result<anki_proto::card_rendering::ExtractAvTagsResponse> {\n        let out = extract_av_tags(input.text, input.question_side, &self.tr);\n        Ok(anki_proto::card_rendering::ExtractAvTagsResponse {\n            text: out.0,\n            av_tags: out.1,\n        })\n    }\n\n    fn extract_latex(\n        &mut self,\n        input: anki_proto::card_rendering::ExtractLatexRequest,\n    ) -> Result<anki_proto::card_rendering::ExtractLatexResponse> {\n        let func = if input.expand_clozes {\n            extract_latex_expanding_clozes\n        } else {\n            extract_latex\n        };\n        let (text, extracted) = func(&input.text, input.svg);\n\n        Ok(anki_proto::card_rendering::ExtractLatexResponse {\n            text: text.into_owned(),\n            latex: extracted\n                .into_iter()\n                .map(\n                    |e: ExtractedLatex| anki_proto::card_rendering::ExtractedLatex {\n                        filename: e.fname,\n                        latex_body: e.latex,\n                    },\n                )\n                .collect(),\n        })\n    }\n\n    fn get_empty_cards(&mut self) -> Result<anki_proto::card_rendering::EmptyCardsReport> {\n        let mut empty = self.empty_cards()?;\n        let report = self.empty_cards_report(&mut empty)?;\n\n        let mut outnotes = vec![];\n        for (_ntid, notes) in empty {\n            outnotes.extend(notes.into_iter().map(|e| {\n                anki_proto::card_rendering::empty_cards_report::NoteWithEmptyCards {\n                    note_id: e.nid.0,\n                    will_delete_note: e.empty.len() == e.current_count,\n                    card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(),\n                }\n            }))\n        }\n        Ok(anki_proto::card_rendering::EmptyCardsReport {\n            report,\n            notes: outnotes,\n        })\n    }\n\n    fn render_existing_card(\n        &mut self,\n        input: anki_proto::card_rendering::RenderExistingCardRequest,\n    ) -> Result<anki_proto::card_rendering::RenderCardResponse> {\n        self.render_existing_card(CardId(input.card_id), input.browser, input.partial_render)\n            .map(Into::into)\n    }\n\n    fn render_uncommitted_card(\n        &mut self,\n        input: anki_proto::card_rendering::RenderUncommittedCardRequest,\n    ) -> Result<anki_proto::card_rendering::RenderCardResponse> {\n        let template = input.template.or_invalid(\"missing template\")?.into();\n        let mut note = input.note.or_invalid(\"missing note\")?.into();\n        let ord = input.card_ord as u16;\n        let fill_empty = input.fill_empty;\n\n        self.render_uncommitted_card(&mut note, &template, ord, fill_empty, input.partial_render)\n            .map(Into::into)\n    }\n\n    fn render_uncommitted_card_legacy(\n        &mut self,\n        input: anki_proto::card_rendering::RenderUncommittedCardLegacyRequest,\n    ) -> Result<anki_proto::card_rendering::RenderCardResponse> {\n        let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?;\n        let template = schema11.into();\n        let mut note = input.note.or_invalid(\"missing note\")?.into();\n        let ord = input.card_ord as u16;\n        let fill_empty = input.fill_empty;\n\n        self.render_uncommitted_card(&mut note, &template, ord, fill_empty, input.partial_render)\n            .map(Into::into)\n    }\n\n    fn strip_av_tags(&mut self, input: generic::String) -> Result<generic::String> {\n        Ok(strip_av_tags(input.val).into())\n    }\n\n    fn render_markdown(\n        &mut self,\n        input: anki_proto::card_rendering::RenderMarkdownRequest,\n    ) -> Result<generic::String> {\n        let mut text = render_markdown(&input.markdown);\n        if input.sanitize {\n            // currently no images\n            text = sanitize_html_no_images(&text);\n        }\n        Ok(text.into())\n    }\n\n    fn encode_iri_paths(&mut self, input: generic::String) -> Result<generic::String> {\n        Ok(encode_iri_paths(&input.val).to_string().into())\n    }\n\n    fn decode_iri_paths(&mut self, input: generic::String) -> Result<generic::String> {\n        Ok(decode_iri_paths(&input.val).to_string().into())\n    }\n\n    fn strip_html(\n        &mut self,\n        input: anki_proto::card_rendering::StripHtmlRequest,\n    ) -> Result<generic::String> {\n        strip_html_proto(input)\n    }\n\n    fn html_to_text_line(\n        &mut self,\n        input: anki_proto::card_rendering::HtmlToTextLineRequest,\n    ) -> Result<generic::String> {\n        Ok(\n            html_to_text_line(&input.text, input.preserve_media_filenames)\n                .to_string()\n                .into(),\n        )\n    }\n\n    fn compare_answer(\n        &mut self,\n        input: anki_proto::card_rendering::CompareAnswerRequest,\n    ) -> Result<generic::String> {\n        Ok(compare_answer(&input.expected, &input.provided, input.combining).into())\n    }\n\n    fn extract_cloze_for_typing(\n        &mut self,\n        input: ExtractClozeForTypingRequest,\n    ) -> Result<generic::String> {\n        Ok(extract_cloze_for_typing(&input.text, input.ordinal as u16)\n            .to_string()\n            .into())\n    }\n}\n\nfn rendered_nodes_to_proto(\n    nodes: Vec<RenderedNode>,\n) -> Vec<anki_proto::card_rendering::RenderedTemplateNode> {\n    nodes\n        .into_iter()\n        .map(|n| anki_proto::card_rendering::RenderedTemplateNode {\n            value: Some(rendered_node_to_proto(n)),\n        })\n        .collect()\n}\n\nfn rendered_node_to_proto(\n    node: RenderedNode,\n) -> anki_proto::card_rendering::rendered_template_node::Value {\n    match node {\n        RenderedNode::Text { text } => {\n            anki_proto::card_rendering::rendered_template_node::Value::Text(text)\n        }\n        RenderedNode::Replacement {\n            field_name,\n            current_text,\n            filters,\n        } => anki_proto::card_rendering::rendered_template_node::Value::Replacement(\n            anki_proto::card_rendering::RenderedTemplateReplacement {\n                field_name,\n                current_text,\n                filters,\n            },\n        ),\n    }\n}\n\nimpl From<RenderCardOutput> for anki_proto::card_rendering::RenderCardResponse {\n    fn from(o: RenderCardOutput) -> Self {\n        anki_proto::card_rendering::RenderCardResponse {\n            question_nodes: rendered_nodes_to_proto(o.qnodes),\n            answer_nodes: rendered_nodes_to_proto(o.anodes),\n            css: o.css,\n            latex_svg: o.latex_svg,\n            is_empty: o.is_empty,\n        }\n    }\n}\n\npub(crate) fn strip_html_proto(\n    input: anki_proto::card_rendering::StripHtmlRequest,\n) -> Result<generic::String> {\n    Ok(match input.mode() {\n        anki_proto::card_rendering::strip_html_request::Mode::Normal => strip_html(&input.text),\n        anki_proto::card_rendering::strip_html_request::Mode::PreserveMediaFilenames => {\n            strip_html_preserving_media_filenames(&input.text)\n        }\n    }\n    .to_string()\n    .into())\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/tts/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::card_rendering::all_tts_voices_response::TtsVoice;\n\nuse crate::prelude::*;\n\n#[cfg(windows)]\n#[path = \"windows.rs\"]\nmod inner;\n#[cfg(not(windows))]\n#[path = \"other.rs\"]\nmod inner;\n\npub fn all_voices(validate: bool) -> Result<Vec<TtsVoice>> {\n    inner::all_voices(validate)\n}\n\npub fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> {\n    inner::write_stream(path, voice_id, speed, text)\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/tts/other.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::card_rendering::all_tts_voices_response::TtsVoice;\n\nuse crate::prelude::*;\n\npub(super) fn all_voices(_validate: bool) -> Result<Vec<TtsVoice>> {\n    invalid_input!(\"not implemented for this OS\");\n}\n\npub(super) fn write_stream(_path: &str, _voice_id: &str, _speed: f32, _text: &str) -> Result<()> {\n    invalid_input!(\"not implemented for this OS\");\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/tts/windows.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs::File;\nuse std::io::Write;\n\nuse anki_proto::card_rendering::all_tts_voices_response::TtsVoice;\nuse windows::core::Interface;\nuse windows::core::HSTRING;\nuse windows::Media::SpeechSynthesis::SpeechSynthesisStream;\nuse windows::Media::SpeechSynthesis::SpeechSynthesizer;\nuse windows::Media::SpeechSynthesis::VoiceInformation;\nuse windows::Storage::Streams::DataReader;\nuse windows::Storage::Streams::IRandomAccessStream;\n\nuse crate::error::windows::WindowsErrorDetails;\nuse crate::error::windows::WindowsSnafu;\nuse crate::prelude::*;\n\nconst MAX_BUFFER_SIZE: usize = 128 * 1024;\n\npub(super) fn all_voices(validate: bool) -> Result<Vec<TtsVoice>> {\n    SpeechSynthesizer::AllVoices()?\n        .into_iter()\n        .map(|info| tts_voice_from_information(info, validate))\n        .collect()\n}\n\npub(super) fn write_stream(path: &str, voice_id: &str, speed: f32, text: &str) -> Result<()> {\n    let voice = find_voice(voice_id)?;\n    let stream = synthesize_stream(&voice, speed, text)?;\n    write_stream_to_path(stream, path)?;\n    Ok(())\n}\n\nfn find_voice(voice_id: &str) -> Result<VoiceInformation> {\n    SpeechSynthesizer::AllVoices()?\n        .into_iter()\n        .find(|info| {\n            info.Id()\n                .map(|id| id.to_string_lossy().eq(voice_id))\n                .unwrap_or_default()\n        })\n        .or_invalid(\"voice id not found\")\n}\n\nfn to_hstring(text: &str) -> HSTRING {\n    let utf16: Vec<u16> = text.encode_utf16().collect();\n    HSTRING::from_wide(&utf16)\n}\n\nfn synthesize_stream(\n    voice: &VoiceInformation,\n    speed: f32,\n    text: &str,\n) -> Result<SpeechSynthesisStream> {\n    let synthesizer = SpeechSynthesizer::new()?;\n    synthesizer.SetVoice(voice).with_context(|_| WindowsSnafu {\n        details: WindowsErrorDetails::SettingVoice(voice.clone()),\n    })?;\n    synthesizer\n        .Options()?\n        .SetSpeakingRate(speed as f64)\n        .context(WindowsSnafu {\n            details: WindowsErrorDetails::SettingRate(speed),\n        })?;\n    let async_op = synthesizer.SynthesizeTextToStreamAsync(&to_hstring(text))?;\n    let stream = async_op.get().context(WindowsSnafu {\n        details: WindowsErrorDetails::Synthesizing,\n    })?;\n    Ok(stream)\n}\n\nfn write_stream_to_path(stream: SpeechSynthesisStream, path: &str) -> Result<()> {\n    let random_access_stream: IRandomAccessStream = stream.cast()?;\n    let input_stream = random_access_stream.GetInputStreamAt(0)?;\n    let date_reader = DataReader::CreateDataReader(&input_stream)?;\n    let stream_size = random_access_stream\n        .Size()?\n        .try_into()\n        .or_invalid(\"stream too large\")?;\n    date_reader.LoadAsync(stream_size)?;\n    let mut file = File::create(path)?;\n    write_reader_to_file(date_reader, &mut file, stream_size as usize)\n}\n\nfn write_reader_to_file(reader: DataReader, file: &mut File, stream_size: usize) -> Result<()> {\n    let mut bytes_remaining = stream_size;\n    let mut buf = [0u8; MAX_BUFFER_SIZE];\n    while bytes_remaining > 0 {\n        let chunk_size = bytes_remaining.min(MAX_BUFFER_SIZE);\n        reader.ReadBytes(&mut buf[..chunk_size])?;\n        file.write_all(&buf[..chunk_size])?;\n        bytes_remaining -= chunk_size;\n    }\n    Ok(())\n}\n\nfn tts_voice_from_information(info: VoiceInformation, validate: bool) -> Result<TtsVoice> {\n    Ok(TtsVoice {\n        id: info.Id()?.to_string_lossy(),\n        name: info.DisplayName()?.to_string_lossy(),\n        language: info.Language()?.to_string_lossy(),\n        // Windows lists voices that fail when actually trying to use them. This has been\n        // observed with voices from an uninstalled language pack.\n        // Validation is optional because it may be slow.\n        available: validate.then(|| synthesize_stream(&info, 1.0, \"\").is_ok()),\n    })\n}\n"
  },
  {
    "path": "rslib/src/card_rendering/writer.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Write as _;\n\nuse super::CardNodes;\nuse super::Directive;\nuse super::Node;\nuse super::OtherDirective;\nuse super::TtsDirective;\nuse crate::prelude::*;\nuse crate::text::decode_entities;\nuse crate::text::strip_html_for_tts;\n\nimpl CardNodes<'_> {\n    pub(super) fn write_without_av_tags(&self) -> String {\n        AvStripper::new().write(self)\n    }\n\n    pub(super) fn write_and_extract_av_tags(\n        &self,\n        question_side: bool,\n        tr: &I18n,\n    ) -> (String, Vec<anki_proto::card_rendering::AvTag>) {\n        let mut extractor = AvExtractor::new(question_side, tr);\n        (extractor.write(self), extractor.tags)\n    }\n\n    pub(super) fn write_with_pretty_av_tags(&self) -> String {\n        AvPrettifier::new().write(self)\n    }\n}\n\ntrait Write {\n    fn write<'iter, 'nodes: 'iter, T>(&mut self, nodes: T) -> String\n    where\n        T: IntoIterator<Item = &'iter Node<'nodes>>,\n    {\n        let mut buf = String::new();\n        for node in nodes {\n            match node {\n                Node::Text(s) => self.write_text(&mut buf, s),\n                Node::SoundOrVideo(r) => self.write_sound(&mut buf, r),\n                Node::Directive(directive) => self.write_directive(&mut buf, directive),\n            };\n        }\n        buf\n    }\n\n    fn write_text(&mut self, buf: &mut String, txt: &str) {\n        buf.push_str(txt);\n    }\n\n    fn write_sound(&mut self, buf: &mut String, resource: &str) {\n        write!(buf, \"[sound:{resource}]\").unwrap();\n    }\n\n    fn write_directive(&mut self, buf: &mut String, directive: &Directive) {\n        match directive {\n            Directive::Tts(directive) => self.write_tts_directive(buf, directive),\n            Directive::Other(directive) => self.write_other_directive(buf, directive),\n        };\n    }\n\n    fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) {\n        write!(buf, \"[anki:tts\").unwrap();\n\n        for (key, val) in [\n            (\"lang\", directive.lang),\n            (\"voices\", &directive.voices.join(\",\")),\n            (\"speed\", &directive.speed.to_string()),\n        ] {\n            self.write_directive_option(buf, key, val);\n        }\n        if let Some(blank) = directive.blank {\n            self.write_directive_option(buf, \"cloze_blank\", blank);\n        }\n        for (key, val) in &directive.options {\n            self.write_directive_option(buf, key, val);\n        }\n\n        write!(buf, \"]{}[/anki:tts]\", directive.content).unwrap();\n    }\n\n    fn write_other_directive(&mut self, buf: &mut String, directive: &OtherDirective) {\n        write!(buf, \"[anki:{}\", directive.name).unwrap();\n        for (key, val) in &directive.options {\n            self.write_directive_option(buf, key, val);\n        }\n        buf.push(']');\n        self.write_directive_content(buf, directive.content);\n        write!(buf, \"[/anki:{}]\", directive.name).unwrap();\n    }\n\n    fn write_directive_option(&mut self, buf: &mut String, key: &str, val: &str) {\n        if val.contains([']', ' ', '\\t', '\\r', '\\n']) {\n            write!(buf, \" {key}=\\\"{val}\\\"\").unwrap();\n        } else {\n            write!(buf, \" {key}={val}\").unwrap();\n        }\n    }\n\n    fn write_directive_content(&mut self, buf: &mut String, content: &str) {\n        buf.push_str(content);\n    }\n}\n\nstruct AvStripper;\n\nimpl AvStripper {\n    fn new() -> Self {\n        Self {}\n    }\n}\n\nimpl Write for AvStripper {\n    fn write_sound(&mut self, _buf: &mut String, _resource: &str) {}\n\n    fn write_tts_directive(&mut self, _buf: &mut String, _directive: &TtsDirective) {}\n}\n\nstruct AvExtractor<'a> {\n    side: char,\n    tags: Vec<anki_proto::card_rendering::AvTag>,\n    tr: &'a I18n,\n}\n\nimpl<'a> AvExtractor<'a> {\n    fn new(question_side: bool, tr: &'a I18n) -> Self {\n        Self {\n            side: if question_side { 'q' } else { 'a' },\n            tags: vec![],\n            tr,\n        }\n    }\n\n    fn write_play_tag(&self, buf: &mut String) {\n        write!(buf, \"[anki:play:{}:{}]\", self.side, self.tags.len()).unwrap();\n    }\n\n    fn transform_tts_content(&self, directive: &TtsDirective) -> String {\n        strip_html_for_tts(directive.content).replace(\n            \"[...]\",\n            directive.blank.unwrap_or(&self.tr.card_templates_blank()),\n        )\n    }\n}\n\nimpl Write for AvExtractor<'_> {\n    fn write_sound(&mut self, buf: &mut String, resource: &str) {\n        self.write_play_tag(buf);\n        self.tags.push(anki_proto::card_rendering::AvTag {\n            value: Some(anki_proto::card_rendering::av_tag::Value::SoundOrVideo(\n                decode_entities(resource).into(),\n            )),\n        });\n    }\n\n    fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) {\n        if let Some(error) = directive.error(self.tr) {\n            write!(buf, \"[{error}]\").unwrap();\n            return;\n        }\n\n        self.write_play_tag(buf);\n        self.tags.push(anki_proto::card_rendering::AvTag {\n            value: Some(anki_proto::card_rendering::av_tag::Value::Tts(\n                anki_proto::card_rendering::TtsTag {\n                    field_text: self.transform_tts_content(directive),\n                    lang: directive.lang.into(),\n                    voices: directive.voices.iter().map(ToString::to_string).collect(),\n                    speed: directive.speed,\n                    other_args: directive\n                        .options\n                        .iter()\n                        .map(|(key, val)| format!(\"{key}={val}\"))\n                        .collect(),\n                },\n            )),\n        });\n    }\n}\n\nimpl TtsDirective<'_> {\n    fn error(&self, tr: &I18n) -> Option<String> {\n        if self.lang.is_empty() {\n            Some(\n                tr.errors_bad_directive(\"anki:tts\", tr.errors_option_not_set(\"lang\"))\n                    .into(),\n            )\n        } else {\n            None\n        }\n    }\n}\n\nstruct AvPrettifier;\n\nimpl AvPrettifier {\n    fn new() -> Self {\n        Self {}\n    }\n}\n\nimpl Write for AvPrettifier {\n    fn write_sound(&mut self, buf: &mut String, resource: &str) {\n        write!(buf, \"🔉{resource}🔉\").unwrap();\n    }\n\n    fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) {\n        write!(buf, \"💬{}💬\", directive.content).unwrap();\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    struct Writer;\n    impl Write for Writer {}\n    impl Writer {\n        fn new() -> Self {\n            Self {}\n        }\n    }\n\n    /// Parse input, write it out, and assert equality with input or separately\n    /// passed output.\n    macro_rules! roundtrip {\n        ($input:expr) => {\n            assert_eq!($input, Writer::new().write(&CardNodes::parse($input)));\n        };\n        ($input:expr, $output:expr) => {\n            assert_eq!(Writer::new().write(&CardNodes::parse($input)), $output);\n        };\n    }\n\n    #[test]\n    fn writing() {\n        roundtrip!(\"foo\");\n        roundtrip!(\"[sound:foo]\");\n        roundtrip!(\"[anki:foo bar=baz]spam[/anki:foo]\");\n\n        // normalizing (not currently exposed)\n        roundtrip!(\n            \"[anki:foo\\nbar=baz ][/anki:foo]\",\n            \"[anki:foo bar=baz][/anki:foo]\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/cloze.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::fmt::Write;\nuse std::sync::LazyLock;\n\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusion;\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape;\nuse htmlescape::encode_attribute;\nuse itertools::Itertools;\nuse nom::branch::alt;\nuse nom::bytes::complete::tag;\nuse nom::bytes::complete::take_while;\nuse nom::combinator::map;\nuse nom::IResult;\nuse nom::Parser;\nuse regex::Captures;\nuse regex::Regex;\n\nuse crate::image_occlusion::imageocclusion::get_image_cloze_data;\nuse crate::image_occlusion::imageocclusion::parse_image_cloze;\nuse crate::latex::contains_latex;\nuse crate::template::RenderContext;\nuse crate::text::strip_html_preserving_entities;\n\nstatic CLOZE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?s)\\{\\{c[\\d,]+::(.*?)(::.*?)?\\}\\}\").unwrap());\n\nstatic MATHJAX: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?xsi)\n            (\\\\[(\\[])       # 1 = mathjax opening tag\n            (.*?)           # 2 = inner content\n            (\\\\[])])        # 3 = mathjax closing tag\n           \",\n    )\n    .unwrap()\n});\n\nmod mathjax_caps {\n    pub const OPENING_TAG: usize = 1;\n    pub const INNER_TEXT: usize = 2;\n    pub const CLOSING_TAG: usize = 3;\n}\n\n#[derive(Debug)]\nenum Token<'a> {\n    // The parameter is the cloze number as is appears in the field content.\n    OpenCloze(Vec<u16>),\n    Text(&'a str),\n    CloseCloze,\n}\n\n/// Tokenize string\nfn tokenize(mut text: &str) -> impl Iterator<Item = Token<'_>> {\n    fn open_cloze(text: &str) -> IResult<&str, Token<'_>> {\n        // opening brackets and 'c'\n        let (text, _opening_brackets_and_c) = tag(\"{{c\")(text)?;\n        // following comma-seperated numbers\n        let (text, ordinals) = take_while(|c: char| c.is_ascii_digit() || c == ',')(text)?;\n        let ordinals: Vec<u16> = ordinals\n            .split(',')\n            .filter_map(|s| s.parse().ok())\n            .collect::<HashSet<_>>() // deduplicate\n            .into_iter()\n            .sorted() // set conversion can de-order\n            .collect();\n        if ordinals.is_empty() {\n            return Err(nom::Err::Error(nom::error::make_error(\n                text,\n                nom::error::ErrorKind::Digit,\n            )));\n        }\n        // ::\n        let (text, _colons) = tag(\"::\")(text)?;\n        Ok((text, Token::OpenCloze(ordinals)))\n    }\n\n    fn close_cloze(text: &str) -> IResult<&str, Token<'_>> {\n        map(tag(\"}}\"), |_| Token::CloseCloze).parse(text)\n    }\n\n    /// Match a run of text until an open/close marker is encountered.\n    fn normal_text(text: &str) -> IResult<&str, Token<'_>> {\n        if text.is_empty() {\n            return Err(nom::Err::Error(nom::error::make_error(\n                text,\n                nom::error::ErrorKind::Eof,\n            )));\n        }\n        let mut other_token = alt((open_cloze, close_cloze));\n        // start with the no-match case\n        let mut index = text.len();\n        for (idx, _) in text.char_indices() {\n            if other_token.parse(&text[idx..]).is_ok() {\n                index = idx;\n                break;\n            }\n        }\n        Ok((&text[index..], Token::Text(&text[0..index])))\n    }\n\n    std::iter::from_fn(move || {\n        if text.is_empty() {\n            None\n        } else {\n            let (remaining_text, token) = alt((open_cloze, close_cloze, normal_text))\n                .parse(text)\n                .unwrap();\n            text = remaining_text;\n            Some(token)\n        }\n    })\n}\n\n#[derive(Debug)]\nenum TextOrCloze<'a> {\n    Text(&'a str),\n    Cloze(ExtractedCloze<'a>),\n}\n\n#[derive(Debug)]\nstruct ExtractedCloze<'a> {\n    // `ordinal` is the cloze number as is appears in the field content.\n    ordinals: Vec<u16>,\n    nodes: Vec<TextOrCloze<'a>>,\n    hint: Option<&'a str>,\n}\n\n/// Generate a string representation of the ordinals for HTML\nfn ordinals_str(ordinals: &[u16]) -> String {\n    ordinals\n        .iter()\n        .map(|o| o.to_string())\n        .collect::<Vec<_>>()\n        .join(\",\")\n}\n\nimpl ExtractedCloze<'_> {\n    /// Return the cloze's hint, or \"...\" if none was provided.\n    fn hint(&self) -> &str {\n        self.hint.unwrap_or(\"...\")\n    }\n\n    fn clozed_text(&self) -> Cow<'_, str> {\n        // happy efficient path?\n        if self.nodes.len() == 1 {\n            if let TextOrCloze::Text(text) = self.nodes.last().unwrap() {\n                return (*text).into();\n            }\n        }\n\n        let mut buf = String::new();\n        for node in &self.nodes {\n            match node {\n                TextOrCloze::Text(text) => buf.push_str(text),\n                TextOrCloze::Cloze(cloze) => buf.push_str(&cloze.clozed_text()),\n            }\n        }\n\n        buf.into()\n    }\n\n    /// Checks if this cloze is active for a given ordinal\n    fn contains_ordinal(&self, ordinal: u16) -> bool {\n        self.ordinals.contains(&ordinal)\n    }\n\n    /// If cloze starts with image-occlusion:, return the text following that.\n    fn image_occlusion(&self) -> Option<&str> {\n        let TextOrCloze::Text(text) = self.nodes.first()? else {\n            return None;\n        };\n        text.strip_prefix(\"image-occlusion:\")\n    }\n}\n\nfn parse_text_with_clozes(text: &str) -> Vec<TextOrCloze<'_>> {\n    let mut open_clozes: Vec<ExtractedCloze> = vec![];\n    let mut output = vec![];\n    for token in tokenize(text) {\n        match token {\n            Token::OpenCloze(ordinals) => {\n                if open_clozes.len() < 10 {\n                    open_clozes.push(ExtractedCloze {\n                        ordinals,\n                        nodes: Vec::with_capacity(1), // common case\n                        hint: None,\n                    })\n                }\n            }\n            Token::Text(mut text) => {\n                if let Some(cloze) = open_clozes.last_mut() {\n                    // extract hint if found\n                    if let Some((head, tail)) = text.split_once(\"::\") {\n                        text = head;\n                        cloze.hint = Some(tail);\n                    }\n                    cloze.nodes.push(TextOrCloze::Text(text));\n                } else {\n                    output.push(TextOrCloze::Text(text));\n                }\n            }\n            Token::CloseCloze => {\n                // take the currently active cloze\n                if let Some(cloze) = open_clozes.pop() {\n                    let target = if let Some(outer_cloze) = open_clozes.last_mut() {\n                        // and place it into the cloze layer above\n                        &mut outer_cloze.nodes\n                    } else {\n                        // or the top level if no other clozes active\n                        &mut output\n                    };\n                    target.push(TextOrCloze::Cloze(cloze));\n                } else {\n                    // closing marker outside of any clozes\n                    output.push(TextOrCloze::Text(\"}}\"))\n                }\n            }\n        }\n    }\n    output\n}\n\nfn reveal_cloze_text_in_nodes(\n    node: &TextOrCloze,\n    cloze_ord: u16,\n    question: bool,\n    output: &mut Vec<String>,\n) {\n    if let TextOrCloze::Cloze(cloze) = node {\n        if cloze.contains_ordinal(cloze_ord) {\n            if question {\n                output.push(cloze.hint().into())\n            } else {\n                output.push(cloze.clozed_text().into())\n            }\n        }\n        for node in &cloze.nodes {\n            reveal_cloze_text_in_nodes(node, cloze_ord, question, output);\n        }\n    }\n}\n\nfn reveal_cloze(\n    cloze: &ExtractedCloze,\n    cloze_ord: u16,\n    question: bool,\n    active_cloze_found_in_text: &mut bool,\n    buf: &mut String,\n) {\n    let active = cloze.contains_ordinal(cloze_ord);\n    *active_cloze_found_in_text |= active;\n\n    if let Some(image_occlusion_text) = cloze.image_occlusion() {\n        buf.push_str(&render_image_occlusion(\n            image_occlusion_text,\n            question,\n            active,\n            &cloze.ordinals,\n        ));\n        return;\n    }\n    match (question, active) {\n        (true, true) => {\n            // question side with active cloze; all inner content is elided\n            let mut content_buf = String::new();\n            for node in &cloze.nodes {\n                match node {\n                    TextOrCloze::Text(text) => content_buf.push_str(text),\n                    TextOrCloze::Cloze(cloze) => reveal_cloze(\n                        cloze,\n                        cloze_ord,\n                        question,\n                        active_cloze_found_in_text,\n                        &mut content_buf,\n                    ),\n                }\n            }\n            write!(\n                buf,\n                r#\"<span class=\"cloze\" data-cloze=\"{}\" data-ordinal=\"{}\">[{}]</span>\"#,\n                encode_attribute(&content_buf),\n                ordinals_str(&cloze.ordinals),\n                cloze.hint()\n            )\n            .unwrap();\n        }\n        (false, true) => {\n            write!(\n                buf,\n                r#\"<span class=\"cloze\" data-ordinal=\"{}\">\"#,\n                ordinals_str(&cloze.ordinals)\n            )\n            .unwrap();\n            for node in &cloze.nodes {\n                match node {\n                    TextOrCloze::Text(text) => buf.push_str(text),\n                    TextOrCloze::Cloze(cloze) => {\n                        reveal_cloze(cloze, cloze_ord, question, active_cloze_found_in_text, buf)\n                    }\n                }\n            }\n            buf.push_str(\"</span>\");\n        }\n        (_, false) => {\n            // question or answer side inactive cloze; text shown, children may be active\n            write!(\n                buf,\n                r#\"<span class=\"cloze-inactive\" data-ordinal=\"{}\">\"#,\n                ordinals_str(&cloze.ordinals)\n            )\n            .unwrap();\n            for node in &cloze.nodes {\n                match node {\n                    TextOrCloze::Text(text) => buf.push_str(text),\n                    TextOrCloze::Cloze(cloze) => {\n                        reveal_cloze(cloze, cloze_ord, question, active_cloze_found_in_text, buf)\n                    }\n                }\n            }\n            buf.push_str(\"</span>\")\n        }\n    }\n}\n\nfn render_image_occlusion(\n    text: &str,\n    question_side: bool,\n    active: bool,\n    ordinals: &[u16],\n) -> String {\n    if (question_side && active) || ordinals.contains(&0) {\n        format!(\n            r#\"<div class=\"cloze\" data-ordinal=\"{}\" {}></div>\"#,\n            ordinals_str(ordinals),\n            &get_image_cloze_data(text)\n        )\n    } else if !active {\n        format!(\n            r#\"<div class=\"cloze-inactive\" data-ordinal=\"{}\" {}></div>\"#,\n            ordinals_str(ordinals),\n            &get_image_cloze_data(text)\n        )\n    } else if !question_side && active {\n        format!(\n            r#\"<div class=\"cloze-highlight\" data-ordinal=\"{}\" {}></div>\"#,\n            ordinals_str(ordinals),\n            &get_image_cloze_data(text)\n        )\n    } else {\n        \"\".into()\n    }\n}\n\npub fn parse_image_occlusions(text: &str) -> Vec<ImageOcclusion> {\n    let mut occlusions: HashMap<u16, Vec<ImageOcclusionShape>> = HashMap::new();\n    for node in parse_text_with_clozes(text) {\n        if let TextOrCloze::Cloze(cloze) = node {\n            if cloze.image_occlusion().is_some() {\n                if let Some(shape) = parse_image_cloze(cloze.image_occlusion().unwrap()) {\n                    // Associate this occlusion with all ordinals in this cloze\n                    for &ordinal in &cloze.ordinals {\n                        occlusions.entry(ordinal).or_default().push(shape.clone());\n                    }\n                }\n            }\n        }\n    }\n\n    occlusions\n        .iter()\n        .map(|(k, v)| ImageOcclusion {\n            ordinal: *k as u32,\n            shapes: v.to_vec(),\n        })\n        .collect()\n}\n\npub fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow<'_, str> {\n    let mut buf = String::new();\n    let mut active_cloze_found_in_text = false;\n    for node in &parse_text_with_clozes(text) {\n        match node {\n            // top-level text is indiscriminately added\n            TextOrCloze::Text(text) => buf.push_str(text),\n            TextOrCloze::Cloze(cloze) => reveal_cloze(\n                cloze,\n                cloze_ord,\n                question,\n                &mut active_cloze_found_in_text,\n                &mut buf,\n            ),\n        }\n    }\n    if active_cloze_found_in_text {\n        buf.into()\n    } else {\n        Cow::from(\"\")\n    }\n}\n\npub fn reveal_cloze_text_only(text: &str, cloze_ord: u16, question: bool) -> Cow<'_, str> {\n    let mut output = Vec::new();\n    for node in &parse_text_with_clozes(text) {\n        reveal_cloze_text_in_nodes(node, cloze_ord, question, &mut output);\n    }\n    output.join(\", \").into()\n}\n\npub fn extract_cloze_for_typing(text: &str, cloze_ord: u16) -> Cow<'_, str> {\n    let mut output = Vec::new();\n    for node in &parse_text_with_clozes(text) {\n        reveal_cloze_text_in_nodes(node, cloze_ord, false, &mut output);\n    }\n    if output.is_empty() {\n        \"\".into()\n    } else if output.iter().min() == output.iter().max() {\n        // If all matches are identical text, they get collapsed into a single entry\n        output.pop().unwrap().into()\n    } else {\n        output.join(\", \").into()\n    }\n}\n\n/// If text contains any LaTeX tags, render the front and back\n/// of each cloze deletion so that LaTeX can be generated. If\n/// no LaTeX is found, returns an empty string.\npub fn expand_clozes_to_reveal_latex(text: &str) -> String {\n    if !contains_latex(text) {\n        return \"\".into();\n    }\n    let ords = cloze_numbers_in_string(text);\n    let mut buf = String::new();\n    for ord in ords {\n        buf += reveal_cloze_text(text, ord, true).as_ref();\n        buf += reveal_cloze_text(text, ord, false).as_ref();\n    }\n\n    buf\n}\n\n// Whether `text` contains any cloze number above 0\npub(crate) fn contains_cloze(text: &str) -> bool {\n    parse_text_with_clozes(text)\n        .iter()\n        .any(|node| matches!(node, TextOrCloze::Cloze(e) if e.ordinals.iter().any(|&o| o != 0)))\n}\n\n/// Returns the set of cloze number as they appear in the fields's content.\npub fn cloze_numbers_in_string(html: &str) -> HashSet<u16> {\n    let mut set = HashSet::with_capacity(4);\n    add_cloze_numbers_in_string(html, &mut set);\n    set\n}\n\nfn add_cloze_numbers_in_text_with_clozes(nodes: &[TextOrCloze], set: &mut HashSet<u16>) {\n    for node in nodes {\n        if let TextOrCloze::Cloze(cloze) = node {\n            for &ordinal in &cloze.ordinals {\n                if ordinal != 0 {\n                    set.insert(ordinal);\n                }\n            }\n            add_cloze_numbers_in_text_with_clozes(&cloze.nodes, set);\n        }\n    }\n}\n\n/// Add to `set` the cloze numbers as they appear in `field`.\n#[allow(clippy::implicit_hasher)]\npub fn add_cloze_numbers_in_string(field: &str, set: &mut HashSet<u16>) {\n    add_cloze_numbers_in_text_with_clozes(&parse_text_with_clozes(field), set)\n}\n\n/// The set of cloze numbers as they appear in any of the fields from `fields`.\npub fn cloze_number_in_fields(fields: impl IntoIterator<Item: AsRef<str>>) -> HashSet<u16> {\n    let mut set = HashSet::with_capacity(4);\n    for field in fields {\n        add_cloze_numbers_in_string(field.as_ref(), &mut set);\n    }\n    set\n}\n\npub(crate) fn strip_clozes(text: &str) -> Cow<'_, str> {\n    CLOZE.replace_all(text, \"$1\")\n}\n\nfn strip_html_inside_mathjax(text: &str) -> Cow<'_, str> {\n    MATHJAX.replace_all(text, |caps: &Captures| -> String {\n        format!(\n            \"{}{}{}\",\n            caps.get(mathjax_caps::OPENING_TAG).unwrap().as_str(),\n            strip_html_preserving_entities(caps.get(mathjax_caps::INNER_TEXT).unwrap().as_str())\n                .as_ref(),\n            caps.get(mathjax_caps::CLOSING_TAG).unwrap().as_str()\n        )\n    })\n}\n\npub(crate) fn cloze_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> {\n    strip_html_inside_mathjax(\n        reveal_cloze_text(text, context.card_ord + 1, context.frontside.is_none()).as_ref(),\n    )\n    .into_owned()\n    .into()\n}\n\npub(crate) fn cloze_only_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> {\n    reveal_cloze_text_only(text, context.card_ord + 1, context.frontside.is_none())\n}\n\n#[cfg(test)]\nmod test {\n    use std::collections::HashSet;\n\n    use super::*;\n    use crate::text::strip_html;\n\n    #[test]\n    fn cloze() {\n        assert_eq!(\n            cloze_numbers_in_string(\"test\"),\n            vec![].into_iter().collect::<HashSet<u16>>()\n        );\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c2::te}}{{c1::s}}t{{\"),\n            vec![1, 2].into_iter().collect::<HashSet<u16>>()\n        );\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c0::te}}s{{c2::t}}s\"),\n            vec![2].into_iter().collect::<HashSet<u16>>()\n        );\n\n        assert_eq!(\n            expand_clozes_to_reveal_latex(\"{{c1::foo}} {{c2::bar::baz}}\"),\n            \"\".to_string()\n        );\n\n        let expanded = expand_clozes_to_reveal_latex(\"[latex]{{c1::foo}} {{c2::bar::baz}}[/latex]\");\n        let expanded = strip_html(expanded.as_ref());\n        assert!(expanded.contains(\"foo [baz]\"));\n        assert!(expanded.contains(\"[...] bar\"));\n        assert!(expanded.contains(\"foo bar\"));\n    }\n\n    #[test]\n    fn cloze_only() {\n        assert_eq!(reveal_cloze_text_only(\"foo\", 1, true), \"\");\n        assert_eq!(reveal_cloze_text_only(\"foo {{c1::bar}}\", 1, true), \"...\");\n        assert_eq!(\n            reveal_cloze_text_only(\"foo {{c1::bar::baz}}\", 1, true),\n            \"baz\"\n        );\n        assert_eq!(reveal_cloze_text_only(\"foo {{c1::bar}}\", 1, false), \"bar\");\n        assert_eq!(reveal_cloze_text_only(\"foo {{c1::bar}}\", 2, false), \"\");\n        assert_eq!(\n            reveal_cloze_text_only(\"{{c1::foo}} {{c1::bar}}\", 1, false),\n            \"foo, bar\"\n        );\n    }\n\n    #[test]\n    fn clozes_for_typing() {\n        assert_eq!(extract_cloze_for_typing(\"{{c2::foo}}\", 1), \"\");\n        assert_eq!(\n            extract_cloze_for_typing(\"{{c1::foo}} {{c1::bar}} {{c1::foo}}\", 1),\n            \"foo, bar, foo\"\n        );\n        assert_eq!(\n            extract_cloze_for_typing(\"{{c1::foo}} {{c1::foo}} {{c1::foo}}\", 1),\n            \"foo\"\n        );\n    }\n\n    #[test]\n    fn nested_cloze_plain_text() {\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}}}\", 1, true).as_ref()),\n            \"foo [...]\"\n        );\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}}}\", 1, false).as_ref()),\n            \"foo bar baz\"\n        );\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 2, true).as_ref()),\n            \"foo bar [...]\"\n        );\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 2, false).as_ref()),\n            \"foo bar baz\"\n        );\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 1, true).as_ref()),\n            \"foo [qux]\"\n        );\n        assert_eq!(\n            strip_html(reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 1, false).as_ref()),\n            \"foo bar baz\"\n        );\n    }\n\n    #[test]\n    fn nested_cloze_html() {\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c2::te{{c1::s}}}}t{{\"),\n            vec![1, 2].into_iter().collect::<HashSet<u16>>()\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}}}\", 1, true),\n            format!(\n                r#\"foo <span class=\"cloze\" data-cloze=\"{}\" data-ordinal=\"1\">[...]</span>\"#,\n                htmlescape::encode_attribute(\n                    r#\"bar <span class=\"cloze-inactive\" data-ordinal=\"2\">baz</span>\"#\n                )\n            )\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}}}\", 1, false),\n            r#\"foo <span class=\"cloze\" data-ordinal=\"1\">bar <span class=\"cloze-inactive\" data-ordinal=\"2\">baz</span></span>\"#\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 2, true),\n            r#\"foo <span class=\"cloze-inactive\" data-ordinal=\"1\">bar <span class=\"cloze\" data-cloze=\"baz\" data-ordinal=\"2\">[...]</span></span>\"#\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 2, false),\n            r#\"foo <span class=\"cloze-inactive\" data-ordinal=\"1\">bar <span class=\"cloze\" data-ordinal=\"2\">baz</span></span>\"#\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 1, true),\n            format!(\n                r#\"foo <span class=\"cloze\" data-cloze=\"{}\" data-ordinal=\"1\">[qux]</span>\"#,\n                htmlescape::encode_attribute(\n                    r#\"bar <span class=\"cloze-inactive\" data-ordinal=\"2\">baz</span>\"#\n                )\n            )\n        );\n        assert_eq!(\n            reveal_cloze_text(\"foo {{c1::bar {{c2::baz}}::qux}}\", 1, false),\n            r#\"foo <span class=\"cloze\" data-ordinal=\"1\">bar <span class=\"cloze-inactive\" data-ordinal=\"2\">baz</span></span>\"#\n        );\n    }\n\n    #[test]\n    fn strip_clozes_regex() {\n        assert_eq!(\n            strip_clozes(\n                r#\"The {{c1::moon::🌛}} {{c2::orbits::this hint has \"::\" in it}} the {{c3::🌏}}.\"#\n            ),\n            \"The moon orbits the 🌏.\"\n        );\n    }\n\n    #[test]\n    fn mathjax_html() {\n        // escaped angle brackets should be preserved\n        assert_eq!(\n            strip_html_inside_mathjax(r\"\\(<foo>&lt;&gt;</foo>\\)\"),\n            r\"\\(&lt;&gt;\\)\"\n        );\n    }\n\n    #[test]\n    fn non_latin() {\n        assert!(cloze_numbers_in_string(\"öaöaöööaö\").is_empty());\n    }\n\n    #[test]\n    fn image_cloze() {\n        assert_eq!(\n            reveal_cloze_text(\n                \"{{c1::image-occlusion:rect:left=10.0:top=20:width=30:height=10}}\",\n                1,\n                true\n            ),\n            format!(\n                r#\"<div class=\"cloze\" data-ordinal=\"1\" data-shape=\"rect\" data-left=\"10.0\" data-top=\"20\" data-width=\"30\" data-height=\"10\" ></div>\"#,\n            )\n        );\n    }\n\n    #[test]\n    fn multi_card_card_generation() {\n        let text = \"{{c1,2,3::multi}}\";\n        assert_eq!(\n            cloze_number_in_fields(vec![text]),\n            vec![1, 2, 3].into_iter().collect::<HashSet<u16>>()\n        );\n    }\n\n    #[test]\n    fn multi_card_cloze_basic() {\n        let text = \"{{c1,2::shared}} word and {{c1::first}} vs {{c2::second}}\";\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),\n            \"[...] word and [...] vs second\"\n        );\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),\n            \"[...] word and first vs [...]\"\n        );\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, false)).as_ref(),\n            \"shared word and first vs second\"\n        );\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 2, false)).as_ref(),\n            \"shared word and first vs second\"\n        );\n        assert_eq!(\n            cloze_numbers_in_string(text),\n            vec![1, 2].into_iter().collect::<HashSet<u16>>()\n        );\n    }\n\n    #[test]\n    fn multi_card_cloze_html_attributes() {\n        let text = \"{{c1,2,3::multi}}\";\n\n        let card1_html = reveal_cloze_text(text, 1, true);\n        assert!(card1_html.contains(r#\"data-ordinal=\"1,2,3\"\"#));\n\n        let card2_html = reveal_cloze_text(text, 2, true);\n        assert!(card2_html.contains(r#\"data-ordinal=\"1,2,3\"\"#));\n\n        let card3_html = reveal_cloze_text(text, 3, true);\n        assert!(card3_html.contains(r#\"data-ordinal=\"1,2,3\"\"#));\n    }\n\n    #[test]\n    fn multi_card_cloze_with_hints() {\n        let text = \"{{c1,2::answer::hint}}\";\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),\n            \"[hint]\"\n        );\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),\n            \"[hint]\"\n        );\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, false)).as_ref(),\n            \"answer\"\n        );\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 2, false)).as_ref(),\n            \"answer\"\n        );\n    }\n\n    #[test]\n    fn multi_card_cloze_edge_cases() {\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c1,1,2::test}}\"),\n            vec![1, 2].into_iter().collect::<HashSet<u16>>()\n        );\n\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c0,1,2::test}}\"),\n            vec![1, 2].into_iter().collect::<HashSet<u16>>()\n        );\n\n        assert_eq!(\n            cloze_numbers_in_string(\"{{c1,,3::test}}\"),\n            vec![1, 3].into_iter().collect::<HashSet<u16>>()\n        );\n    }\n\n    #[test]\n    fn multi_card_cloze_only_filter() {\n        let text = \"{{c1,2::shared}} and {{c1::first}} vs {{c2::second}}\";\n\n        assert_eq!(reveal_cloze_text_only(text, 1, true), \"..., ...\");\n        assert_eq!(reveal_cloze_text_only(text, 2, true), \"..., ...\");\n        assert_eq!(reveal_cloze_text_only(text, 1, false), \"shared, first\");\n        assert_eq!(reveal_cloze_text_only(text, 2, false), \"shared, second\");\n    }\n\n    #[test]\n    fn multi_card_nested_cloze() {\n        let text = \"{{c1,2::outer {{c3::inner}}}}\";\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),\n            \"[...]\"\n        );\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 2, true)).as_ref(),\n            \"[...]\"\n        );\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 3, true)).as_ref(),\n            \"outer [...]\"\n        );\n\n        assert_eq!(\n            cloze_numbers_in_string(text),\n            vec![1, 2, 3].into_iter().collect::<HashSet<u16>>()\n        );\n    }\n\n    #[test]\n    fn nested_parent_child_card_same_cloze() {\n        let text = \"{{c1::outer {{c1::inner}}}}\";\n\n        assert_eq!(\n            strip_html(&reveal_cloze_text(text, 1, true)).as_ref(),\n            \"[...]\"\n        );\n\n        assert_eq!(\n            cloze_numbers_in_string(text),\n            vec![1].into_iter().collect::<HashSet<u16>>()\n        );\n    }\n\n    #[test]\n    fn multi_card_image_occlusion() {\n        let text = \"{{c1,2::image-occlusion:rect:left=10:top=20:width=30:height=40}}\";\n\n        let occlusions = parse_image_occlusions(text);\n        assert_eq!(occlusions.len(), 2);\n        assert!(occlusions.iter().any(|o| o.ordinal == 1));\n        assert!(occlusions.iter().any(|o| o.ordinal == 2));\n\n        let card1_html = reveal_cloze_text(text, 1, true);\n        assert!(card1_html.contains(r#\"data-ordinal=\"1,2\"\"#));\n\n        let card2_html = reveal_cloze_text(text, 2, true);\n        assert!(card2_html.contains(r#\"data-ordinal=\"1,2\"\"#));\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/backup.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::ffi::OsStr;\nuse std::fs::read_dir;\nuse std::fs::remove_file;\nuse std::fs::DirEntry;\nuse std::path::Path;\nuse std::path::PathBuf;\nuse std::thread;\nuse std::thread::JoinHandle;\nuse std::time::SystemTime;\n\nuse anki_io::read_locked_db_file;\nuse anki_proto::config::preferences::BackupLimits;\nuse chrono::prelude::*;\nuse itertools::Itertools;\nuse tracing::error;\n\nuse crate::import_export::package::export_colpkg_from_data;\nuse crate::prelude::*;\n\nconst BACKUP_FORMAT_STRING: &str = \"backup-%Y-%m-%d-%H.%M.%S.colpkg\";\n\nimpl Collection {\n    /// Create a backup if enough time has elapsed, or if forced.\n    /// Returns a handle that can be awaited if a backup was created.\n    pub fn maybe_backup(\n        &mut self,\n        backup_folder: impl AsRef<Path> + Send + 'static,\n        force: bool,\n    ) -> Result<Option<JoinHandle<Result<()>>>> {\n        if !self.changed_since_last_backup()? {\n            return Ok(None);\n        }\n        let limits = self.get_backup_limits();\n        if should_skip_backup(force, limits.minimum_interval_mins, backup_folder.as_ref())? {\n            Ok(None)\n        } else {\n            let tr = self.tr.clone();\n            self.storage.checkpoint()?;\n            let col_data = read_locked_db_file(&self.col_path)?;\n            self.update_last_backup_timestamp()?;\n            Ok(Some(thread::spawn(move || {\n                backup_inner(&col_data, &backup_folder, limits, &tr)\n            })))\n        }\n    }\n}\n\nfn should_skip_backup(\n    force: bool,\n    minimum_interval_mins: u32,\n    backup_folder: &Path,\n) -> Result<bool> {\n    if force {\n        Ok(false)\n    } else {\n        has_recent_backup(backup_folder, minimum_interval_mins)\n    }\n}\n\nfn has_recent_backup(backup_folder: &Path, recent_mins: u32) -> Result<bool> {\n    let recent_secs = (recent_mins * 60) as u64;\n    let now = SystemTime::now();\n    Ok(read_dir(backup_folder)?\n        .filter_map(|res| res.ok())\n        .filter_map(|entry| entry.metadata().ok())\n        .filter_map(|meta| {\n            // created time unsupported on Android\n            #[cfg(target_os = \"android\")]\n            {\n                meta.modified().ok()\n            }\n            #[cfg(not(target_os = \"android\"))]\n            {\n                meta.created().ok()\n            }\n        })\n        .filter_map(|time| now.duration_since(time).ok())\n        .any(|duration| duration.as_secs() < recent_secs))\n}\n\nfn backup_inner<P: AsRef<Path>>(\n    col_data: &[u8],\n    backup_folder: P,\n    limits: BackupLimits,\n    tr: &I18n,\n) -> Result<()> {\n    write_backup(col_data, backup_folder.as_ref(), tr)?;\n    thin_backups(backup_folder, limits)\n}\n\nfn write_backup<S: AsRef<OsStr>>(col_data: &[u8], backup_folder: S, tr: &I18n) -> Result<()> {\n    let out_path =\n        Path::new(&backup_folder).join(format!(\"{}\", Local::now().format(BACKUP_FORMAT_STRING)));\n    export_colpkg_from_data(out_path, col_data, tr)\n}\n\nfn thin_backups<P: AsRef<Path>>(backup_folder: P, limits: BackupLimits) -> Result<()> {\n    let backups =\n        read_dir(backup_folder)?.filter_map(|entry| entry.ok().and_then(Backup::from_entry));\n    let obsolete_backups = BackupFilter::new(Local::now(), limits).obsolete_backups(backups);\n    for backup in obsolete_backups {\n        if let Err(error) = remove_file(&backup.path) {\n            error!(\"failed to remove {:?}: {error:?}\", &backup.path);\n        };\n    }\n\n    Ok(())\n}\n\nfn datetime_from_file_name(file_name: &str) -> Option<DateTime<Local>> {\n    NaiveDateTime::parse_from_str(file_name, BACKUP_FORMAT_STRING)\n        .ok()\n        .and_then(|datetime| Local.from_local_datetime(&datetime).latest())\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\nstruct Backup {\n    path: PathBuf,\n    datetime: DateTime<Local>,\n}\n\nimpl Backup {\n    /// Serial day number\n    fn day(&self) -> i32 {\n        self.datetime.num_days_from_ce()\n    }\n\n    /// Serial week number, starting on Monday\n    fn week(&self) -> i32 {\n        // Day 1 (01/01/01) was a Monday, meaning week rolled over on Sunday (when day %\n        // 7 == 0). We subtract 1 to shift the rollover to Monday.\n        (self.day() - 1) / 7\n    }\n\n    /// Serial month number\n    fn month(&self) -> u32 {\n        self.datetime.year() as u32 * 12 + self.datetime.month()\n    }\n}\n\nimpl Backup {\n    fn from_entry(entry: DirEntry) -> Option<Self> {\n        entry\n            .file_name()\n            .to_str()\n            .and_then(datetime_from_file_name)\n            .map(|datetime| Self {\n                path: entry.path(),\n                datetime,\n            })\n    }\n}\n\n#[derive(Debug)]\nstruct BackupFilter {\n    yesterday: i32,\n    last_kept_day: i32,\n    last_kept_week: i32,\n    last_kept_month: u32,\n    limits: BackupLimits,\n    obsolete: Vec<Backup>,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum BackupStage {\n    Daily,\n    Weekly,\n    Monthly,\n}\n\nimpl BackupFilter {\n    fn new(today: DateTime<Local>, limits: BackupLimits) -> Self {\n        Self {\n            yesterday: today.num_days_from_ce() - 1,\n            last_kept_day: i32::MAX,\n            last_kept_week: i32::MAX,\n            last_kept_month: u32::MAX,\n            limits,\n            obsolete: Vec::new(),\n        }\n    }\n\n    fn obsolete_backups(mut self, backups: impl Iterator<Item = Backup>) -> Vec<Backup> {\n        use BackupStage::*;\n\n        for backup in backups\n            .sorted_unstable_by_key(|b| b.datetime.timestamp())\n            .rev()\n        {\n            if self.is_recent(&backup) {\n                self.mark_fresh(None, backup);\n            } else if self.remaining(Daily) {\n                self.mark_fresh_or_obsolete(Daily, backup);\n            } else if self.remaining(Weekly) {\n                self.mark_fresh_or_obsolete(Weekly, backup);\n            } else if self.remaining(Monthly) {\n                self.mark_fresh_or_obsolete(Monthly, backup);\n            } else {\n                self.mark_obsolete(backup);\n            }\n        }\n\n        self.obsolete\n    }\n\n    fn is_recent(&self, backup: &Backup) -> bool {\n        backup.day() >= self.yesterday\n    }\n\n    fn remaining(&self, stage: BackupStage) -> bool {\n        match stage {\n            BackupStage::Daily => self.limits.daily > 0,\n            BackupStage::Weekly => self.limits.weekly > 0,\n            BackupStage::Monthly => self.limits.monthly > 0,\n        }\n    }\n\n    fn mark_fresh_or_obsolete(&mut self, stage: BackupStage, backup: Backup) {\n        let keep = match stage {\n            BackupStage::Daily => backup.day() < self.last_kept_day,\n            BackupStage::Weekly => backup.week() < self.last_kept_week,\n            BackupStage::Monthly => backup.month() < self.last_kept_month,\n        };\n        if keep {\n            self.mark_fresh(Some(stage), backup);\n        } else {\n            self.mark_obsolete(backup);\n        }\n    }\n\n    /// Adjusts limits as per the stage of the kept backup, and last kept times.\n    fn mark_fresh(&mut self, stage: Option<BackupStage>, backup: Backup) {\n        self.last_kept_day = backup.day();\n        self.last_kept_week = backup.week();\n        self.last_kept_month = backup.month();\n        match stage {\n            None => (),\n            Some(BackupStage::Daily) => self.limits.daily -= 1,\n            Some(BackupStage::Weekly) => self.limits.weekly -= 1,\n            Some(BackupStage::Monthly) => self.limits.monthly -= 1,\n        }\n    }\n\n    fn mark_obsolete(&mut self, backup: Backup) {\n        self.obsolete.push(backup);\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    macro_rules! backup {\n        ($num_days_from_ce:expr) => {\n            Backup {\n                datetime: Local\n                    .from_local_datetime(\n                        &NaiveDate::from_num_days_from_ce_opt($num_days_from_ce)\n                            .unwrap()\n                            .and_hms_opt(0, 0, 0)\n                            .unwrap(),\n                    )\n                    .latest()\n                    .unwrap(),\n                path: PathBuf::new(),\n            }\n        };\n        ($year:expr, $month:expr, $day:expr) => {\n            Backup {\n                datetime: Local\n                    .with_ymd_and_hms($year, $month, $day, 0, 0, 0)\n                    .latest()\n                    .unwrap(),\n                path: PathBuf::new(),\n            }\n        };\n        ($year:expr, $month:expr, $day:expr, $hour:expr, $min:expr, $sec:expr) => {\n            Backup {\n                datetime: Local\n                    .with_ymd_and_hms($year, $month, $day, $hour, $min, $sec)\n                    .latest()\n                    .unwrap(),\n                path: PathBuf::new(),\n            }\n        };\n    }\n\n    #[test]\n    fn thinning_manual() {\n        let today = Local\n            .with_ymd_and_hms(2022, 2, 22, 0, 0, 0)\n            .latest()\n            .unwrap();\n        let limits = BackupLimits {\n            daily: 3,\n            weekly: 2,\n            monthly: 1,\n            ..Default::default()\n        };\n\n        // true => should be removed\n        let backups = [\n            // grace period\n            (backup!(2022, 2, 22), false),\n            (backup!(2022, 2, 22), false),\n            (backup!(2022, 2, 21), false),\n            // daily\n            (backup!(2022, 2, 20, 6, 0, 0), true),\n            (backup!(2022, 2, 20, 18, 0, 0), false),\n            (backup!(2022, 2, 10), false),\n            (backup!(2022, 2, 9), false),\n            // weekly\n            (backup!(2022, 2, 7), true), // Monday, week already backed up\n            (backup!(2022, 2, 6, 1, 0, 0), true),\n            (backup!(2022, 2, 6, 2, 0, 0), false),\n            (backup!(2022, 1, 6), false),\n            // monthly\n            (backup!(2022, 1, 5), true),\n            (backup!(2021, 12, 24), false),\n            (backup!(2021, 12, 1), true),\n            (backup!(2021, 11, 1), true),\n        ];\n\n        let expected: Vec<_> = backups\n            .iter()\n            .filter(|b| b.1)\n            .map(|b| b.0.clone())\n            .collect();\n        let obsolete_backups =\n            BackupFilter::new(today, limits).obsolete_backups(backups.into_iter().map(|b| b.0));\n\n        assert_eq!(obsolete_backups, expected);\n    }\n\n    #[test]\n    fn thinning_generic() {\n        let today = Local\n            .with_ymd_and_hms(2022, 1, 1, 0, 0, 0)\n            .latest()\n            .unwrap();\n        let today_ce_days = today.num_days_from_ce();\n        let limits = BackupLimits {\n            // config defaults\n            daily: 12,\n            weekly: 10,\n            monthly: 9,\n            ..Default::default()\n        };\n        let backups: Vec<_> = (1..366).map(|i| backup!(today_ce_days - i)).collect();\n        let mut expected = Vec::new();\n\n        // one day grace period, then daily backups\n        let mut backup_iter = backups.iter().skip(1 + limits.daily as usize);\n\n        // weekly backups from the last day of the week (Sunday)\n        for _ in 0..limits.weekly {\n            for backup in backup_iter.by_ref() {\n                if backup.datetime.weekday() == Weekday::Sun {\n                    break;\n                } else {\n                    expected.push(backup.clone())\n                }\n            }\n        }\n\n        // monthly backups from the last day of the month\n        for _ in 0..limits.monthly {\n            for backup in backup_iter.by_ref() {\n                if backup.datetime.month()\n                    != backup.datetime.date_naive().succ_opt().unwrap().month()\n                {\n                    break;\n                } else {\n                    expected.push(backup.clone())\n                }\n            }\n        }\n\n        // limits reached; collect rest\n        backup_iter\n            .cloned()\n            .for_each(|backup| expected.push(backup));\n\n        let obsolete_backups =\n            BackupFilter::new(today, limits).obsolete_backups(backups.into_iter());\n        assert_eq!(obsolete_backups, expected);\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod backup;\nmod service;\npub(crate) mod timestamps;\nmod transact;\npub(crate) mod undo;\n\nuse std::collections::HashMap;\nuse std::fmt::Debug;\nuse std::fmt::Formatter;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse std::sync::Mutex;\n\nuse anki_i18n::I18n;\nuse anki_io::create_dir_all;\n\nuse crate::browser_table;\nuse crate::decks::Deck;\nuse crate::decks::DeckId;\nuse crate::error::Result;\nuse crate::notetype::Notetype;\nuse crate::notetype::NotetypeId;\nuse crate::progress::ProgressState;\nuse crate::scheduler::queue::CardQueues;\nuse crate::scheduler::SchedulerInfo;\nuse crate::storage::SchemaVersion;\nuse crate::storage::SqliteStorage;\nuse crate::timestamp::TimestampMillis;\nuse crate::types::Usn;\nuse crate::undo::UndoManager;\n\n#[derive(Default)]\npub struct CollectionBuilder {\n    collection_path: Option<PathBuf>,\n    media_folder: Option<PathBuf>,\n    media_db: Option<PathBuf>,\n    server: Option<bool>,\n    tr: Option<I18n>,\n    check_integrity: bool,\n    progress_handler: Option<Arc<Mutex<ProgressState>>>,\n}\n\nimpl CollectionBuilder {\n    /// Create a new builder with the provided collection path.\n    /// If an in-memory database is desired, used ::default() instead.\n    pub fn new(col_path: impl Into<PathBuf>) -> Self {\n        let mut builder = Self::default();\n        builder.set_collection_path(col_path);\n        builder\n    }\n\n    pub fn build(&mut self) -> Result<Collection> {\n        let col_path = self\n            .collection_path\n            .clone()\n            .unwrap_or_else(|| PathBuf::from(\":memory:\"));\n        let tr = self.tr.clone().unwrap_or_else(I18n::template_only);\n        let server = self.server.unwrap_or_default();\n        let media_folder = self.media_folder.clone().unwrap_or_default();\n        let media_db = self.media_db.clone().unwrap_or_default();\n        let storage = SqliteStorage::open_or_create(&col_path, &tr, server, self.check_integrity)?;\n        let col = Collection {\n            storage,\n            col_path,\n            media_folder,\n            media_db,\n            tr,\n            server,\n            state: CollectionState {\n                progress: self.progress_handler.clone().unwrap_or_default(),\n                ..Default::default()\n            },\n        };\n\n        Ok(col)\n    }\n\n    pub fn set_collection_path<P: Into<PathBuf>>(&mut self, collection: P) -> &mut Self {\n        self.collection_path = Some(collection.into());\n        self\n    }\n\n    pub fn set_media_paths<P: Into<PathBuf>>(&mut self, media_folder: P, media_db: P) -> &mut Self {\n        self.media_folder = Some(media_folder.into());\n        self.media_db = Some(media_db.into());\n        self\n    }\n\n    /// For a `foo.anki2` file, use `foo.media` and `foo.mdb`. Mobile clients\n    /// use different paths, so the backend must continue to use\n    /// [set_media_paths].\n    pub fn with_desktop_media_paths(&mut self) -> &mut Self {\n        let col_path = self.collection_path.as_ref().unwrap();\n        let media_folder = col_path.with_extension(\"media\");\n        create_dir_all(&media_folder).expect(\"creating media folder\");\n        let media_db = col_path.with_extension(\"mdb\");\n        self.set_media_paths(media_folder, media_db)\n    }\n\n    pub fn set_server(&mut self, server: bool) -> &mut Self {\n        self.server = Some(server);\n        self\n    }\n\n    pub fn set_tr(&mut self, tr: I18n) -> &mut Self {\n        self.tr = Some(tr);\n        self\n    }\n\n    pub fn set_check_integrity(&mut self, check_integrity: bool) -> &mut Self {\n        self.check_integrity = check_integrity;\n        self\n    }\n\n    /// If provided, progress info will be written to the provided mutex, and\n    /// can be tracked on a separate thread.\n    pub fn set_shared_progress_state(&mut self, state: Arc<Mutex<ProgressState>>) -> &mut Self {\n        self.progress_handler = Some(state);\n        self\n    }\n}\n\n#[derive(Debug, Default)]\npub struct CollectionState {\n    pub(crate) undo: UndoManager,\n    pub(crate) notetype_cache: HashMap<NotetypeId, Arc<Notetype>>,\n    pub(crate) deck_cache: HashMap<DeckId, Arc<Deck>>,\n    pub(crate) scheduler_info: Option<SchedulerInfo>,\n    pub(crate) card_queues: Option<CardQueues>,\n    pub(crate) active_browser_columns: Option<Arc<Vec<browser_table::Column>>>,\n    /// True if legacy Python code has executed SQL that has modified the\n    /// database, requiring modification time to be bumped.\n    pub(crate) modified_by_dbproxy: bool,\n    /// The modification time at the last backup, so we don't create multiple\n    /// identical backups.\n    pub(crate) last_backup_modified: Option<TimestampMillis>,\n    pub(crate) progress: Arc<Mutex<ProgressState>>,\n}\n\npub struct Collection {\n    pub storage: SqliteStorage,\n    pub(crate) col_path: PathBuf,\n    pub(crate) media_folder: PathBuf,\n    pub(crate) media_db: PathBuf,\n    pub(crate) tr: I18n,\n    pub(crate) server: bool,\n    pub(crate) state: CollectionState,\n}\n\nimpl Debug for Collection {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Collection\")\n            .field(\"col_path\", &self.col_path)\n            .finish()\n    }\n}\n\nimpl Collection {\n    pub fn as_builder(&self) -> CollectionBuilder {\n        let mut builder = CollectionBuilder::new(&self.col_path);\n        builder\n            .set_media_paths(self.media_folder.clone(), self.media_db.clone())\n            .set_server(self.server)\n            .set_tr(self.tr.clone())\n            .set_shared_progress_state(self.state.progress.clone());\n        builder\n    }\n\n    // A count of all changed rows since the collection was opened, which can be\n    // used to detect if the collection was modified or not.\n    pub fn changes_since_open(&self) -> Result<u64> {\n        self.storage\n            .db\n            .query_row(\"select total_changes()\", [], |row| row.get(0))\n            .map_err(Into::into)\n    }\n\n    pub fn close(self, desired_version: Option<SchemaVersion>) -> Result<()> {\n        self.storage.close(desired_version)\n    }\n\n    pub(crate) fn usn(&self) -> Result<Usn> {\n        // if we cache this in the future, must make sure to invalidate cache when usn\n        // bumped in sync.finish()\n        self.storage.usn(self.server)\n    }\n\n    /// Prepare for upload. Caller should not create transaction.\n    pub(crate) fn before_upload(&mut self) -> Result<()> {\n        self.transact_no_undo(|col| {\n            col.storage.clear_all_graves()?;\n            col.storage.clear_pending_note_usns()?;\n            col.storage.clear_pending_card_usns()?;\n            col.storage.clear_pending_revlog_usns()?;\n            col.storage.clear_tag_usns()?;\n            col.storage.clear_deck_conf_usns()?;\n            col.storage.clear_deck_usns()?;\n            col.storage.clear_notetype_usns()?;\n            col.storage.increment_usn()?;\n            col.set_schema_modified()?;\n            col.storage\n                .set_last_sync(col.storage.get_collection_timestamps()?.schema_change)\n        })?;\n        self.storage.optimize()\n    }\n\n    pub(crate) fn clear_caches(&mut self) {\n        self.state.deck_cache.clear();\n        self.state.notetype_cache.clear();\n    }\n\n    pub fn tr(&self) -> &I18n {\n        &self.tr\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::collection::GetCustomColoursResponse;\nuse anki_proto::generic;\n\nuse crate::collection::Collection;\nuse crate::config::ConfigKey;\nuse crate::error;\nuse crate::prelude::BoolKey;\nuse crate::prelude::Op;\nuse crate::progress::progress_to_proto;\n\nimpl crate::services::CollectionService for Collection {\n    fn check_database(&mut self) -> error::Result<anki_proto::collection::CheckDatabaseResponse> {\n        {\n            self.check_database()\n                .map(|problems| anki_proto::collection::CheckDatabaseResponse {\n                    problems: problems.to_i18n_strings(&self.tr),\n                })\n        }\n    }\n\n    fn get_undo_status(&mut self) -> error::Result<anki_proto::collection::UndoStatus> {\n        Ok(self.undo_status().into_protobuf(&self.tr))\n    }\n\n    fn undo(&mut self) -> error::Result<anki_proto::collection::OpChangesAfterUndo> {\n        self.undo().map(|out| out.into_protobuf(&self.tr))\n    }\n\n    fn redo(&mut self) -> error::Result<anki_proto::collection::OpChangesAfterUndo> {\n        self.redo().map(|out| out.into_protobuf(&self.tr))\n    }\n\n    fn add_custom_undo_entry(&mut self, input: generic::String) -> error::Result<generic::UInt32> {\n        Ok(self.add_custom_undo_step(input.val).into())\n    }\n\n    fn merge_undo_entries(\n        &mut self,\n        input: generic::UInt32,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let starting_from = input.val as usize;\n        self.merge_undoable_ops(starting_from).map(Into::into)\n    }\n\n    fn latest_progress(&mut self) -> error::Result<anki_proto::collection::Progress> {\n        let progress = self.state.progress.lock().unwrap().last_progress;\n        Ok(progress_to_proto(progress, &self.tr))\n    }\n\n    fn set_wants_abort(&mut self) -> error::Result<()> {\n        self.state.progress.lock().unwrap().want_abort = true;\n        Ok(())\n    }\n\n    fn set_load_balancer_enabled(\n        &mut self,\n        input: generic::Bool,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.transact(Op::ToggleLoadBalancer, |col| {\n            col.set_config(BoolKey::LoadBalancerEnabled, &input.val)?;\n            Ok(())\n        })\n        .map(Into::into)\n    }\n\n    fn get_custom_colours(\n        &mut self,\n    ) -> error::Result<anki_proto::collection::GetCustomColoursResponse> {\n        let colours = self\n            .get_config_optional(ConfigKey::CustomColorPickerPalette)\n            .unwrap_or_default();\n        Ok(GetCustomColoursResponse { colours })\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/timestamps.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\npub(crate) struct CollectionTimestamps {\n    pub collection_change: TimestampMillis,\n    pub schema_change: TimestampMillis,\n    pub last_sync: TimestampMillis,\n}\n\nimpl CollectionTimestamps {\n    pub fn collection_changed_since_sync(&self) -> bool {\n        self.collection_change > self.last_sync\n    }\n\n    pub fn schema_changed_since_sync(&self) -> bool {\n        self.schema_change > self.last_sync\n    }\n}\n\nimpl Collection {\n    /// This is done automatically when you call collection methods, so callers\n    /// outside this crate should only need this if you are manually\n    /// modifying the database.\n    pub fn set_modified(&mut self) -> Result<()> {\n        let stamps = self.storage.get_collection_timestamps()?;\n        self.set_modified_time_undoable(TimestampMillis::now(), stamps.collection_change)\n    }\n\n    /// Forces the next sync in one direction.\n    pub fn set_schema_modified(&mut self) -> Result<()> {\n        let stamps = self.storage.get_collection_timestamps()?;\n        self.set_schema_modified_time_undoable(TimestampMillis::now(), stamps.schema_change)\n    }\n\n    pub fn changed_since_last_backup(&self) -> Result<bool> {\n        let stamps = self.storage.get_collection_timestamps()?;\n        Ok(self\n            .state\n            .last_backup_modified\n            .map(|last_backup| last_backup != stamps.collection_change)\n            .unwrap_or(true))\n    }\n\n    pub(crate) fn update_last_backup_timestamp(&mut self) -> Result<()> {\n        self.state.last_backup_modified =\n            Some(self.storage.get_collection_timestamps()?.collection_change);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/transact.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::ops::StateChanges;\nuse crate::prelude::*;\n\nimpl Collection {\n    fn transact_inner<F, R>(&mut self, op: Option<Op>, func: F) -> Result<OpOutput<R>>\n    where\n        F: FnOnce(&mut Collection) -> Result<R>,\n    {\n        let have_op = op.is_some();\n        let skip_undo_queue = op == Some(Op::SkipUndo);\n        let autocommit = self.storage.db.is_autocommit();\n\n        self.storage.begin_rust_trx()?;\n        self.begin_undoable_operation(op);\n\n        func(self)\n            .and_then(|output| {\n                // any changes mean an mtime bump\n                if !have_op || (self.current_undo_step_has_changes() && !self.undoing_or_redoing())\n                {\n                    self.set_modified()?;\n                }\n                // then commit\n                self.storage.commit_rust_trx()?;\n                // finalize undo\n                let changes = if have_op {\n                    let changes = self.op_changes();\n                    self.maybe_clear_study_queues_after_op(&changes);\n                    self.maybe_coalesce_note_undo_entry(&changes);\n                    changes\n                } else {\n                    self.clear_study_queues();\n                    // dummy value that will be discarded\n                    OpChanges {\n                        op: Op::SkipUndo,\n                        changes: StateChanges::default(),\n                    }\n                };\n                self.end_undoable_operation(skip_undo_queue);\n                Ok(OpOutput { output, changes })\n            })\n            // roll back on error\n            .or_else(|err| {\n                self.discard_undo_and_study_queues();\n                if autocommit {\n                    self.storage.rollback_trx()?;\n                } else {\n                    self.storage.rollback_rust_trx()?;\n                }\n                Err(err)\n            })\n    }\n\n    /// Execute the provided closure in a transaction, rolling back if\n    /// an error is returned. Records undo state, and returns changes.\n    pub(crate) fn transact<F, R>(&mut self, op: Op, func: F) -> Result<OpOutput<R>>\n    where\n        F: FnOnce(&mut Collection) -> Result<R>,\n    {\n        self.transact_inner(Some(op), func)\n    }\n\n    /// Execute the provided closure in a transaction, rolling back if\n    /// an error is returned.\n    pub(crate) fn transact_no_undo<F, R>(&mut self, func: F) -> Result<R>\n    where\n        F: FnOnce(&mut Collection) -> Result<R>,\n    {\n        self.transact_inner(None, func).map(|out| out.output)\n    }\n}\n"
  },
  {
    "path": "rslib/src/collection/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableCollectionChange {\n    Schema(TimestampMillis),\n    Modified(TimestampMillis),\n}\n\nimpl Collection {\n    pub(crate) fn undo_collection_change(\n        &mut self,\n        change: UndoableCollectionChange,\n    ) -> Result<()> {\n        match change {\n            UndoableCollectionChange::Schema(schema) => {\n                let current = self.storage.get_collection_timestamps()?.schema_change;\n                self.set_schema_modified_time_undoable(schema, current)\n            }\n            UndoableCollectionChange::Modified(modified) => {\n                let current = self.storage.get_collection_timestamps()?.collection_change;\n                self.set_modified_time_undoable(modified, current)\n            }\n        }\n    }\n\n    pub(super) fn set_modified_time_undoable(\n        &mut self,\n        modified: TimestampMillis,\n        original: TimestampMillis,\n    ) -> Result<()> {\n        self.save_undo(UndoableCollectionChange::Modified(original));\n        self.storage.set_modified_time(modified)\n    }\n\n    pub(super) fn set_schema_modified_time_undoable(\n        &mut self,\n        schema: TimestampMillis,\n        original: TimestampMillis,\n    ) -> Result<()> {\n        self.save_undo(UndoableCollectionChange::Schema(original));\n        self.storage.set_schema_modified_time(schema)\n    }\n}\n"
  },
  {
    "path": "rslib/src/config/bool.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoStaticStr;\n\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, Copy, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub enum BoolKey {\n    ApplyAllParentLimits,\n    BrowserTableShowNotesMode,\n    CardCountsSeparateInactive,\n    CollapseCardState,\n    CollapseDecks,\n    CollapseFlags,\n    CollapseNotetypes,\n    CollapseSavedSearches,\n    CollapseTags,\n    CollapseToday,\n    FutureDueShowBacklog,\n    HideAudioPlayButtons,\n    IgnoreAccentsInSearch,\n    InterruptAudioWhenAnswering,\n    NewCardsIgnoreReviewLimit,\n    PasteImagesAsPng,\n    PasteStripsFormatting,\n    RenderLatex,\n    PreviewBothSides,\n    RestorePositionBrowser,\n    RestorePositionReviewer,\n    ResetCountsBrowser,\n    ResetCountsReviewer,\n    RandomOrderReposition,\n    Sched2021,\n    ShiftPositionOfExistingCards,\n    MergeNotetypes,\n    WithScheduling,\n    WithDeckConfigs,\n    Fsrs,\n    FsrsHealthCheck,\n    FsrsLegacyEvaluate,\n    LoadBalancerEnabled,\n    FsrsShortTermWithStepsEnabled,\n    #[strum(to_string = \"normalize_note_text\")]\n    NormalizeNoteText,\n    #[strum(to_string = \"dayLearnFirst\")]\n    ShowDayLearningCardsFirst,\n    #[strum(to_string = \"estTimes\")]\n    ShowIntervalsAboveAnswerButtons,\n    #[strum(to_string = \"dueCounts\")]\n    ShowRemainingDueCountsInStudy,\n    #[strum(to_string = \"addToCur\")]\n    AddingDefaultsToCurrentDeck,\n}\n\nimpl Collection {\n    pub fn get_config_bool(&self, key: BoolKey) -> bool {\n        match key {\n            // some keys default to true\n            BoolKey::InterruptAudioWhenAnswering\n            | BoolKey::ShowIntervalsAboveAnswerButtons\n            | BoolKey::AddingDefaultsToCurrentDeck\n            | BoolKey::FutureDueShowBacklog\n            | BoolKey::ShowRemainingDueCountsInStudy\n            | BoolKey::CardCountsSeparateInactive\n            | BoolKey::RestorePositionBrowser\n            | BoolKey::RestorePositionReviewer\n            | BoolKey::LoadBalancerEnabled\n            | BoolKey::FsrsHealthCheck\n            | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true),\n\n            // other options default to false\n            other => self.get_config_default(other),\n        }\n    }\n\n    pub fn set_config_bool(\n        &mut self,\n        key: BoolKey,\n        value: bool,\n        undoable: bool,\n    ) -> Result<OpOutput<()>> {\n        let op = if undoable {\n            Op::UpdateConfig\n        } else {\n            Op::SkipUndo\n        };\n        self.transact(op, |col| {\n            col.set_config(key, &value)?;\n            Ok(())\n        })\n    }\n}\n\nimpl Collection {\n    pub(crate) fn set_config_bool_inner(&mut self, key: BoolKey, value: bool) -> Result<bool> {\n        self.set_config(key, &value)\n    }\n}\n"
  },
  {
    "path": "rslib/src/config/deck.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoStaticStr;\n\nuse crate::prelude::*;\n\n/// Auxiliary deck state, stored in the config table.\n#[derive(Debug, Clone, Copy, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub enum DeckConfigKey {\n    LastNotetype,\n    CustomStudyIncludeTags,\n    CustomStudyExcludeTags,\n}\n\nimpl DeckConfigKey {\n    pub fn for_deck(self, did: DeckId) -> String {\n        build_aux_deck_key(did, <&'static str>::from(self))\n    }\n}\n\nimpl Collection {\n    pub(crate) fn clear_aux_config_for_deck(&mut self, ntid: DeckId) -> Result<()> {\n        self.remove_config_prefix(&build_aux_deck_key(ntid, \"\"))\n    }\n\n    pub(crate) fn get_last_notetype_for_deck(&self, id: DeckId) -> Option<NotetypeId> {\n        let key = DeckConfigKey::LastNotetype.for_deck(id);\n        self.get_config_optional(key.as_str())\n    }\n\n    pub(crate) fn set_last_notetype_for_deck(\n        &mut self,\n        did: DeckId,\n        ntid: NotetypeId,\n    ) -> Result<bool> {\n        let key = DeckConfigKey::LastNotetype.for_deck(did);\n        self.set_config(key.as_str(), &ntid)\n    }\n}\n\nfn build_aux_deck_key(deck: DeckId, key: &str) -> String {\n    format!(\"_deck_{deck}_{key}\")\n}\n"
  },
  {
    "path": "rslib/src/config/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod bool;\nmod deck;\nmod notetype;\nmod number;\npub(crate) mod schema11;\nmod string;\npub(crate) mod undo;\n\nuse anki_proto::config::preferences::BackupLimits;\nuse serde::de::DeserializeOwned;\nuse serde::Serialize;\nuse serde_repr::Deserialize_repr;\nuse serde_repr::Serialize_repr;\nuse strum::IntoStaticStr;\n\npub use self::bool::BoolKey;\npub use self::deck::DeckConfigKey;\npub use self::notetype::get_aux_notetype_config_key;\npub use self::number::I32ConfigKey;\npub use self::string::StringKey;\nuse crate::import_export::package::UpdateCondition;\nuse crate::prelude::*;\n\n/// Only used when updating/undoing.\n#[derive(Debug)]\npub(crate) struct ConfigEntry {\n    pub key: String,\n    pub value: Vec<u8>,\n    pub usn: Usn,\n    pub mtime: TimestampSecs,\n}\n\nimpl ConfigEntry {\n    pub(crate) fn boxed(key: &str, value: Vec<u8>, usn: Usn, mtime: TimestampSecs) -> Box<Self> {\n        Box::new(Self {\n            key: key.into(),\n            value,\n            usn,\n            mtime,\n        })\n    }\n}\n\n#[derive(IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub(crate) enum ConfigKey {\n    CreationOffset,\n    FirstDayOfWeek,\n    LocalOffset,\n    Rollover,\n    Backups,\n    UpdateNotes,\n    UpdateNotetypes,\n\n    #[strum(to_string = \"timeLim\")]\n    AnswerTimeLimitSecs,\n    #[strum(to_string = \"curDeck\")]\n    CurrentDeckId,\n    #[strum(to_string = \"curModel\")]\n    CurrentNotetypeId,\n    #[strum(to_string = \"lastUnburied\")]\n    LastUnburiedDay,\n    #[strum(to_string = \"collapseTime\")]\n    LearnAheadSecs,\n    #[strum(to_string = \"newSpread\")]\n    NewReviewMix,\n    #[strum(to_string = \"nextPos\")]\n    NextNewCardPosition,\n    #[strum(to_string = \"schedVer\")]\n    SchedulerVersion,\n    CustomColorPickerPalette,\n}\n\n#[derive(PartialEq, Eq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)]\n#[repr(u8)]\npub enum SchedulerVersion {\n    V1 = 1,\n    V2 = 2,\n}\n\nimpl Collection {\n    pub fn set_config_json<T: Serialize>(\n        &mut self,\n        key: &str,\n        val: &T,\n        undoable: bool,\n    ) -> Result<OpOutput<()>> {\n        let op = if undoable {\n            Op::UpdateConfig\n        } else {\n            Op::SkipUndo\n        };\n        self.transact(op, |col| {\n            col.set_config(key, val)?;\n            Ok(())\n        })\n    }\n\n    pub fn remove_config(&mut self, key: &str) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateConfig, |col| col.remove_config_inner(key))\n    }\n}\n\nimpl Collection {\n    /// Get config item, returning None if missing/invalid.\n    pub(crate) fn get_config_optional<'a, T, K>(&self, key: K) -> Option<T>\n    where\n        T: DeserializeOwned,\n        K: Into<&'a str>,\n    {\n        let key = key.into();\n        match self.storage.get_config_value(key) {\n            Ok(Some(val)) => Some(val),\n            Ok(None) => None,\n            // If the key is missing or invalid, we use the default value.\n            Err(_) => None,\n        }\n    }\n\n    // /// Get config item, returning default value if missing/invalid.\n    pub(crate) fn get_config_default<'a, T, K>(&self, key: K) -> T\n    where\n        T: DeserializeOwned + Default,\n        K: Into<&'a str>,\n    {\n        self.get_config_optional(key).unwrap_or_default()\n    }\n\n    /// True if added, or new value is different.\n    pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result<bool>\n    where\n        K: Into<&'a str>,\n    {\n        let entry = ConfigEntry::boxed(\n            key.into(),\n            serde_json::to_vec(val)?,\n            self.usn()?,\n            TimestampSecs::now(),\n        );\n        self.set_config_undoable(entry)\n    }\n\n    pub(crate) fn remove_config_inner<'a, K>(&mut self, key: K) -> Result<()>\n    where\n        K: Into<&'a str>,\n    {\n        self.remove_config_undoable(key.into())\n    }\n\n    /// Remove all keys starting with provided prefix, which must end with '_'.\n    pub(crate) fn remove_config_prefix(&mut self, key: &str) -> Result<()> {\n        for (key, _val) in self.storage.get_config_prefix(key)? {\n            self.remove_config_inner(key.as_str())?;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn get_creation_utc_offset(&self) -> Option<i32> {\n        self.get_config_optional(ConfigKey::CreationOffset)\n    }\n\n    pub(crate) fn set_creation_utc_offset(&mut self, mins: Option<i32>) -> Result<()> {\n        self.state.scheduler_info = None;\n        if let Some(mins) = mins {\n            self.set_config(ConfigKey::CreationOffset, &mins)\n                .map(|_| ())\n        } else {\n            self.remove_config_inner(ConfigKey::CreationOffset)\n        }\n    }\n\n    /// In minutes west of UTC.\n    pub fn get_configured_utc_offset(&self) -> Option<i32> {\n        self.get_config_optional(ConfigKey::LocalOffset)\n    }\n\n    /// In minutes west of UTC.\n    pub fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> {\n        self.state.scheduler_info = None;\n        self.set_config(ConfigKey::LocalOffset, &mins).map(|_| ())\n    }\n\n    pub(crate) fn get_v2_rollover(&self) -> Option<u8> {\n        self.get_config_optional::<u8, _>(ConfigKey::Rollover)\n            .map(|r| r.min(23))\n    }\n\n    pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> {\n        self.state.scheduler_info = None;\n        self.set_config(ConfigKey::Rollover, &hour).map(|_| ())\n    }\n\n    pub(crate) fn get_next_card_position(&self) -> u32 {\n        self.get_config_default(ConfigKey::NextNewCardPosition)\n    }\n\n    pub(crate) fn get_and_update_next_card_position(&mut self) -> Result<u32> {\n        let pos: u32 = self\n            .get_config_optional(ConfigKey::NextNewCardPosition)\n            .unwrap_or_default();\n        self.set_config(ConfigKey::NextNewCardPosition, &pos.wrapping_add(1))?;\n        Ok(pos)\n    }\n\n    pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> {\n        self.set_config(ConfigKey::NextNewCardPosition, &pos)\n            .map(|_| ())\n    }\n\n    pub(crate) fn scheduler_version(&self) -> SchedulerVersion {\n        self.get_config_optional(ConfigKey::SchedulerVersion)\n            .unwrap_or(SchedulerVersion::V1)\n    }\n\n    pub fn v2_enabled(&self) -> bool {\n        self.scheduler_version() == SchedulerVersion::V2\n    }\n\n    pub fn v3_enabled(&self) -> bool {\n        self.scheduler_version() == SchedulerVersion::V2 && self.get_config_bool(BoolKey::Sched2021)\n    }\n\n    /// Caution: this only updates the config setting.\n    pub(crate) fn set_scheduler_version_config_key(&mut self, ver: SchedulerVersion) -> Result<()> {\n        self.state.scheduler_info = None;\n        self.set_config(ConfigKey::SchedulerVersion, &ver)\n            .map(|_| ())\n    }\n\n    pub(crate) fn learn_ahead_secs(&self) -> u32 {\n        self.get_config_optional(ConfigKey::LearnAheadSecs)\n            .unwrap_or(1200)\n    }\n\n    pub(crate) fn set_learn_ahead_secs(&mut self, secs: u32) -> Result<()> {\n        self.set_config(ConfigKey::LearnAheadSecs, &secs)\n            .map(|_| ())\n    }\n\n    pub(crate) fn get_new_review_mix(&self) -> NewReviewMix {\n        match self.get_config_default::<u8, _>(ConfigKey::NewReviewMix) {\n            1 => NewReviewMix::ReviewsFirst,\n            2 => NewReviewMix::NewFirst,\n            _ => NewReviewMix::Mix,\n        }\n    }\n\n    pub(crate) fn set_new_review_mix(&mut self, mix: NewReviewMix) -> Result<()> {\n        self.set_config(ConfigKey::NewReviewMix, &(mix as u8))\n            .map(|_| ())\n    }\n\n    pub(crate) fn get_first_day_of_week(&self) -> Weekday {\n        self.get_config_optional(ConfigKey::FirstDayOfWeek)\n            .unwrap_or(Weekday::Sunday)\n    }\n\n    pub(crate) fn set_first_day_of_week(&mut self, weekday: Weekday) -> Result<()> {\n        self.set_config(ConfigKey::FirstDayOfWeek, &weekday)\n            .map(|_| ())\n    }\n\n    pub(crate) fn get_answer_time_limit_secs(&self) -> u32 {\n        self.get_config_optional(ConfigKey::AnswerTimeLimitSecs)\n            .unwrap_or_default()\n    }\n\n    pub(crate) fn set_answer_time_limit_secs(&mut self, secs: u32) -> Result<()> {\n        self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs)\n            .map(|_| ())\n    }\n\n    pub(crate) fn get_last_unburied_day(&self) -> u32 {\n        self.get_config_optional(ConfigKey::LastUnburiedDay)\n            .unwrap_or_default()\n    }\n\n    pub(crate) fn set_last_unburied_day(&mut self, day: u32) -> Result<()> {\n        self.set_config(ConfigKey::LastUnburiedDay, &day)\n            .map(|_| ())\n    }\n\n    pub(crate) fn get_backup_limits(&self) -> BackupLimits {\n        self.get_config_optional(ConfigKey::Backups).unwrap_or(\n            // 2d + 12d + 10w + 9m ≈ 1y\n            BackupLimits {\n                daily: 12,\n                weekly: 10,\n                monthly: 9,\n                minimum_interval_mins: 30,\n            },\n        )\n    }\n\n    pub(crate) fn set_backup_limits(&mut self, limits: BackupLimits) -> Result<()> {\n        self.set_config(ConfigKey::Backups, &limits).map(|_| ())\n    }\n\n    pub(crate) fn get_update_notes(&self) -> UpdateCondition {\n        self.get_config_optional(ConfigKey::UpdateNotes)\n            .unwrap_or_default()\n    }\n\n    pub(crate) fn get_update_notetypes(&self) -> UpdateCondition {\n        self.get_config_optional(ConfigKey::UpdateNotetypes)\n            .unwrap_or_default()\n    }\n}\n\n// 2021 scheduler moves this into deck config\n#[derive(Default)]\npub(crate) enum NewReviewMix {\n    #[default]\n    Mix = 0,\n    ReviewsFirst = 1,\n    NewFirst = 2,\n}\n\n#[derive(PartialEq, Eq, Serialize_repr, Deserialize_repr, Clone, Copy)]\n#[repr(u8)]\npub(crate) enum Weekday {\n    Sunday = 0,\n    Monday = 1,\n    Friday = 5,\n    Saturday = 6,\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn defaults() {\n        let col = Collection::new();\n        assert_eq!(col.get_current_deck_id(), DeckId(1));\n    }\n\n    #[test]\n    fn get_set() {\n        let mut col = Collection::new();\n\n        // missing key\n        assert_eq!(col.get_config_optional::<Vec<i64>, _>(\"test\"), None);\n\n        // normal retrieval\n        col.set_config(\"test\", &vec![1, 2]).unwrap();\n        assert_eq!(\n            col.get_config_optional::<Vec<i64>, _>(\"test\"),\n            Some(vec![1, 2])\n        );\n\n        // invalid type conversion\n        assert_eq!(col.get_config_optional::<i64, _>(\"test\"), None,);\n\n        // invalid json\n        col.storage\n            .db\n            .execute(\"update config set val=? where key='test'\", [b\"xx\".as_ref()])\n            .unwrap();\n        assert_eq!(col.get_config_optional::<i64, _>(\"test\"), None,);\n    }\n}\n"
  },
  {
    "path": "rslib/src/config/notetype.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoStaticStr;\n\nuse super::ConfigKey;\nuse crate::notetype::NotetypeKind;\nuse crate::prelude::*;\n\n/// Notetype config packed into a collection config key. This may change\n/// frequently, and we want to avoid the potentially expensive notetype\n/// write/sync.\n#[derive(Debug, Clone, Copy, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\nenum NotetypeConfigKey {\n    #[strum(to_string = \"lastDeck\")]\n    LastDeckAddedTo,\n}\n\nimpl Collection {\n    pub fn get_aux_template_config_key(\n        &mut self,\n        ntid: NotetypeId,\n        card_ordinal: usize,\n        key: &str,\n    ) -> Result<String> {\n        let nt = self.get_notetype(ntid)?.or_not_found(ntid)?;\n        let ordinal = if matches!(nt.config.kind(), NotetypeKind::Cloze) {\n            0\n        } else {\n            card_ordinal\n        };\n        Ok(get_aux_notetype_config_key(\n            ntid,\n            &format!(\"{key}_{ordinal}\"),\n        ))\n    }\n}\n\nimpl NotetypeConfigKey {\n    fn for_notetype(self, ntid: NotetypeId) -> String {\n        get_aux_notetype_config_key(ntid, <&'static str>::from(self))\n    }\n}\n\nimpl Collection {\n    #[allow(dead_code)]\n    pub(crate) fn get_current_notetype_id(&self) -> Option<NotetypeId> {\n        self.get_config_optional(ConfigKey::CurrentNotetypeId)\n    }\n\n    pub(crate) fn set_current_notetype_id(&mut self, ntid: NotetypeId) -> Result<()> {\n        self.set_config(ConfigKey::CurrentNotetypeId, &ntid)\n            .map(|_| ())\n    }\n\n    pub(crate) fn clear_aux_config_for_notetype(&mut self, ntid: NotetypeId) -> Result<()> {\n        self.remove_config_prefix(&get_aux_notetype_config_key(ntid, \"\"))\n    }\n\n    pub(crate) fn get_last_deck_added_to_for_notetype(&self, id: NotetypeId) -> Option<DeckId> {\n        let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id);\n        self.get_config_optional(key.as_str())\n    }\n\n    pub(crate) fn set_last_deck_for_notetype(&mut self, id: NotetypeId, did: DeckId) -> Result<()> {\n        let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id);\n        self.set_config(key.as_str(), &did).map(|_| ())\n    }\n}\n\npub fn get_aux_notetype_config_key(ntid: NotetypeId, key: &str) -> String {\n    format!(\"_nt_{ntid}_{key}\")\n}\n"
  },
  {
    "path": "rslib/src/config/number.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoStaticStr;\n\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, Copy, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub enum I32ConfigKey {\n    CsvDuplicateResolution,\n    MatchScope,\n    LastFsrsOptimize,\n}\n\nimpl Collection {\n    pub fn get_config_i32(&self, key: I32ConfigKey) -> i32 {\n        #[allow(clippy::match_single_binding)]\n        self.get_config_optional(key).unwrap_or(match key {\n            _other => 0,\n        })\n    }\n}\n\nimpl Collection {\n    pub(crate) fn set_config_i32_inner(&mut self, key: I32ConfigKey, value: i32) -> Result<bool> {\n        self.set_config(key, &value)\n    }\n}\n"
  },
  {
    "path": "rslib/src/config/schema11.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde_json::json;\n\n/// These items are expected to exist in schema 11. When adding\n/// new config variables, you do not need to add them here -\n/// just create an accessor function in one of the config/*.rs files,\n/// with an appropriate default for missing/invalid values instead.\npub(crate) fn schema11_config_as_string(creation_offset: Option<i32>) -> String {\n    let obj = json!({\n        \"activeDecks\": [1],\n        \"curDeck\": 1,\n        \"newSpread\": 0,\n        \"collapseTime\": 1200,\n        \"timeLim\": 0,\n        \"estTimes\": true,\n        \"dueCounts\": true,\n        \"curModel\": null,\n        \"nextPos\": 1,\n        \"sortType\": \"noteFld\",\n        \"sortBackwards\": false,\n        \"addToCur\": true,\n        \"dayLearnFirst\": false,\n        \"schedVer\": 2,\n        \"creationOffset\": creation_offset,\n        \"sched2021\": true,\n    });\n    serde_json::to_string(&obj).unwrap()\n}\n"
  },
  {
    "path": "rslib/src/config/string.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoStaticStr;\n\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, Copy, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub enum StringKey {\n    SetDueBrowser,\n    SetDueReviewer,\n    DefaultSearchText,\n    CardStateCustomizer,\n}\n\nimpl Collection {\n    pub fn get_config_string(&self, key: StringKey) -> String {\n        let default = match key {\n            StringKey::SetDueBrowser => \"0\",\n            StringKey::SetDueReviewer => \"1\",\n            _other => \"\",\n        };\n        self.get_config_optional(key)\n            .unwrap_or_else(|| default.to_string())\n    }\n\n    pub fn set_config_string(\n        &mut self,\n        key: StringKey,\n        val: &str,\n        undoable: bool,\n    ) -> Result<OpOutput<()>> {\n        let op = if undoable {\n            Op::UpdateConfig\n        } else {\n            Op::SkipUndo\n        };\n        self.transact(op, |col| {\n            col.set_config_string_inner(key, val)?;\n            Ok(())\n        })\n    }\n}\n\nimpl Collection {\n    pub(crate) fn set_config_string_inner(&mut self, key: StringKey, val: &str) -> Result<bool> {\n        self.set_config(key, &val)\n    }\n}\n"
  },
  {
    "path": "rslib/src/config/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::ConfigEntry;\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableConfigChange {\n    Added(Box<ConfigEntry>),\n    Updated(Box<ConfigEntry>),\n    Removed(Box<ConfigEntry>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_config_change(&mut self, change: UndoableConfigChange) -> Result<()> {\n        match change {\n            UndoableConfigChange::Added(entry) => self.remove_config_undoable(&entry.key),\n            UndoableConfigChange::Updated(entry) => {\n                let current = self\n                    .storage\n                    .get_config_entry(&entry.key)?\n                    .or_invalid(\"config disappeared\")?;\n                self.update_config_entry_undoable(entry, current)\n                    .map(|_| ())\n            }\n            UndoableConfigChange::Removed(entry) => self.add_config_entry_undoable(entry),\n        }\n    }\n\n    /// True if added, or value changed.\n    pub(super) fn set_config_undoable(&mut self, entry: Box<ConfigEntry>) -> Result<bool> {\n        if let Some(original) = self.storage.get_config_entry(&entry.key)? {\n            self.update_config_entry_undoable(entry, original)\n        } else {\n            self.add_config_entry_undoable(entry)?;\n            Ok(true)\n        }\n    }\n\n    pub(super) fn remove_config_undoable(&mut self, key: &str) -> Result<()> {\n        if let Some(current) = self.storage.get_config_entry(key)? {\n            self.save_undo(UndoableConfigChange::Removed(current));\n            self.storage.remove_config(key)?;\n        }\n\n        Ok(())\n    }\n\n    fn add_config_entry_undoable(&mut self, entry: Box<ConfigEntry>) -> Result<()> {\n        self.storage.set_config_entry(&entry)?;\n        self.save_undo(UndoableConfigChange::Added(entry));\n        Ok(())\n    }\n\n    /// True if new value differed.\n    fn update_config_entry_undoable(\n        &mut self,\n        entry: Box<ConfigEntry>,\n        original: Box<ConfigEntry>,\n    ) -> Result<bool> {\n        if entry.value != original.value {\n            self.save_undo(UndoableConfigChange::Updated(original));\n            self.storage.set_config_entry(&entry)?;\n            Ok(true)\n        } else {\n            Ok(false)\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn undo() -> Result<()> {\n        let mut col = Collection::new();\n        // the op kind doesn't matter, we just need undo enabled\n        let op = Op::Bury;\n        // test key\n        let key = BoolKey::NormalizeNoteText;\n\n        // not set by default, but defaults to true\n        assert!(col.get_config_bool(key));\n\n        // first set adds the key\n        col.transact(op.clone(), |col| col.set_config_bool_inner(key, false))?;\n        assert!(!col.get_config_bool(key));\n\n        // mutate it twice\n        col.transact(op.clone(), |col| col.set_config_bool_inner(key, true))?;\n        assert!(col.get_config_bool(key));\n        col.transact(op.clone(), |col| col.set_config_bool_inner(key, false))?;\n        assert!(!col.get_config_bool(key));\n\n        // when we remove it, it goes back to its default\n        col.transact(op, |col| col.remove_config_inner(key))?;\n        assert!(col.get_config_bool(key));\n\n        // undo the removal\n        col.undo()?;\n        assert!(!col.get_config_bool(key));\n\n        // undo the mutations\n        col.undo()?;\n        assert!(col.get_config_bool(key));\n        col.undo()?;\n        assert!(!col.get_config_bool(key));\n\n        // and undo the initial add\n        col.undo()?;\n        assert!(col.get_config_bool(key));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/dbcheck.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::sync::Arc;\n\nuse anki_i18n::I18n;\nuse anki_proto::notetypes::stock_notetype::OriginalStockKind;\nuse anki_proto::notetypes::ImageOcclusionField;\nuse itertools::Itertools;\nuse tracing::debug;\n\nuse crate::collection::Collection;\nuse crate::config::SchedulerVersion;\nuse crate::error::AnkiError;\nuse crate::error::DbError;\nuse crate::error::DbErrorKind;\nuse crate::error::Result;\nuse crate::notetype::all_stock_notetypes;\nuse crate::notetype::AlreadyGeneratedCardInfo;\nuse crate::notetype::CardGenContext;\nuse crate::notetype::Notetype;\nuse crate::notetype::NotetypeId;\nuse crate::notetype::NotetypeKind;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::storage::card::CardFixStats;\nuse crate::timestamp::TimestampMillis;\nuse crate::timestamp::TimestampSecs;\n\n#[derive(Debug, Default, PartialEq, Eq)]\npub struct CheckDatabaseOutput {\n    card_properties_invalid: usize,\n    card_position_too_high: usize,\n    cards_missing_note: usize,\n    decks_missing: usize,\n    revlog_properties_invalid: usize,\n    templates_missing: usize,\n    card_ords_duplicated: usize,\n    field_count_mismatch: usize,\n    notetypes_recovered: usize,\n    invalid_utf8: usize,\n    invalid_ids: usize,\n    card_last_review_time_empty: usize,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub enum DatabaseCheckProgress {\n    #[default]\n    Integrity,\n    Optimize,\n    Cards,\n    Notes {\n        current: usize,\n        total: usize,\n    },\n    History,\n}\n\nimpl CheckDatabaseOutput {\n    pub fn to_i18n_strings(&self, tr: &I18n) -> Vec<String> {\n        let mut probs = Vec::new();\n\n        if self.notetypes_recovered > 0 {\n            probs.push(tr.database_check_notetypes_recovered());\n        }\n\n        if self.card_position_too_high > 0 {\n            probs.push(tr.database_check_new_card_high_due(self.card_position_too_high));\n        }\n        if self.card_properties_invalid > 0 {\n            probs.push(tr.database_check_card_properties(self.card_properties_invalid));\n        }\n        if self.card_last_review_time_empty > 0 {\n            probs.push(\n                tr.database_check_card_last_review_time_empty(self.card_last_review_time_empty),\n            );\n        }\n        if self.cards_missing_note > 0 {\n            probs.push(tr.database_check_card_missing_note(self.cards_missing_note));\n        }\n        if self.decks_missing > 0 {\n            probs.push(tr.database_check_missing_decks(self.decks_missing));\n        }\n        if self.field_count_mismatch > 0 {\n            probs.push(tr.database_check_field_count(self.field_count_mismatch));\n        }\n        if self.card_ords_duplicated > 0 {\n            probs.push(tr.database_check_duplicate_card_ords(self.card_ords_duplicated));\n        }\n        if self.templates_missing > 0 {\n            probs.push(tr.database_check_missing_templates(self.templates_missing));\n        }\n        if self.revlog_properties_invalid > 0 {\n            probs.push(tr.database_check_revlog_properties(self.revlog_properties_invalid));\n        }\n        if self.invalid_utf8 > 0 {\n            probs.push(tr.database_check_notes_with_invalid_utf8(self.invalid_utf8));\n        }\n        if self.invalid_ids > 0 {\n            probs.push(tr.database_check_fixed_invalid_ids(self.invalid_ids));\n        }\n\n        probs.into_iter().map(Into::into).collect()\n    }\n}\n\nimpl Collection {\n    /// Check the database, returning a list of problems that were fixed.\n    pub(crate) fn check_database(&mut self) -> Result<CheckDatabaseOutput> {\n        let mut progress = self.new_progress_handler();\n        progress.set(DatabaseCheckProgress::Integrity)?;\n        debug!(\"quick check\");\n        if self.storage.quick_check_corrupt() {\n            debug!(\"quick check failed\");\n            return Err(AnkiError::db_error(\n                self.tr.database_check_corrupt(),\n                DbErrorKind::Corrupt,\n            ));\n        }\n\n        progress.set(DatabaseCheckProgress::Optimize)?;\n        debug!(\"optimize\");\n        self.storage.optimize()?;\n\n        self.transact_no_undo(|col| col.check_database_inner(progress))\n    }\n\n    fn check_database_inner(\n        &mut self,\n        mut progress: ThrottlingProgressHandler<DatabaseCheckProgress>,\n    ) -> Result<CheckDatabaseOutput> {\n        let mut out = CheckDatabaseOutput::default();\n\n        // cards first, as we need to be able to read them to process notes\n        progress.set(DatabaseCheckProgress::Cards)?;\n        debug!(\"check cards\");\n        self.check_card_properties(&mut out)?;\n        self.check_orphaned_cards(&mut out)?;\n\n        debug!(\"check decks\");\n        self.check_missing_deck_ids(&mut out)?;\n        self.check_filtered_cards(&mut out)?;\n\n        debug!(\"check notetypes\");\n        self.check_notetypes(&mut out, &mut progress)?;\n\n        progress.set(DatabaseCheckProgress::History)?;\n\n        debug!(\"check review log\");\n        self.check_revlog(&mut out)?;\n\n        debug!(\"missing decks\");\n        self.check_missing_deck_names(&mut out)?;\n\n        self.update_next_new_position()?;\n\n        debug!(\"invalid ids\");\n        out.invalid_ids = self.maybe_fix_invalid_ids()?;\n\n        debug!(\"db check finished: {:#?}\", out);\n\n        Ok(out)\n    }\n\n    fn check_card_properties(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let timing = self.timing_today()?;\n        let CardFixStats {\n            new_cards_fixed,\n            other_cards_fixed,\n            last_review_time_fixed,\n        } = self.storage.fix_card_properties(\n            timing.days_elapsed,\n            TimestampSecs::now(),\n            self.usn()?,\n            self.scheduler_version() == SchedulerVersion::V1,\n        )?;\n        out.card_position_too_high = new_cards_fixed;\n        out.card_properties_invalid += other_cards_fixed;\n        out.card_last_review_time_empty = last_review_time_fixed;\n\n        // Trigger one-way sync if last_review_time was updated to avoid conflicts\n        if last_review_time_fixed > 0 {\n            self.set_schema_modified()?;\n        }\n\n        Ok(())\n    }\n\n    fn check_orphaned_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let cnt = self.storage.delete_orphaned_cards()?;\n        if cnt > 0 {\n            self.set_schema_modified()?;\n            out.cards_missing_note = cnt;\n        }\n        Ok(())\n    }\n\n    fn check_missing_deck_ids(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let usn = self.usn()?;\n        for did in self.storage.missing_decks()? {\n            self.recover_missing_deck(did, usn)?;\n            out.decks_missing += 1;\n        }\n        Ok(())\n    }\n\n    fn check_filtered_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let decks = self.storage.get_decks_map()?;\n\n        let mut wrong = 0;\n        for (cid, did) in self.storage.all_filtered_cards_by_deck()? {\n            // we expect calling code to ensure all decks already exist\n            if let Some(deck) = decks.get(&did) {\n                if !deck.is_filtered() {\n                    let mut card = self.storage.get_card(cid)?.unwrap();\n                    card.original_deck_id.0 = 0;\n                    card.original_due = 0;\n                    self.storage.update_card(&card)?;\n                    wrong += 1;\n                }\n            }\n        }\n\n        if wrong > 0 {\n            self.set_schema_modified()?;\n            out.card_properties_invalid += wrong;\n        }\n\n        Ok(())\n    }\n\n    fn check_notetypes(\n        &mut self,\n        out: &mut CheckDatabaseOutput,\n        progress: &mut ThrottlingProgressHandler<DatabaseCheckProgress>,\n    ) -> Result<()> {\n        let nids_by_notetype = self.storage.all_note_ids_by_notetype()?;\n        let norm = self.get_config_bool(BoolKey::NormalizeNoteText);\n        let usn = self.usn()?;\n        let stamp_millis = TimestampMillis::now();\n        let stamp_secs = TimestampSecs::now();\n\n        let expanded_tags = self.storage.expanded_tags()?;\n        self.storage.clear_all_tags()?;\n\n        let total_notes = self.storage.total_notes()?;\n\n        progress.set(DatabaseCheckProgress::Notes {\n            current: 0,\n            total: total_notes as usize,\n        })?;\n        for (ntid, group) in &nids_by_notetype.into_iter().chunk_by(|tup| tup.0) {\n            debug!(\"check notetype: {}\", ntid);\n            let mut group = group.peekable();\n            let mut nt = match self.get_notetype(ntid)? {\n                None => {\n                    let first_note = self.storage.get_note(group.peek().unwrap().1)?.unwrap();\n                    out.notetypes_recovered += 1;\n                    self.recover_notetype(stamp_millis, first_note.fields().len(), ntid)?\n                }\n                Some(nt) => nt,\n            };\n\n            self.add_missing_field_tags(Arc::make_mut(&mut nt))?;\n\n            let mut genctx = None;\n            for (_, nid) in group {\n                progress.increment(|p| {\n                    let DatabaseCheckProgress::Notes { current, .. } = p else {\n                        unreachable!()\n                    };\n                    current\n                })?;\n\n                let mut note = self.get_note_fixing_invalid_utf8(nid, out)?;\n                let original = note.clone();\n\n                let cards = self.storage.existing_cards_for_note(nid)?;\n\n                out.card_ords_duplicated += self.remove_duplicate_card_ordinals(&cards)?;\n                out.templates_missing += self.remove_cards_without_template(&nt, &cards)?;\n\n                // fix fields\n                if note.fields().len() != nt.fields.len() {\n                    note.fix_field_count(&nt);\n                    note.tags.push(\"db-check\".into());\n                    out.field_count_mismatch += 1;\n                }\n\n                if note.mtime > stamp_secs {\n                    note.mtime = stamp_secs;\n                }\n\n                // note type ID may have changed if we created a recovery notetype\n                note.notetype_id = nt.id;\n\n                // write note, updating tags and generating missing cards\n                let ctx = genctx.get_or_insert_with(|| {\n                    CardGenContext::new(\n                        nt.as_ref(),\n                        self.get_last_deck_added_to_for_notetype(nt.id),\n                        usn,\n                    )\n                });\n                self.update_note_inner_generating_cards(\n                    ctx, &mut note, &original, false, norm, true,\n                )?;\n            }\n        }\n\n        // the note rebuilding process took care of adding tags back, so we just need\n        // to ensure to restore the collapse state\n        self.storage.restore_expanded_tags(&expanded_tags)?;\n\n        // if the collection is empty and the user has deleted all note types, ensure at\n        // least one note type exists\n        if self.storage.get_all_notetype_names()?.is_empty() {\n            let mut nt = all_stock_notetypes(&self.tr).remove(0);\n            self.add_notetype_inner(&mut nt, usn, true)?;\n        }\n\n        if out.card_ords_duplicated > 0\n            || out.field_count_mismatch > 0\n            || out.templates_missing > 0\n            || out.notetypes_recovered > 0\n        {\n            self.set_schema_modified()?;\n        }\n\n        Ok(())\n    }\n\n    fn get_note_fixing_invalid_utf8(\n        &self,\n        nid: NoteId,\n        out: &mut CheckDatabaseOutput,\n    ) -> Result<Note> {\n        match self.storage.get_note(nid) {\n            Ok(note) => Ok(note.unwrap()),\n            Err(err) => match err {\n                AnkiError::DbError {\n                    source:\n                        DbError {\n                            kind: DbErrorKind::Utf8,\n                            ..\n                        },\n                } => {\n                    // fix note then fetch again\n                    self.storage.fix_invalid_utf8_in_note(nid)?;\n                    out.invalid_utf8 += 1;\n                    Ok(self.storage.get_note(nid)?.unwrap())\n                }\n                // other errors are unhandled\n                _ => Err(err),\n            },\n        }\n    }\n\n    fn remove_duplicate_card_ordinals(\n        &mut self,\n        cards: &[AlreadyGeneratedCardInfo],\n    ) -> Result<usize> {\n        let mut ords = HashSet::new();\n        let mut removed = 0;\n        for card in cards {\n            if !ords.insert(card.ord) {\n                self.storage.remove_card(card.id)?;\n                removed += 1;\n            }\n        }\n\n        Ok(removed)\n    }\n\n    fn remove_cards_without_template(\n        &mut self,\n        nt: &Notetype,\n        cards: &[AlreadyGeneratedCardInfo],\n    ) -> Result<usize> {\n        if nt.config.kind() == NotetypeKind::Cloze {\n            return Ok(0);\n        }\n        let mut removed = 0;\n        for card in cards {\n            if card.ord as usize >= nt.templates.len() {\n                self.storage.remove_card(card.id)?;\n                removed += 1;\n            }\n        }\n\n        Ok(removed)\n    }\n\n    fn recover_notetype(\n        &mut self,\n        stamp: TimestampMillis,\n        field_count: usize,\n        previous_id: NotetypeId,\n    ) -> Result<Arc<Notetype>> {\n        debug!(\"create recovery notetype\");\n        let extra_cards_required = self\n            .storage\n            .highest_card_ordinal_for_notetype(previous_id)?;\n        let mut basic = all_stock_notetypes(&self.tr).remove(0);\n        let mut field = 3;\n        while basic.fields.len() < field_count {\n            basic.add_field(format!(\"{field}\"));\n            field += 1;\n        }\n        basic.name = format!(\"db-check-{stamp}-{field_count}\");\n        let qfmt = basic.templates[0].config.q_format.clone();\n        let afmt = basic.templates[0].config.a_format.clone();\n        for n in 0..extra_cards_required {\n            basic.add_template(format!(\"Card {}\", n + 2), &qfmt, &afmt);\n        }\n        self.add_notetype(&mut basic, true)?;\n        Ok(Arc::new(basic))\n    }\n\n    fn check_revlog(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let cnt = self.storage.fix_revlog_properties()?;\n        if cnt > 0 {\n            self.set_schema_modified()?;\n            out.revlog_properties_invalid = cnt;\n        }\n\n        Ok(())\n    }\n\n    fn check_missing_deck_names(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {\n        let names = self.storage.get_all_deck_names()?;\n        out.decks_missing += self.add_missing_deck_names(&names)?;\n        Ok(())\n    }\n\n    fn update_next_new_position(&mut self) -> Result<()> {\n        let pos = self.storage.max_new_card_position().unwrap_or(0);\n        self.set_next_card_position(pos)\n    }\n\n    pub(crate) fn maybe_fix_invalid_ids(&mut self) -> Result<usize> {\n        let now = TimestampMillis::now();\n        let tomorrow = now.adding_secs(24 * 60 * 60).0;\n        let num_invalid_ids = self.storage.invalid_ids(tomorrow)?;\n        if num_invalid_ids > 0 {\n            self.storage.fix_invalid_ids(tomorrow, now.0)?;\n            self.set_schema_modified()?;\n        }\n        Ok(num_invalid_ids)\n    }\n    fn add_missing_field_tags(&mut self, nt: &mut Notetype) -> Result<()> {\n        // we only try to fix I/O, as the other notetypes have been in circulation too\n        // long, and there's too much of a risk that the user has reordered the fields\n        // already. We could try to match on field name in the future though.\n        let usn = self.usn()?;\n        if let OriginalStockKind::ImageOcclusion = nt.config.original_stock_kind() {\n            let mut changed = false;\n            if nt.fields.len() >= 5 {\n                for i in 0..5 {\n                    let conf = &mut nt.fields[i].config;\n                    if !conf.prevent_deletion {\n                        changed = true;\n                        conf.prevent_deletion = i != ImageOcclusionField::Comments as usize;\n                        conf.tag = Some(i as u32);\n                    }\n                }\n            }\n            if changed {\n                nt.set_modified(usn);\n                self.add_or_update_notetype_with_existing_id_inner(nt, None, usn, true)?;\n            }\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::decks::DeckId;\n    use crate::search::SortMode;\n\n    #[test]\n    fn cards() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        // card properties\n        col.storage\n            .db\n            .execute_batch(\"update cards set ivl=1.5,due=2000000,odue=1.5\")?;\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                card_properties_invalid: 2,\n                card_position_too_high: 1,\n                ..Default::default()\n            }\n        );\n        // should be idempotent\n        assert_eq!(col.check_database()?, Default::default());\n\n        // missing deck\n        col.storage.db.execute_batch(\"update cards set did=123\")?;\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                decks_missing: 1,\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            col.storage\n                .get_deck(DeckId(123))?\n                .unwrap()\n                .name\n                .as_native_str(),\n            \"recovered123\"\n        );\n\n        // missing note\n        col.storage.remove_note(note.id)?;\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                cards_missing_note: 1,\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            0\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn revlog() -> Result<()> {\n        let mut col = Collection::new();\n\n        col.storage.db.execute_batch(\n            \"\n        insert into revlog (id,cid,usn,ease,ivl,lastIvl,factor,time,type)\n        values (0,0,0,0,1.5,1.5,0,0,0)\",\n        )?;\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                revlog_properties_invalid: 1,\n                ..Default::default()\n            }\n        );\n        assert!(col\n            .storage\n            .db_scalar::<bool>(\"select ivl = lastIvl = 1 from revlog\")?);\n\n        Ok(())\n    }\n\n    #[test]\n    fn note_card_link() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        // duplicate ordinals\n        let cid = col.search_cards(\"\", SortMode::NoOrder)?[0];\n        let mut card = col.storage.get_card(cid)?.unwrap();\n        card.id.0 += 1;\n        col.storage.add_card(&mut card)?;\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                card_ords_duplicated: 1,\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            1\n        );\n\n        // missing templates\n        let cid = col.search_cards(\"\", SortMode::NoOrder)?[0];\n        let mut card = col.storage.get_card(cid)?.unwrap();\n        card.id.0 += 1;\n        card.template_idx = 10;\n        col.storage.add_card(&mut card)?;\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                templates_missing: 1,\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            1\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn note_fields() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        // excess fields get joined into the last one\n        col.storage\n            .db\n            .execute_batch(\"update notes set flds = 'a\\x1fb\\x1fc\\x1fd'\")?;\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                field_count_mismatch: 1,\n                ..Default::default()\n            }\n        );\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(&note.fields()[..], &[\"a\", \"b; c; d\"]);\n\n        // missing fields get filled with blanks\n        col.storage\n            .db\n            .execute_batch(\"update notes set flds = 'a'\")?;\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                field_count_mismatch: 1,\n                ..Default::default()\n            }\n        );\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(&note.fields()[..], &[\"a\", \"\"]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn deck_names() -> Result<()> {\n        let mut col = Collection::new();\n\n        let deck = col.get_or_create_normal_deck(\"foo::bar::baz\")?;\n        // includes default\n        assert_eq!(col.storage.get_all_deck_names()?.len(), 4);\n\n        col.storage\n            .db\n            .prepare(\"delete from decks where id != ? and id != 1\")?\n            .execute([deck.id])?;\n        assert_eq!(col.storage.get_all_deck_names()?.len(), 2);\n\n        let out = col.check_database()?;\n        assert_eq!(\n            out,\n            CheckDatabaseOutput {\n                decks_missing: 1, // only counts the immediate parent that was missing\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            &col.storage\n                .get_all_deck_names()?\n                .iter()\n                .map(|(_, name)| name)\n                .collect::<Vec<_>>(),\n            &[\"Default\", \"foo\", \"foo::bar\", \"foo::bar::baz\"]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn tags() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.tags.push(\"one\".into());\n        note.tags.push(\"two\".into());\n        col.add_note(&mut note, DeckId(1))?;\n\n        col.set_tag_collapsed(\"one\", false)?;\n\n        col.check_database()?;\n\n        assert!(col.storage.get_tag(\"one\")?.unwrap().expanded);\n        assert!(!col.storage.get_tag(\"two\")?.unwrap().expanded);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/deckconfig/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod schema11;\nmod service;\npub(crate) mod undo;\nmod update;\n\npub use anki_proto::deck_config::deck_config::config::AnswerAction;\npub use anki_proto::deck_config::deck_config::config::LeechAction;\npub use anki_proto::deck_config::deck_config::config::NewCardGatherPriority;\npub use anki_proto::deck_config::deck_config::config::NewCardInsertOrder;\npub use anki_proto::deck_config::deck_config::config::NewCardSortOrder;\npub use anki_proto::deck_config::deck_config::config::QuestionAction;\npub use anki_proto::deck_config::deck_config::config::ReviewCardOrder;\npub use anki_proto::deck_config::deck_config::config::ReviewMix;\npub use anki_proto::deck_config::deck_config::Config as DeckConfigInner;\npub use schema11::DeckConfSchema11;\npub use schema11::NewCardOrderSchema11;\npub use update::UpdateDeckConfigsRequest;\n\n/// Old deck config and cards table store 250% as 2500.\npub(crate) const INITIAL_EASE_FACTOR_THOUSANDS: u16 = (INITIAL_EASE_FACTOR * 1000.0) as u16;\n\nuse crate::define_newtype;\nuse crate::prelude::*;\nuse crate::scheduler::states::review::INITIAL_EASE_FACTOR;\n\ndefine_newtype!(DeckConfigId, i64);\n\n#[derive(Debug, PartialEq, Clone)]\npub struct DeckConfig {\n    pub id: DeckConfigId,\n    pub name: String,\n    pub mtime_secs: TimestampSecs,\n    pub usn: Usn,\n    pub inner: DeckConfigInner,\n}\n\n/// NOTE: this does not set the default steps\nconst DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner {\n    learn_steps: Vec::new(),\n    relearn_steps: Vec::new(),\n    new_per_day: 20,\n    reviews_per_day: 200,\n    new_per_day_minimum: 0,\n    initial_ease: 2.5,\n    easy_multiplier: 1.3,\n    hard_multiplier: 1.2,\n    lapse_multiplier: 0.0,\n    interval_multiplier: 1.0,\n    maximum_review_interval: 36_500,\n    minimum_lapse_interval: 1,\n    graduating_interval_good: 1,\n    graduating_interval_easy: 4,\n    new_card_insert_order: NewCardInsertOrder::Due as i32,\n    new_card_gather_priority: NewCardGatherPriority::Deck as i32,\n    new_card_sort_order: NewCardSortOrder::Template as i32,\n    review_order: ReviewCardOrder::Day as i32,\n    new_mix: ReviewMix::MixWithReviews as i32,\n    interday_learning_mix: ReviewMix::MixWithReviews as i32,\n    leech_action: LeechAction::TagOnly as i32,\n    leech_threshold: 8,\n    disable_autoplay: false,\n    cap_answer_time_to_secs: 60,\n    show_timer: false,\n    stop_timer_on_answer: false,\n    seconds_to_show_question: 0.0,\n    seconds_to_show_answer: 0.0,\n    question_action: QuestionAction::ShowAnswer as i32,\n    answer_action: AnswerAction::BuryCard as i32,\n    wait_for_audio: true,\n    skip_question_when_replaying_answer: false,\n    bury_new: false,\n    bury_reviews: false,\n    bury_interday_learning: false,\n    fsrs_params_4: vec![],\n    fsrs_params_5: vec![],\n    fsrs_params_6: vec![],\n    desired_retention: 0.9,\n    other: Vec::new(),\n    historical_retention: 0.9,\n    param_search: String::new(),\n    ignore_revlogs_before_date: String::new(),\n    easy_days_percentages: Vec::new(),\n};\n\nimpl Default for DeckConfig {\n    fn default() -> Self {\n        DeckConfig {\n            id: DeckConfigId(0),\n            name: \"\".to_string(),\n            mtime_secs: Default::default(),\n            usn: Default::default(),\n            inner: DeckConfigInner {\n                learn_steps: vec![1.0, 10.0],\n                relearn_steps: vec![10.0],\n                easy_days_percentages: vec![1.0; 7],\n                ..DEFAULT_DECK_CONFIG_INNER\n            },\n        }\n    }\n}\n\nimpl DeckConfig {\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.mtime_secs = TimestampSecs::now();\n        self.usn = usn;\n    }\n\n    /// Retrieve the FSRS 6.0 params, falling back on 5.0 or 4.x ones.\n    pub fn fsrs_params(&self) -> &Vec<f32> {\n        if !self.inner.fsrs_params_6.is_empty() {\n            &self.inner.fsrs_params_6\n        } else if !self.inner.fsrs_params_5.is_empty() {\n            &self.inner.fsrs_params_5\n        } else {\n            &self.inner.fsrs_params_4\n        }\n    }\n}\n\nimpl Collection {\n    /// If fallback is true, guaranteed to return a deck config.\n    pub fn get_deck_config(\n        &self,\n        dcid: DeckConfigId,\n        fallback: bool,\n    ) -> Result<Option<DeckConfig>> {\n        if let Some(conf) = self.storage.get_deck_config(dcid)? {\n            return Ok(Some(conf));\n        }\n        if fallback {\n            if let Some(conf) = self.storage.get_deck_config(DeckConfigId(1))? {\n                return Ok(Some(conf));\n            }\n            // if even the default deck config is missing, just return the defaults\n            Ok(Some(DeckConfig::default()))\n        } else {\n            Ok(None)\n        }\n    }\n}\n\nimpl Collection {\n    pub(crate) fn add_or_update_deck_config(&mut self, config: &mut DeckConfig) -> Result<()> {\n        let usn = Some(self.usn()?);\n\n        if config.id.0 == 0 {\n            self.add_deck_config_inner(config, usn)\n        } else {\n            let original = self\n                .storage\n                .get_deck_config(config.id)?\n                .or_not_found(config.id)?;\n            self.update_deck_config_inner(config, original, usn)\n        }\n    }\n\n    /// Used by the old import code; if provided id is non-zero, will add\n    /// instead of ignoring. Does not support undo.\n    pub(crate) fn add_or_update_deck_config_legacy(\n        &mut self,\n        config: &mut DeckConfig,\n    ) -> Result<()> {\n        let usn = self.usn()?;\n\n        if config.id.0 == 0 {\n            self.add_deck_config_inner(config, Some(usn))\n        } else {\n            config.set_modified(usn);\n            self.storage\n                .add_or_update_deck_config_with_existing_id(config)\n        }\n    }\n\n    /// Assigns an id and adds to DB. If usn is provided, modification time and\n    /// usn will be updated.\n    pub(crate) fn add_deck_config_inner(\n        &mut self,\n        config: &mut DeckConfig,\n        usn: Option<Usn>,\n    ) -> Result<()> {\n        if let Some(usn) = usn {\n            config.set_modified(usn);\n        }\n        config.id.0 = TimestampMillis::now().0;\n        self.add_deck_config_undoable(config)\n    }\n\n    /// Update an existing deck config. If usn is provided, modification time\n    /// and usn will be updated.\n    pub(crate) fn update_deck_config_inner(\n        &mut self,\n        config: &mut DeckConfig,\n        original: DeckConfig,\n        usn: Option<Usn>,\n    ) -> Result<()> {\n        if config == &original {\n            return Ok(());\n        }\n        if let Some(usn) = usn {\n            config.set_modified(usn);\n        }\n        self.update_deck_config_undoable(config, original)\n    }\n\n    /// Remove a deck configuration. This will force a full sync.\n    pub(crate) fn remove_deck_config_inner(&mut self, dcid: DeckConfigId) -> Result<()> {\n        require!(dcid.0 != 1, \"can't delete default conf\");\n        let conf = self.storage.get_deck_config(dcid)?.or_not_found(dcid)?;\n        self.set_schema_modified()?;\n        self.remove_deck_config_undoable(conf)\n    }\n}\n\n/// There was a period of time when the deck options screen was allowing\n/// 0/NaN to be persisted, so we need to check the values are within\n/// valid bounds when reading from the DB.\npub(crate) fn ensure_deck_config_values_valid(config: &mut DeckConfigInner) {\n    let default = DEFAULT_DECK_CONFIG_INNER;\n    ensure_u32_valid(&mut config.new_per_day, default.new_per_day, 0, 9999);\n    ensure_u32_valid(\n        &mut config.reviews_per_day,\n        default.reviews_per_day,\n        0,\n        9999,\n    );\n    ensure_u32_valid(\n        &mut config.new_per_day_minimum,\n        default.new_per_day_minimum,\n        0,\n        9999,\n    );\n    ensure_f32_valid(&mut config.initial_ease, default.initial_ease, 1.31, 5.0);\n    ensure_f32_valid(\n        &mut config.easy_multiplier,\n        default.easy_multiplier,\n        1.0,\n        5.0,\n    );\n    ensure_f32_valid(\n        &mut config.hard_multiplier,\n        default.hard_multiplier,\n        0.5,\n        1.3,\n    );\n    ensure_f32_valid(\n        &mut config.lapse_multiplier,\n        default.lapse_multiplier,\n        0.0,\n        1.0,\n    );\n    ensure_f32_valid(\n        &mut config.interval_multiplier,\n        default.interval_multiplier,\n        0.5,\n        2.0,\n    );\n    ensure_u32_valid(\n        &mut config.maximum_review_interval,\n        default.maximum_review_interval,\n        1,\n        36_500,\n    );\n    ensure_u32_valid(\n        &mut config.minimum_lapse_interval,\n        default.minimum_lapse_interval,\n        1,\n        36_500,\n    );\n    ensure_u32_valid(\n        &mut config.graduating_interval_good,\n        default.graduating_interval_good,\n        1,\n        36_500,\n    );\n    ensure_u32_valid(\n        &mut config.graduating_interval_easy,\n        default.graduating_interval_easy,\n        1,\n        36_500,\n    );\n    ensure_u32_valid(\n        &mut config.leech_threshold,\n        default.leech_threshold,\n        1,\n        9999,\n    );\n    ensure_u32_valid(\n        &mut config.cap_answer_time_to_secs,\n        default.cap_answer_time_to_secs,\n        1,\n        9999,\n    );\n    ensure_f32_valid(\n        &mut config.desired_retention,\n        default.desired_retention,\n        0.7,\n        0.99,\n    );\n    ensure_f32_valid(\n        &mut config.historical_retention,\n        default.historical_retention,\n        0.7,\n        0.97,\n    )\n}\n\nfn ensure_f32_valid(val: &mut f32, default: f32, min: f32, max: f32) {\n    if val.is_nan() || *val < min || *val > max {\n        *val = default;\n    }\n}\n\nfn ensure_u32_valid(val: &mut u32, default: u32, min: u32, max: u32) {\n    if *val < min || *val > max {\n        *val = default;\n    }\n}\n"
  },
  {
    "path": "rslib/src/deckconfig/schema11.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse phf::phf_set;\nuse phf::Set;\nuse serde::Deserialize as DeTrait;\nuse serde::Deserialize;\nuse serde::Deserializer;\nuse serde::Serialize;\nuse serde_aux::field_attributes::deserialize_number_from_string;\nuse serde_json::Value;\nuse serde_repr::Deserialize_repr;\nuse serde_repr::Serialize_repr;\nuse serde_tuple::Serialize_tuple;\n\nuse super::DeckConfig;\nuse super::DeckConfigId;\nuse super::DeckConfigInner;\nuse super::NewCardInsertOrder;\nuse super::INITIAL_EASE_FACTOR_THOUSANDS;\nuse crate::serde::default_on_invalid;\nuse crate::timestamp::TimestampSecs;\nuse crate::types::Usn;\n\nfn wait_for_audio_default() -> bool {\n    true\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct DeckConfSchema11 {\n    #[serde(deserialize_with = \"deserialize_number_from_string\")]\n    pub(crate) id: DeckConfigId,\n    #[serde(rename = \"mod\", deserialize_with = \"deserialize_number_from_string\")]\n    pub(crate) mtime: TimestampSecs,\n    pub(crate) name: String,\n    pub(crate) usn: Usn,\n    max_taken: i32,\n    autoplay: bool,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    timer: u8,\n    #[serde(default)]\n    replayq: bool,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) new: NewConfSchema11,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) rev: RevConfSchema11,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) lapse: LapseConfSchema11,\n    #[serde(rename = \"dyn\", default, deserialize_with = \"default_on_invalid\")]\n    dynamic: bool,\n\n    // 2021 scheduler options: these were not in schema 11, but we need to persist them\n    // so the settings are not lost on upgrade/downgrade.\n    #[serde(default)]\n    new_mix: i32,\n    #[serde(default)]\n    new_per_day_minimum: u32,\n    #[serde(default)]\n    interday_learning_mix: i32,\n    #[serde(default)]\n    review_order: i32,\n    #[serde(default)]\n    new_sort_order: i32,\n    #[serde(default)]\n    new_gather_priority: i32,\n    #[serde(default)]\n    bury_interday_learning: bool,\n\n    #[serde(default, rename = \"fsrsWeights\")]\n    fsrs_params_4: Vec<f32>,\n    #[serde(default)]\n    fsrs_params_5: Vec<f32>,\n    #[serde(default)]\n    fsrs_params_6: Vec<f32>,\n    #[serde(default)]\n    desired_retention: f32,\n    #[serde(default)]\n    ignore_revlogs_before_date: String,\n    #[serde(default)]\n    easy_days_percentages: Vec<f32>,\n    #[serde(default)]\n    stop_timer_on_answer: bool,\n    #[serde(default)]\n    seconds_to_show_question: f32,\n    #[serde(default)]\n    seconds_to_show_answer: f32,\n    #[serde(default)]\n    question_action: QuestionAction,\n    #[serde(default)]\n    answer_action: AnswerAction,\n    #[serde(default = \"wait_for_audio_default\")]\n    wait_for_audio: bool,\n    #[serde(default)]\n    /// historical retention\n    sm2_retention: f32,\n    #[serde(default, rename = \"weightSearch\")]\n    param_search: String,\n\n    #[serde(flatten)]\n    other: HashMap<String, Value>,\n}\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)]\n#[repr(u8)]\n#[derive(Default)]\npub enum QuestionAction {\n    #[default]\n    ShowAnswer = 0,\n    ShowReminder = 1,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)]\n#[repr(u8)]\n#[derive(Default)]\npub enum AnswerAction {\n    #[default]\n    BuryCard = 0,\n    AnswerAgain = 1,\n    AnswerGood = 2,\n    AnswerHard = 3,\n    ShowReminder = 4,\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct NewConfSchema11 {\n    #[serde(default)]\n    bury: bool,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    delays: Vec<f32>,\n    initial_factor: u16,\n    #[serde(deserialize_with = \"deserialize_new_intervals\")]\n    ints: NewCardIntervals,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) order: NewCardOrderSchema11,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) per_day: u32,\n\n    #[serde(flatten)]\n    other: HashMap<String, Value>,\n}\n\n#[derive(Serialize_tuple, Debug, PartialEq, Eq, Clone)]\npub struct NewCardIntervals {\n    good: u16,\n    easy: u16,\n    _unused: u16,\n}\n\nimpl Default for NewCardIntervals {\n    fn default() -> Self {\n        Self {\n            good: 1,\n            easy: 4,\n            _unused: 0,\n        }\n    }\n}\n\n/// This extra logic is required because AnkiDroid's options screen was creating\n/// a 2 element array instead of a 3 element one.\nfn deserialize_new_intervals<'de, D>(deserializer: D) -> Result<NewCardIntervals, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let vals: Result<Vec<u16>, _> = DeTrait::deserialize(deserializer);\n    Ok(vals\n        .ok()\n        .and_then(|vals| {\n            if vals.len() >= 2 {\n                Some(NewCardIntervals {\n                    good: vals[0],\n                    easy: vals[1],\n                    _unused: 0,\n                })\n            } else {\n                None\n            }\n        })\n        .unwrap_or_default())\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)]\n#[repr(u8)]\n#[derive(Default)]\npub enum NewCardOrderSchema11 {\n    Random = 0,\n    #[default]\n    Due = 1,\n}\n\nfn hard_factor_default() -> f32 {\n    1.2\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct RevConfSchema11 {\n    #[serde(default)]\n    bury: bool,\n    ease4: f32,\n    ivl_fct: f32,\n    max_ivl: u32,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) per_day: u32,\n    #[serde(default = \"hard_factor_default\")]\n    hard_factor: f32,\n\n    #[serde(flatten)]\n    other: HashMap<String, Value>,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)]\n#[repr(u8)]\n#[derive(Default)]\npub enum LeechAction {\n    Suspend = 0,\n    #[default]\n    TagOnly = 1,\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct LapseConfSchema11 {\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    delays: Vec<f32>,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    leech_action: LeechAction,\n    leech_fails: u32,\n    min_int: u32,\n    mult: f32,\n\n    #[serde(flatten)]\n    other: HashMap<String, Value>,\n}\n\nimpl Default for RevConfSchema11 {\n    fn default() -> Self {\n        RevConfSchema11 {\n            bury: false,\n            ease4: 1.3,\n            ivl_fct: 1.0,\n            max_ivl: 36500,\n            per_day: 200,\n            hard_factor: 1.2,\n            other: Default::default(),\n        }\n    }\n}\n\nimpl Default for NewConfSchema11 {\n    fn default() -> Self {\n        NewConfSchema11 {\n            bury: false,\n            delays: vec![1.0, 10.0],\n            initial_factor: INITIAL_EASE_FACTOR_THOUSANDS,\n            ints: NewCardIntervals::default(),\n            order: NewCardOrderSchema11::default(),\n            per_day: 20,\n            other: Default::default(),\n        }\n    }\n}\n\nimpl Default for LapseConfSchema11 {\n    fn default() -> Self {\n        LapseConfSchema11 {\n            delays: vec![10.0],\n            leech_action: LeechAction::default(),\n            leech_fails: 8,\n            min_int: 1,\n            mult: 0.0,\n            other: Default::default(),\n        }\n    }\n}\n\nimpl Default for DeckConfSchema11 {\n    fn default() -> Self {\n        DeckConfSchema11 {\n            id: DeckConfigId(0),\n            mtime: TimestampSecs(0),\n            name: \"Default\".to_string(),\n            usn: Usn(0),\n            max_taken: 60,\n            autoplay: true,\n            timer: 0,\n            stop_timer_on_answer: false,\n            seconds_to_show_question: 0.0,\n            seconds_to_show_answer: 0.0,\n            question_action: QuestionAction::ShowAnswer,\n            answer_action: AnswerAction::BuryCard,\n            wait_for_audio: true,\n            replayq: true,\n            dynamic: false,\n            new: Default::default(),\n            rev: Default::default(),\n            lapse: Default::default(),\n            other: Default::default(),\n            new_mix: 0,\n            new_per_day_minimum: 0,\n            interday_learning_mix: 0,\n            review_order: 0,\n            new_sort_order: 0,\n            new_gather_priority: 0,\n            bury_interday_learning: false,\n            fsrs_params_4: vec![],\n            fsrs_params_5: vec![],\n            fsrs_params_6: vec![],\n            desired_retention: 0.9,\n            sm2_retention: 0.9,\n            param_search: \"\".to_string(),\n            ignore_revlogs_before_date: \"\".to_string(),\n            easy_days_percentages: vec![1.0; 7],\n        }\n    }\n}\n\n// schema11 -> schema15\n\nimpl From<DeckConfSchema11> for DeckConfig {\n    fn from(mut c: DeckConfSchema11) -> DeckConfig {\n        // merge any json stored in new/rev/lapse into top level\n        if !c.new.other.is_empty() {\n            if let Ok(val) = serde_json::to_value(c.new.other) {\n                c.other.insert(\"new\".into(), val);\n            }\n        }\n        if !c.rev.other.is_empty() {\n            if let Ok(val) = serde_json::to_value(c.rev.other) {\n                c.other.insert(\"rev\".into(), val);\n            }\n        }\n        if !c.lapse.other.is_empty() {\n            if let Ok(val) = serde_json::to_value(c.lapse.other) {\n                c.other.insert(\"lapse\".into(), val);\n            }\n        }\n        let other_bytes = if c.other.is_empty() {\n            vec![]\n        } else {\n            serde_json::to_vec(&c.other).unwrap_or_default()\n        };\n\n        DeckConfig {\n            id: c.id,\n            name: c.name,\n            mtime_secs: c.mtime,\n            usn: c.usn,\n            inner: DeckConfigInner {\n                learn_steps: c.new.delays,\n                relearn_steps: c.lapse.delays,\n                new_per_day: c.new.per_day,\n                reviews_per_day: c.rev.per_day,\n                new_per_day_minimum: c.new_per_day_minimum,\n                initial_ease: (c.new.initial_factor as f32) / 1000.0,\n                easy_multiplier: c.rev.ease4,\n                hard_multiplier: c.rev.hard_factor,\n                lapse_multiplier: c.lapse.mult,\n                interval_multiplier: c.rev.ivl_fct,\n                maximum_review_interval: c.rev.max_ivl,\n                minimum_lapse_interval: c.lapse.min_int,\n                graduating_interval_good: c.new.ints.good as u32,\n                graduating_interval_easy: c.new.ints.easy as u32,\n                new_card_insert_order: match c.new.order {\n                    NewCardOrderSchema11::Random => NewCardInsertOrder::Random,\n                    NewCardOrderSchema11::Due => NewCardInsertOrder::Due,\n                } as i32,\n                new_card_gather_priority: c.new_gather_priority,\n                new_card_sort_order: c.new_sort_order,\n                review_order: c.review_order,\n                new_mix: c.new_mix,\n                interday_learning_mix: c.interday_learning_mix,\n                leech_action: c.lapse.leech_action as i32,\n                leech_threshold: c.lapse.leech_fails,\n                disable_autoplay: !c.autoplay,\n                cap_answer_time_to_secs: c.max_taken.max(0) as u32,\n                show_timer: c.timer != 0,\n                stop_timer_on_answer: c.stop_timer_on_answer,\n                seconds_to_show_question: c.seconds_to_show_question,\n                seconds_to_show_answer: c.seconds_to_show_answer,\n                question_action: c.question_action as i32,\n                answer_action: c.answer_action as i32,\n                wait_for_audio: c.wait_for_audio,\n                skip_question_when_replaying_answer: !c.replayq,\n                bury_new: c.new.bury,\n                bury_reviews: c.rev.bury,\n                bury_interday_learning: c.bury_interday_learning,\n                fsrs_params_4: c.fsrs_params_4,\n                fsrs_params_5: c.fsrs_params_5,\n                fsrs_params_6: c.fsrs_params_6,\n                ignore_revlogs_before_date: c.ignore_revlogs_before_date,\n                easy_days_percentages: c.easy_days_percentages,\n                desired_retention: c.desired_retention,\n                historical_retention: c.sm2_retention,\n                param_search: c.param_search,\n                other: other_bytes,\n            },\n        }\n    }\n}\n\n// latest schema -> schema 11\nimpl From<DeckConfig> for DeckConfSchema11 {\n    fn from(c: DeckConfig) -> DeckConfSchema11 {\n        // split extra json up\n        let mut top_other: HashMap<String, Value>;\n        let mut new_other = Default::default();\n        let mut rev_other = Default::default();\n        let mut lapse_other = Default::default();\n        if c.inner.other.is_empty() {\n            top_other = Default::default();\n        } else {\n            top_other = serde_json::from_slice(&c.inner.other).unwrap_or_default();\n            if let Some(new) = top_other.remove(\"new\") {\n                let val: HashMap<String, Value> = serde_json::from_value(new).unwrap_or_default();\n                new_other = val;\n                new_other.retain(|k, _v| !RESERVED_DECKCONF_NEW_KEYS.contains(k))\n            }\n            if let Some(rev) = top_other.remove(\"rev\") {\n                let val: HashMap<String, Value> = serde_json::from_value(rev).unwrap_or_default();\n                rev_other = val;\n                rev_other.retain(|k, _v| !RESERVED_DECKCONF_REV_KEYS.contains(k))\n            }\n            if let Some(lapse) = top_other.remove(\"lapse\") {\n                let val: HashMap<String, Value> = serde_json::from_value(lapse).unwrap_or_default();\n                lapse_other = val;\n                lapse_other.retain(|k, _v| !RESERVED_DECKCONF_LAPSE_KEYS.contains(k))\n            }\n            top_other.retain(|k, _v| !RESERVED_DECKCONF_KEYS.contains(k));\n        }\n        let i = c.inner;\n        let new_order = i.new_card_insert_order();\n        DeckConfSchema11 {\n            id: c.id,\n            mtime: c.mtime_secs,\n            name: c.name,\n            usn: c.usn,\n            max_taken: i.cap_answer_time_to_secs as i32,\n            autoplay: !i.disable_autoplay,\n            timer: i.show_timer.into(),\n            stop_timer_on_answer: i.stop_timer_on_answer,\n            seconds_to_show_question: i.seconds_to_show_question,\n            seconds_to_show_answer: i.seconds_to_show_answer,\n            answer_action: match i.answer_action {\n                1 => AnswerAction::AnswerAgain,\n                2 => AnswerAction::AnswerGood,\n                3 => AnswerAction::AnswerHard,\n                4 => AnswerAction::ShowReminder,\n                _ => AnswerAction::BuryCard,\n            },\n            question_action: match i.question_action {\n                1 => QuestionAction::ShowReminder,\n                _ => QuestionAction::ShowAnswer,\n            },\n            wait_for_audio: i.wait_for_audio,\n            replayq: !i.skip_question_when_replaying_answer,\n            dynamic: false,\n            new: NewConfSchema11 {\n                bury: i.bury_new,\n                delays: i.learn_steps,\n                initial_factor: (i.initial_ease * 1000.0) as u16,\n                ints: NewCardIntervals {\n                    good: i.graduating_interval_good as u16,\n                    easy: i.graduating_interval_easy as u16,\n                    _unused: 0,\n                },\n                order: match new_order {\n                    NewCardInsertOrder::Random => NewCardOrderSchema11::Random,\n                    NewCardInsertOrder::Due => NewCardOrderSchema11::Due,\n                },\n                per_day: i.new_per_day,\n                other: new_other,\n            },\n            rev: RevConfSchema11 {\n                bury: i.bury_reviews,\n                ease4: i.easy_multiplier,\n                ivl_fct: i.interval_multiplier,\n                max_ivl: i.maximum_review_interval,\n                per_day: i.reviews_per_day,\n                hard_factor: i.hard_multiplier,\n                other: rev_other,\n            },\n            lapse: LapseConfSchema11 {\n                delays: i.relearn_steps,\n                leech_action: match i.leech_action {\n                    1 => LeechAction::TagOnly,\n                    _ => LeechAction::Suspend,\n                },\n                leech_fails: i.leech_threshold,\n                min_int: i.minimum_lapse_interval,\n                mult: i.lapse_multiplier,\n                other: lapse_other,\n            },\n            other: top_other,\n            new_mix: i.new_mix,\n            new_per_day_minimum: i.new_per_day_minimum,\n            interday_learning_mix: i.interday_learning_mix,\n            review_order: i.review_order,\n            new_sort_order: i.new_card_sort_order,\n            new_gather_priority: i.new_card_gather_priority,\n            bury_interday_learning: i.bury_interday_learning,\n            fsrs_params_4: i.fsrs_params_4,\n            fsrs_params_5: i.fsrs_params_5,\n            fsrs_params_6: i.fsrs_params_6,\n            desired_retention: i.desired_retention,\n            sm2_retention: i.historical_retention,\n            param_search: i.param_search,\n            ignore_revlogs_before_date: i.ignore_revlogs_before_date,\n            easy_days_percentages: i.easy_days_percentages,\n        }\n    }\n}\n\nstatic RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! {\n    \"id\",\n    \"newSortOrder\",\n    \"replayq\",\n    \"newPerDayMinimum\",\n    \"usn\",\n    \"autoplay\",\n    \"dyn\",\n    \"maxTaken\",\n    \"reviewOrder\",\n    \"buryInterdayLearning\",\n    \"newMix\",\n    \"mod\",\n    \"timer\",\n    \"name\",\n    \"interdayLearningMix\",\n    \"newGatherPriority\",\n    \"fsrsWeights\",\n    \"fsrsParams5\",\n    \"fsrsParams6\",\n    \"desiredRetention\",\n    \"stopTimerOnAnswer\",\n    \"secondsToShowQuestion\",\n    \"secondsToShowAnswer\",\n    \"questionAction\",\n    \"answerAction\",\n    \"waitForAudio\",\n    \"sm2Retention\",\n    \"weightSearch\",\n    \"ignoreRevlogsBeforeDate\",\n    \"easyDaysPercentages\",\n};\n\nstatic RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! {\n    \"order\", \"delays\", \"bury\", \"perDay\", \"initialFactor\", \"ints\"\n};\n\nstatic RESERVED_DECKCONF_REV_KEYS: Set<&'static str> = phf_set! {\n    \"maxIvl\", \"hardFactor\", \"ease4\", \"ivlFct\", \"perDay\", \"bury\"\n};\n\nstatic RESERVED_DECKCONF_LAPSE_KEYS: Set<&'static str> = phf_set! {\n    \"leechFails\", \"mult\", \"leechAction\", \"delays\", \"minInt\"\n};\n\n#[cfg(test)]\nmod test {\n    use itertools::Itertools;\n    use serde::de::IntoDeserializer;\n    use serde_json::json;\n    use serde_json::Value;\n\n    use super::*;\n    use crate::prelude::*;\n\n    #[test]\n    fn all_reserved_fields_are_removed() -> Result<()> {\n        let key_source = DeckConfSchema11::default();\n        let mut config = DeckConfig::default();\n        let empty: &[&String] = &[];\n\n        config.inner.other = serde_json::to_vec(&key_source)?;\n        let s11 = DeckConfSchema11::from(config);\n        assert_eq!(&s11.other.keys().collect_vec(), empty);\n        assert_eq!(&s11.new.other.keys().collect_vec(), empty);\n        assert_eq!(&s11.rev.other.keys().collect_vec(), empty);\n        assert_eq!(&s11.lapse.other.keys().collect_vec(), empty);\n\n        Ok(())\n    }\n\n    #[test]\n    fn new_intervals() {\n        let decode = |value: Value| -> NewCardIntervals {\n            deserialize_new_intervals(value.into_deserializer()).unwrap()\n        };\n        assert_eq!(\n            decode(json!([2, 4, 6])),\n            NewCardIntervals {\n                good: 2,\n                easy: 4,\n                _unused: 0\n            }\n        );\n        assert_eq!(\n            decode(json!([3, 9])),\n            NewCardIntervals {\n                good: 3,\n                easy: 9,\n                _unused: 0\n            }\n        );\n        // invalid input will yield defaults\n        assert_eq!(\n            decode(json!([4])),\n            NewCardIntervals {\n                good: 1,\n                easy: 4,\n                _unused: 0\n            }\n        );\n        assert_eq!(\n            decode(json!([-5, 4, 3])),\n            NewCardIntervals {\n                good: 1,\n                easy: 4,\n                _unused: 0\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/deckconfig/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\n\nuse anki_proto::generic;\nuse rayon::iter::IntoParallelIterator;\nuse rayon::iter::ParallelIterator;\n\nuse crate::collection::Collection;\nuse crate::deckconfig::DeckConfSchema11;\nuse crate::deckconfig::DeckConfig;\nuse crate::deckconfig::DeckConfigId;\nuse crate::deckconfig::UpdateDeckConfigsRequest;\nuse crate::error::Result;\nuse crate::scheduler::fsrs::params::ignore_revlogs_before_date_to_ms;\nuse crate::scheduler::fsrs::simulator::is_included_card;\n\nimpl crate::services::DeckConfigService for Collection {\n    fn add_or_update_deck_config_legacy(\n        &mut self,\n        input: generic::Json,\n    ) -> Result<anki_proto::deck_config::DeckConfigId> {\n        let conf: DeckConfSchema11 = serde_json::from_slice(&input.json)?;\n        let mut conf: DeckConfig = conf.into();\n\n        self.transact_no_undo(|col| {\n            col.add_or_update_deck_config_legacy(&mut conf)?;\n            Ok(anki_proto::deck_config::DeckConfigId { dcid: conf.id.0 })\n        })\n    }\n\n    fn all_deck_config_legacy(&mut self) -> Result<generic::Json> {\n        let conf: Vec<DeckConfSchema11> = self\n            .storage\n            .all_deck_config()?\n            .into_iter()\n            .map(Into::into)\n            .collect();\n        serde_json::to_vec(&conf)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_deck_config(\n        &mut self,\n        input: anki_proto::deck_config::DeckConfigId,\n    ) -> Result<anki_proto::deck_config::DeckConfig> {\n        Ok(Collection::get_deck_config(self, input.into(), true)?\n            .unwrap()\n            .into())\n    }\n\n    fn get_deck_config_legacy(\n        &mut self,\n        input: anki_proto::deck_config::DeckConfigId,\n    ) -> Result<generic::Json> {\n        let conf = Collection::get_deck_config(self, input.into(), true)?.unwrap();\n        let conf: DeckConfSchema11 = conf.into();\n        Ok(serde_json::to_vec(&conf)?.into())\n    }\n\n    fn new_deck_config_legacy(&mut self) -> Result<generic::Json> {\n        serde_json::to_vec(&DeckConfSchema11::default())\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn remove_deck_config(&mut self, input: anki_proto::deck_config::DeckConfigId) -> Result<()> {\n        self.transact_no_undo(|col| col.remove_deck_config_inner(input.into()))\n    }\n\n    fn get_deck_configs_for_update(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> Result<anki_proto::deck_config::DeckConfigsForUpdate> {\n        self.get_deck_configs_for_update(input.did.into())\n    }\n\n    fn update_deck_configs(\n        &mut self,\n        input: anki_proto::deck_config::UpdateDeckConfigsRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.update_deck_configs(input.into()).map(Into::into)\n    }\n\n    fn get_ignored_before_count(\n        &mut self,\n        input: anki_proto::deck_config::GetIgnoredBeforeCountRequest,\n    ) -> Result<anki_proto::deck_config::GetIgnoredBeforeCountResponse> {\n        let timestamp = ignore_revlogs_before_date_to_ms(&input.ignore_revlogs_before_date)?;\n        let guard = self.search_cards_into_table(\n            &format!(\"{} -is:new\", input.search),\n            crate::search::SortMode::NoOrder,\n        )?;\n\n        Ok(anki_proto::deck_config::GetIgnoredBeforeCountResponse {\n            included: guard\n                .col\n                .storage\n                .get_card_count_with_ignore_before(timestamp)?,\n            total: guard.cards.try_into().unwrap_or(0),\n        })\n    }\n\n    fn get_retention_workload(\n        &mut self,\n        input: anki_proto::deck_config::GetRetentionWorkloadRequest,\n    ) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> {\n        let days_elapsed = self.timing_today().unwrap().days_elapsed as i32;\n        let guard =\n            self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?;\n\n        let revlogs = guard\n            .col\n            .storage\n            .get_revlog_entries_for_searched_cards_in_card_order()?;\n\n        let mut config = guard.col.get_optimal_retention_parameters(revlogs)?;\n        let cards = guard\n            .col\n            .storage\n            .all_searched_cards()?\n            .into_iter()\n            .filter(is_included_card)\n            .filter_map(|c| crate::card::Card::convert(c.clone(), days_elapsed, c.memory_state?))\n            .collect::<Vec<fsrs::Card>>();\n\n        config.deck_size = guard.cards;\n\n        let costs = (70u32..=99u32)\n            .into_par_iter()\n            .map(|dr| {\n                Ok((\n                    dr,\n                    fsrs::expected_workload_with_existing_cards(\n                        &input.w,\n                        dr as f32 / 100.,\n                        &config,\n                        &cards,\n                    )?,\n                ))\n            })\n            .collect::<Result<HashMap<_, _>>>()?;\n\n        Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { costs })\n    }\n}\n\nimpl From<DeckConfig> for anki_proto::deck_config::DeckConfig {\n    fn from(c: DeckConfig) -> Self {\n        anki_proto::deck_config::DeckConfig {\n            id: c.id.0,\n            name: c.name,\n            mtime_secs: c.mtime_secs.0,\n            usn: c.usn.0,\n            config: Some(c.inner),\n        }\n    }\n}\n\nimpl From<anki_proto::deck_config::UpdateDeckConfigsRequest> for UpdateDeckConfigsRequest {\n    fn from(c: anki_proto::deck_config::UpdateDeckConfigsRequest) -> Self {\n        let mode = c.mode();\n        UpdateDeckConfigsRequest {\n            target_deck_id: c.target_deck_id.into(),\n            configs: c.configs.into_iter().map(Into::into).collect(),\n            removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(),\n            mode,\n            card_state_customizer: c.card_state_customizer,\n            limits: c.limits.unwrap_or_default(),\n            new_cards_ignore_review_limit: c.new_cards_ignore_review_limit,\n            apply_all_parent_limits: c.apply_all_parent_limits,\n            fsrs: c.fsrs,\n            fsrs_reschedule: c.fsrs_reschedule,\n            fsrs_health_check: c.fsrs_health_check,\n        }\n    }\n}\n\nimpl From<anki_proto::deck_config::DeckConfig> for DeckConfig {\n    fn from(c: anki_proto::deck_config::DeckConfig) -> Self {\n        DeckConfig {\n            id: c.id.into(),\n            name: c.name,\n            mtime_secs: c.mtime_secs.into(),\n            usn: c.usn.into(),\n            inner: c.config.unwrap_or_default(),\n        }\n    }\n}\n\nimpl From<anki_proto::deck_config::DeckConfigId> for DeckConfigId {\n    fn from(dcid: anki_proto::deck_config::DeckConfigId) -> Self {\n        DeckConfigId(dcid.dcid)\n    }\n}\n"
  },
  {
    "path": "rslib/src/deckconfig/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\n\npub(crate) enum UndoableDeckConfigChange {\n    Added(Box<DeckConfig>),\n    Updated(Box<DeckConfig>),\n    Removed(Box<DeckConfig>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_deck_config_change(\n        &mut self,\n        change: UndoableDeckConfigChange,\n    ) -> Result<()> {\n        match change {\n            UndoableDeckConfigChange::Added(config) => self.remove_deck_config_undoable(*config),\n            UndoableDeckConfigChange::Updated(config) => {\n                let current = self\n                    .storage\n                    .get_deck_config(config.id)?\n                    .or_invalid(\"deck config disappeared\")?;\n                self.update_deck_config_undoable(&config, current)\n            }\n            UndoableDeckConfigChange::Removed(config) => self.restore_deleted_deck_config(*config),\n        }\n    }\n\n    pub(crate) fn remove_deck_config_undoable(&mut self, config: DeckConfig) -> Result<()> {\n        self.storage.remove_deck_conf(config.id)?;\n        self.save_undo(UndoableDeckConfigChange::Removed(Box::new(config)));\n        Ok(())\n    }\n\n    pub(super) fn add_deck_config_undoable(\n        &mut self,\n        config: &mut DeckConfig,\n    ) -> Result<(), AnkiError> {\n        self.storage.add_deck_conf(config)?;\n        self.save_undo(UndoableDeckConfigChange::Added(Box::new(config.clone())));\n        Ok(())\n    }\n\n    pub(crate) fn add_deck_config_if_unique_undoable(&mut self, config: &DeckConfig) -> Result<()> {\n        if self.storage.add_deck_conf_if_unique(config)? {\n            self.save_undo(UndoableDeckConfigChange::Added(Box::new(config.clone())));\n        }\n        Ok(())\n    }\n\n    pub(super) fn update_deck_config_undoable(\n        &mut self,\n        config: &DeckConfig,\n        original: DeckConfig,\n    ) -> Result<()> {\n        self.save_undo(UndoableDeckConfigChange::Updated(Box::new(original)));\n        self.storage.update_deck_conf(config)\n    }\n\n    fn restore_deleted_deck_config(&mut self, config: DeckConfig) -> Result<()> {\n        self.storage\n            .add_or_update_deck_config_with_existing_id(&config)?;\n        self.save_undo(UndoableDeckConfigChange::Added(Box::new(config)));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/deckconfig/update.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Updating configs in bulk, from the deck options screen.\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::iter;\n\nuse anki_proto::deck_config::deck_configs_for_update::current_deck::Limits;\nuse anki_proto::deck_config::deck_configs_for_update::ConfigWithExtra;\nuse anki_proto::deck_config::deck_configs_for_update::CurrentDeck;\nuse anki_proto::deck_config::UpdateDeckConfigsMode;\nuse anki_proto::decks::deck::normal::DayLimit;\nuse fsrs::DEFAULT_PARAMETERS;\nuse fsrs::FSRS;\n\nuse crate::config::I32ConfigKey;\nuse crate::config::StringKey;\nuse crate::decks::NormalDeck;\nuse crate::prelude::*;\nuse crate::scheduler::fsrs::memory_state::UpdateMemoryStateEntry;\nuse crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest;\nuse crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config;\nuse crate::scheduler::fsrs::params::ComputeParamsRequest;\nuse crate::search::JoinSearches;\nuse crate::search::Negated;\nuse crate::search::SearchNode;\nuse crate::search::StateKind;\nuse crate::storage::comma_separated_ids;\n\n#[derive(Debug, Clone)]\npub struct UpdateDeckConfigsRequest {\n    pub target_deck_id: DeckId,\n    /// Deck will be set to last provided deck config.\n    pub configs: Vec<DeckConfig>,\n    pub removed_config_ids: Vec<DeckConfigId>,\n    pub mode: UpdateDeckConfigsMode,\n    pub card_state_customizer: String,\n    pub limits: Limits,\n    pub new_cards_ignore_review_limit: bool,\n    pub apply_all_parent_limits: bool,\n    pub fsrs: bool,\n    pub fsrs_reschedule: bool,\n    pub fsrs_health_check: bool,\n}\n\nimpl Collection {\n    /// Information required for the deck options screen.\n    pub fn get_deck_configs_for_update(\n        &mut self,\n        deck: DeckId,\n    ) -> Result<anki_proto::deck_config::DeckConfigsForUpdate> {\n        let mut defaults = DeckConfig::default();\n        defaults.inner.fsrs_params_6 = DEFAULT_PARAMETERS.into();\n        let last_optimize = self.get_config_i32(I32ConfigKey::LastFsrsOptimize) as u32;\n        let days_since_last_fsrs_optimize = if last_optimize > 0 {\n            self.timing_today()?\n                .days_elapsed\n                .saturating_sub(last_optimize)\n        } else {\n            0\n        };\n        Ok(anki_proto::deck_config::DeckConfigsForUpdate {\n            all_config: self.get_deck_config_with_extra_for_update()?,\n            current_deck: Some(self.get_current_deck_for_update(deck)?),\n            defaults: Some(defaults.into()),\n            schema_modified: self\n                .storage\n                .get_collection_timestamps()?\n                .schema_changed_since_sync(),\n            card_state_customizer: self.get_config_string(StringKey::CardStateCustomizer),\n            new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit),\n            apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits),\n            fsrs: self.get_config_bool(BoolKey::Fsrs),\n            fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck),\n            fsrs_legacy_evaluate: self.get_config_bool(BoolKey::FsrsLegacyEvaluate),\n            days_since_last_fsrs_optimize,\n        })\n    }\n\n    /// Information required for the deck options screen.\n    pub fn update_deck_configs(&mut self, input: UpdateDeckConfigsRequest) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateDeckConfig, |col| {\n            col.update_deck_configs_inner(input)\n        })\n    }\n}\n\nimpl Collection {\n    fn get_deck_config_with_extra_for_update(&self) -> Result<Vec<ConfigWithExtra>> {\n        // grab the config and sort it\n        let mut config = self.storage.all_deck_config()?;\n        config.sort_unstable_by(|a, b| a.name.cmp(&b.name));\n        // pre-fill empty fsrs params with older params\n        config.iter_mut().for_each(|c| {\n            if c.inner.fsrs_params_6.is_empty() {\n                c.inner.fsrs_params_6 = if c.inner.fsrs_params_5.is_empty() {\n                    c.inner.fsrs_params_4.clone()\n                } else {\n                    c.inner.fsrs_params_5.clone()\n                };\n            }\n        });\n\n        // combine with use counts\n        let counts = self.get_deck_config_use_counts()?;\n        Ok(config\n            .into_iter()\n            .map(|config| ConfigWithExtra {\n                use_count: counts.get(&config.id).cloned().unwrap_or_default() as u32,\n                config: Some(config.into()),\n            })\n            .collect())\n    }\n\n    fn get_deck_config_use_counts(&self) -> Result<HashMap<DeckConfigId, usize>> {\n        let mut counts = HashMap::new();\n        for deck in self.storage.get_all_decks()? {\n            if let Ok(normal) = deck.normal() {\n                *counts.entry(DeckConfigId(normal.config_id)).or_default() += 1;\n            }\n        }\n\n        Ok(counts)\n    }\n\n    fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result<CurrentDeck> {\n        let deck = self.get_deck(deck)?.or_not_found(deck)?;\n        let normal = deck.normal()?;\n        let today = self.timing_today()?.days_elapsed;\n\n        Ok(CurrentDeck {\n            name: deck.human_name(),\n            config_id: normal.config_id,\n            parent_config_ids: self\n                .parent_config_ids(&deck)?\n                .into_iter()\n                .map(Into::into)\n                .collect(),\n            limits: Some(normal_deck_to_limits(normal, today)),\n        })\n    }\n\n    /// Deck configs used by parent decks.\n    fn parent_config_ids(&self, deck: &Deck) -> Result<HashSet<DeckConfigId>> {\n        Ok(self\n            .storage\n            .parent_decks(deck)?\n            .iter()\n            .filter_map(|deck| {\n                deck.normal()\n                    .ok()\n                    .map(|normal| DeckConfigId(normal.config_id))\n            })\n            .collect())\n    }\n\n    fn update_deck_configs_inner(&mut self, mut req: UpdateDeckConfigsRequest) -> Result<()> {\n        require!(!req.configs.is_empty(), \"config not provided\");\n        let configs_before_update = self.storage.get_deck_config_map()?;\n        let mut configs_after_update = configs_before_update.clone();\n\n        // handle removals first\n        for dcid in &req.removed_config_ids {\n            self.remove_deck_config_inner(*dcid)?;\n            configs_after_update.remove(dcid);\n        }\n\n        if req.mode == UpdateDeckConfigsMode::ComputeAllParams {\n            self.compute_all_params(&mut req)?;\n        }\n\n        // add/update provided configs\n        for conf in &mut req.configs {\n            // If the user has provided empty FSRS6 params, zero out any\n            // old params as well, so we don't fall back on them, which would\n            // be surprising as they're not shown in the GUI.\n            if conf.inner.fsrs_params_6.is_empty() {\n                conf.inner.fsrs_params_5.clear();\n                conf.inner.fsrs_params_4.clear();\n            }\n            // check the provided parameters are valid before we save them\n            FSRS::new(Some(conf.fsrs_params()))?;\n            self.add_or_update_deck_config(conf)?;\n            configs_after_update.insert(conf.id, conf.clone());\n        }\n\n        // get selected deck and possibly children\n        let selected_deck_ids: HashSet<_> = if req.mode == UpdateDeckConfigsMode::ApplyToChildren {\n            let deck = self\n                .storage\n                .get_deck(req.target_deck_id)?\n                .or_not_found(req.target_deck_id)?;\n            self.storage\n                .child_decks(&deck)?\n                .iter()\n                .chain(iter::once(&deck))\n                .map(|d| d.id)\n                .collect()\n        } else {\n            [req.target_deck_id].iter().cloned().collect()\n        };\n\n        // loop through all normal decks\n        let usn = self.usn()?;\n        let today = self.timing_today()?.days_elapsed;\n        let selected_config = req.configs.last().unwrap();\n        let mut decks_needing_memory_recompute: HashMap<DeckConfigId, Vec<DeckId>> =\n            Default::default();\n        let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != req.fsrs;\n        if fsrs_toggled {\n            self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?;\n        }\n        let mut deck_desired_retention: HashMap<DeckId, f32> = Default::default();\n        for deck in self.storage.get_all_decks()? {\n            if let Ok(normal) = deck.normal() {\n                let deck_id = deck.id;\n                // previous order & params\n                let previous_config_id = DeckConfigId(normal.config_id);\n                let previous_config = configs_before_update.get(&previous_config_id);\n                let previous_order = previous_config\n                    .map(|c| c.inner.new_card_insert_order())\n                    .unwrap_or_default();\n                let previous_params = previous_config.map(|c| c.fsrs_params());\n                let previous_preset_dr = previous_config.map(|c| c.inner.desired_retention);\n                let previous_deck_dr = normal.desired_retention;\n                let previous_dr = previous_deck_dr.or(previous_preset_dr);\n                let previous_easy_days = previous_config.map(|c| &c.inner.easy_days_percentages);\n\n                // if a selected (sub)deck, or its old config was removed, update deck to point\n                // to new config\n                let (current_config_id, current_deck_dr) = if selected_deck_ids.contains(&deck.id)\n                    || !configs_after_update.contains_key(&previous_config_id)\n                {\n                    let mut updated = deck.clone();\n                    updated.normal_mut()?.config_id = selected_config.id.0;\n                    update_deck_limits(updated.normal_mut()?, &req.limits, today);\n                    self.update_deck_inner(&mut updated, deck, usn)?;\n                    (selected_config.id, updated.normal()?.desired_retention)\n                } else {\n                    (previous_config_id, previous_deck_dr)\n                };\n\n                // if new order differs, deck needs re-sorting\n                let current_config = configs_after_update.get(&current_config_id);\n                let current_order = current_config\n                    .map(|c| c.inner.new_card_insert_order())\n                    .unwrap_or_default();\n                if previous_order != current_order {\n                    self.sort_deck(deck_id, current_order, usn)?;\n                }\n\n                // if params differ, memory state needs to be recomputed\n                let current_params = current_config.map(|c| c.fsrs_params());\n                let current_preset_dr = current_config.map(|c| c.inner.desired_retention);\n                let current_dr = current_deck_dr.or(current_preset_dr);\n                let current_easy_days = current_config.map(|c| &c.inner.easy_days_percentages);\n                if fsrs_toggled\n                    || previous_params != current_params\n                    || previous_dr != current_dr\n                    || (req.fsrs_reschedule && previous_easy_days != current_easy_days)\n                {\n                    decks_needing_memory_recompute\n                        .entry(current_config_id)\n                        .or_default()\n                        .push(deck_id);\n                }\n                if let Some(desired_retention) = current_deck_dr {\n                    deck_desired_retention.insert(deck_id, desired_retention);\n                }\n                self.adjust_remaining_steps_in_deck(deck_id, previous_config, current_config, usn)?;\n            }\n        }\n\n        if !decks_needing_memory_recompute.is_empty() {\n            let input: Vec<UpdateMemoryStateEntry> = decks_needing_memory_recompute\n                .into_iter()\n                .map(|(conf_id, search)| {\n                    let config = configs_after_update.get(&conf_id);\n                    let params = config.and_then(|c| {\n                        if req.fsrs {\n                            Some(UpdateMemoryStateRequest {\n                                params: c.fsrs_params().clone(),\n                                preset_desired_retention: c.inner.desired_retention,\n                                max_interval: c.inner.maximum_review_interval,\n                                reschedule: req.fsrs_reschedule,\n                                historical_retention: c.inner.historical_retention,\n                                deck_desired_retention: deck_desired_retention.clone(),\n                            })\n                        } else {\n                            None\n                        }\n                    });\n                    Ok(UpdateMemoryStateEntry {\n                        req: params,\n                        search: SearchNode::DeckIdsWithoutChildren(comma_separated_ids(&search)),\n                        ignore_before: config\n                            .map(ignore_revlogs_before_ms_from_config)\n                            .unwrap_or(Ok(0.into()))?,\n                    })\n                })\n                .collect::<Result<_>>()?;\n            self.update_memory_state(input)?;\n        }\n\n        self.set_config_string_inner(StringKey::CardStateCustomizer, &req.card_state_customizer)?;\n        self.set_config_bool_inner(\n            BoolKey::NewCardsIgnoreReviewLimit,\n            req.new_cards_ignore_review_limit,\n        )?;\n        self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?;\n        self.set_config_bool_inner(BoolKey::FsrsHealthCheck, req.fsrs_health_check)?;\n\n        Ok(())\n    }\n\n    /// Adjust the remaining steps of cards in the given deck according to the\n    /// config change.\n    pub(crate) fn adjust_remaining_steps_in_deck(\n        &mut self,\n        deck: DeckId,\n        previous_config: Option<&DeckConfig>,\n        current_config: Option<&DeckConfig>,\n        usn: Usn,\n    ) -> Result<()> {\n        if let (Some(old), Some(new)) = (previous_config, current_config) {\n            for (search, old_steps, new_steps) in [\n                (\n                    SearchBuilder::learning_cards(),\n                    &old.inner.learn_steps,\n                    &new.inner.learn_steps,\n                ),\n                (\n                    SearchBuilder::relearning_cards(),\n                    &old.inner.relearn_steps,\n                    &new.inner.relearn_steps,\n                ),\n            ] {\n                if old_steps == new_steps {\n                    continue;\n                }\n                let search = search.clone().and(SearchNode::from_deck_id(deck, false));\n                for mut card in self.all_cards_for_search(search)? {\n                    self.adjust_remaining_steps(&mut card, old_steps, new_steps, usn)?;\n                }\n            }\n        }\n        Ok(())\n    }\n    fn compute_all_params(&mut self, req: &mut UpdateDeckConfigsRequest) -> Result<()> {\n        require!(req.fsrs, \"FSRS must be enabled\");\n\n        // frontend didn't include any unmodified deck configs, so we need to fill them\n        // in\n        let changed_configs: HashSet<_> = req.configs.iter().map(|c| c.id).collect();\n        let previous_last = req.configs.pop().or_invalid(\"no configs provided\")?;\n        for config in self.storage.all_deck_config()? {\n            if !changed_configs.contains(&config.id) {\n                req.configs.push(config);\n            }\n        }\n        // other parts of the code expect the currently-selected preset to come last\n        req.configs.push(previous_last);\n\n        // calculate and apply params to each preset\n        let config_len = req.configs.len() as u32;\n        for (idx, config) in req.configs.iter_mut().enumerate() {\n            let search = if config.inner.param_search.trim().is_empty() {\n                SearchNode::Preset(config.name.clone())\n                    .and(SearchNode::State(StateKind::Suspended).negated())\n                    .try_into_search()?\n                    .to_string()\n            } else {\n                config.inner.param_search.clone()\n            };\n            let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?;\n            let num_of_relearning_steps = config.inner.relearn_steps.len();\n            match self.compute_params(ComputeParamsRequest {\n                search: &search,\n                ignore_revlogs_before_ms,\n                current_preset: idx as u32 + 1,\n                total_presets: config_len,\n                current_params: config.fsrs_params(),\n                num_of_relearning_steps,\n                health_check: false,\n            }) {\n                Ok(params) => {\n                    println!(\"{}: {:?}\", config.name, params.params);\n                    config.inner.fsrs_params_6 = params.params;\n                }\n                Err(AnkiError::Interrupted) => return Err(AnkiError::Interrupted),\n                Err(err) => {\n                    println!(\"{}: {}\", config.name, err)\n                }\n            }\n            let today = self.timing_today()?.days_elapsed as i32;\n            self.set_config_i32_inner(I32ConfigKey::LastFsrsOptimize, today)?;\n        }\n        Ok(())\n    }\n}\n\nfn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits {\n    Limits {\n        review: deck.review_limit,\n        new: deck.new_limit,\n        review_today: deck.review_limit_today.map(|limit| limit.limit),\n        new_today: deck.new_limit_today.map(|limit| limit.limit),\n        review_today_active: deck\n            .review_limit_today\n            .map(|limit| limit.today == today)\n            .unwrap_or_default(),\n        new_today_active: deck\n            .new_limit_today\n            .map(|limit| limit.today == today)\n            .unwrap_or_default(),\n        desired_retention: deck.desired_retention,\n    }\n}\n\nfn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) {\n    deck.review_limit = limits.review;\n    deck.new_limit = limits.new;\n    update_day_limit(&mut deck.review_limit_today, limits.review_today, today);\n    update_day_limit(&mut deck.new_limit_today, limits.new_today, today);\n    deck.desired_retention = limits.desired_retention;\n}\n\nfn update_day_limit(day_limit: &mut Option<DayLimit>, new_limit: Option<u32>, today: u32) {\n    if let Some(limit) = new_limit {\n        day_limit.replace(DayLimit { limit, today });\n    } else {\n        // if the collection was created today, the\n        // \"preserve last value\" hack below won't work\n        // clear \"future\" limits as well (from imports)\n        day_limit.take_if(|limit| limit.today == 0 || limit.today > today);\n        if let Some(limit) = day_limit {\n            // instead of setting to None, only make sure today is in the past,\n            // thus preserving last used value\n            limit.today = limit.today.min(today.saturating_sub(1));\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::deckconfig::NewCardInsertOrder;\n    use crate::tests::open_test_collection_with_learning_card;\n    use crate::tests::open_test_collection_with_relearning_card;\n\n    #[test]\n    fn updating() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note1 = nt.new_note();\n        col.add_note(&mut note1, DeckId(1))?;\n        let card1_id = col.storage.card_ids_of_notes(&[note1.id])?[0];\n        for _ in 0..9 {\n            let mut note = nt.new_note();\n            col.add_note(&mut note, DeckId(1))?;\n        }\n\n        // add the keys so it doesn't trigger a change below\n        col.set_config_string_inner(StringKey::CardStateCustomizer, \"\")?;\n        col.set_config_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?;\n        col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?;\n        col.set_config_bool_inner(BoolKey::FsrsHealthCheck, true)?;\n\n        // pretend we're in sync\n        let stamps = col.storage.get_collection_timestamps()?;\n        col.storage.set_last_sync(stamps.schema_change)?;\n\n        let full_sync_required = |col: &mut Collection| -> bool {\n            col.storage\n                .get_collection_timestamps()\n                .unwrap()\n                .schema_changed_since_sync()\n        };\n        let reset_card1_pos = |col: &mut Collection| {\n            let mut card = col.storage.get_card(card1_id).unwrap().unwrap();\n            // set it out of bounds, so we can be sure it has changed\n            card.due = 0;\n            col.storage.update_card(&card).unwrap();\n        };\n        let card1_pos = |col: &mut Collection| col.storage.get_card(card1_id).unwrap().unwrap().due;\n\n        // if nothing changed, no changes should be made\n        let output = col.get_deck_configs_for_update(DeckId(1))?;\n        let mut input = UpdateDeckConfigsRequest {\n            target_deck_id: DeckId(1),\n            configs: output\n                .all_config\n                .into_iter()\n                .map(|c| c.config.unwrap().into())\n                .collect(),\n            removed_config_ids: vec![],\n            mode: UpdateDeckConfigsMode::Normal,\n            card_state_customizer: \"\".to_string(),\n            limits: Limits::default(),\n            new_cards_ignore_review_limit: false,\n            apply_all_parent_limits: false,\n            fsrs: false,\n            fsrs_reschedule: false,\n            fsrs_health_check: true,\n        };\n        assert!(!col.update_deck_configs(input.clone())?.changes.had_change());\n\n        // modifying a value should update the config, but not the deck\n        input.configs[0].inner.new_per_day += 1;\n        let changes = col.update_deck_configs(input.clone())?.changes.changes;\n        assert!(!changes.deck);\n        assert!(changes.deck_config);\n        assert!(!changes.card);\n\n        // adding a new config will update the deck as well\n        let new_config = DeckConfig {\n            id: DeckConfigId(0),\n            ..input.configs[0].clone()\n        };\n        input.configs.push(new_config);\n        let changes = col.update_deck_configs(input.clone())?.changes.changes;\n        assert!(changes.deck);\n        assert!(changes.deck_config);\n        assert!(!changes.card);\n        let allocated_id = col.get_deck(DeckId(1))?.unwrap().normal()?.config_id;\n        assert_ne!(allocated_id, 0);\n        assert_ne!(allocated_id, 1);\n\n        // changing the order will cause the cards to be re-sorted\n        assert_eq!(card1_pos(&mut col), 1);\n        reset_card1_pos(&mut col);\n        assert_eq!(card1_pos(&mut col), 0);\n        input.configs[1].inner.new_card_insert_order = NewCardInsertOrder::Random as i32;\n        assert!(col.update_deck_configs(input.clone())?.changes.changes.card);\n        assert_ne!(card1_pos(&mut col), 0);\n\n        // removing the config will assign the selected config (default in this case),\n        // and as default has normal sort order, that will reset the order again\n        assert!(!full_sync_required(&mut col));\n        reset_card1_pos(&mut col);\n        input.configs.remove(1);\n        input.removed_config_ids.push(DeckConfigId(allocated_id));\n        col.update_deck_configs(input)?;\n        let current_id = col.get_deck(DeckId(1))?.unwrap().normal()?.config_id;\n        assert_eq!(current_id, 1);\n        assert_eq!(card1_pos(&mut col), 1);\n        // should have forced a full sync\n        assert!(full_sync_required(&mut col));\n\n        Ok(())\n    }\n\n    #[test]\n    fn should_increase_remaining_learning_steps_if_unpassed_learning_step_added() {\n        let mut col = open_test_collection_with_learning_card();\n        col.set_default_learn_steps(vec![1., 10., 100.]);\n        assert_eq!(col.get_first_card().remaining_steps, 3);\n    }\n\n    #[test]\n    fn should_keep_remaining_learning_steps_if_unpassed_relearning_step_added() {\n        let mut col = open_test_collection_with_learning_card();\n        col.set_default_relearn_steps(vec![1., 10., 100.]);\n        assert_eq!(col.get_first_card().remaining_steps, 2);\n    }\n\n    #[test]\n    fn should_keep_remaining_learning_steps_if_passed_learning_step_added() {\n        let mut col = open_test_collection_with_learning_card();\n        col.answer_good();\n        col.set_default_learn_steps(vec![1., 1., 10.]);\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n    }\n\n    #[test]\n    fn should_keep_at_least_one_remaining_learning_step() {\n        let mut col = open_test_collection_with_learning_card();\n        col.answer_good();\n        col.set_default_learn_steps(vec![1.]);\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n    }\n\n    #[test]\n    fn should_increase_remaining_relearning_steps_if_unpassed_relearning_step_added() {\n        let mut col = open_test_collection_with_relearning_card();\n        col.set_default_relearn_steps(vec![1., 10., 100.]);\n        assert_eq!(col.get_first_card().remaining_steps, 3);\n    }\n\n    #[test]\n    fn should_keep_remaining_relearning_steps_if_unpassed_learning_step_added() {\n        let mut col = open_test_collection_with_relearning_card();\n        col.set_default_learn_steps(vec![1., 10., 100.]);\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n    }\n\n    #[test]\n    fn should_keep_remaining_relearning_steps_if_passed_relearning_step_added() {\n        let mut col = open_test_collection_with_relearning_card();\n        col.set_default_relearn_steps(vec![10., 100.]);\n        col.answer_good();\n        col.set_default_relearn_steps(vec![1., 10., 100.]);\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n    }\n\n    #[test]\n    fn should_keep_at_least_one_remaining_relearning_step() {\n        let mut col = open_test_collection_with_relearning_card();\n        col.set_default_relearn_steps(vec![10., 100.]);\n        col.answer_good();\n        col.set_default_relearn_steps(vec![1.]);\n        assert_eq!(col.get_first_card().remaining_steps, 1);\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/addupdate.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Adding and updating.\n\nuse super::name::immediate_parent_name;\nuse crate::error::FilteredDeckError;\nuse crate::prelude::*;\n\nimpl Collection {\n    /// Add a new deck. The id must be 0, as it will be automatically assigned.\n    pub fn add_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {\n        self.transact(Op::AddDeck, |col| col.add_deck_inner(deck, col.usn()?))\n    }\n\n    pub fn update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateDeck, |col| {\n            let existing_deck = col.storage.get_deck(deck.id)?.or_not_found(deck.id)?;\n            col.update_deck_inner(deck, existing_deck, col.usn()?)\n        })\n    }\n\n    /// Add or update an existing deck modified by the user. May add parents,\n    /// or rename children as required. Prefer add_deck() or update_deck() to\n    /// be explicit about your intentions; this function mainly exists so we\n    /// can integrate with older Python code that behaved this way.\n    pub fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {\n        if deck.id.0 == 0 {\n            self.add_deck(deck)\n        } else {\n            self.update_deck(deck)\n        }\n    }\n}\n\nimpl Collection {\n    /// Rename deck if not unique. Bumps mtime and usn if\n    /// name was changed, but otherwise leaves it the same.\n    pub(super) fn prepare_deck_for_update(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> {\n        if deck.name.maybe_normalize() {\n            deck.set_modified(usn);\n        }\n        self.ensure_deck_name_unique(deck, usn)\n    }\n\n    pub(crate) fn add_deck_inner(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> {\n        require!(deck.id.0 == 0, \"deck to add must have id 0\");\n        self.prepare_deck_for_update(deck, usn)?;\n        deck.set_modified(usn);\n        self.match_or_create_parents(deck, usn)?;\n        self.add_deck_undoable(deck)\n    }\n\n    pub(crate) fn update_deck_inner(\n        &mut self,\n        deck: &mut Deck,\n        original: Deck,\n        usn: Usn,\n    ) -> Result<()> {\n        self.prepare_deck_for_update(deck, usn)?;\n        if deck == &original {\n            return Ok(());\n        }\n        deck.set_modified(usn);\n        let name_changed = original.name != deck.name;\n        if name_changed {\n            // match closest parent name\n            self.match_or_create_parents(deck, usn)?;\n            // rename children\n            self.rename_child_decks(&original, &deck.name, usn)?;\n        }\n        self.update_single_deck_undoable(deck, original)?;\n        if name_changed {\n            // after updating, we need to ensure all grandparents exist, which may not be\n            // the case in the parent->child case\n            self.create_missing_parents(&deck.name, usn)?;\n        }\n        Ok(())\n    }\n\n    /// Add/update a single deck when syncing/importing. Ensures name is unique\n    /// & normalized, but does not check parents/children or update mtime\n    /// (unless the name was changed). Caller must set up transaction.\n    pub(crate) fn add_or_update_single_deck_with_existing_id(\n        &mut self,\n        deck: &mut Deck,\n        usn: Usn,\n    ) -> Result<()> {\n        self.prepare_deck_for_update(deck, usn)?;\n        self.add_or_update_deck_with_existing_id_undoable(deck)\n    }\n\n    pub(crate) fn recover_missing_deck(&mut self, did: DeckId, usn: Usn) -> Result<()> {\n        let mut deck = Deck::new_normal();\n        deck.id = did;\n        deck.name = NativeDeckName::from_native_str(format!(\"recovered{did}\"));\n        deck.set_modified(usn);\n        self.add_or_update_single_deck_with_existing_id(&mut deck, usn)\n    }\n\n    /// Add a single, normal deck with the provided name for a child deck.\n    /// Caller must have done necessarily validation on name.\n    fn add_parent_deck(&mut self, machine_name: &str, usn: Usn) -> Result<()> {\n        let mut deck = Deck::new_normal();\n        deck.name = NativeDeckName::from_native_str(machine_name);\n        deck.set_modified(usn);\n        self.add_deck_undoable(&mut deck)\n    }\n\n    /// If parent deck(s) exist, rewrite name to match their case.\n    /// If they don't exist, create them.\n    /// Returns an error if a DB operation fails, or if the first existing\n    /// parent is a filtered deck.\n    fn match_or_create_parents(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> {\n        let child_split: Vec<_> = deck.name.components().collect();\n        if let Some(parent_deck) = self.first_existing_parent(deck.name.as_native_str(), 0)? {\n            if parent_deck.is_filtered() {\n                return Err(FilteredDeckError::MustBeLeafNode.into());\n            }\n            let parent_count = parent_deck.name.components().count();\n            let need_create = parent_count != child_split.len() - 1;\n            deck.name = NativeDeckName::from_native_str(format!(\n                \"{}\\x1f{}\",\n                parent_deck.name,\n                &child_split[parent_count..].join(\"\\x1f\")\n            ));\n            if need_create {\n                self.create_missing_parents(&deck.name, usn)?;\n            }\n            Ok(())\n        } else if child_split.len() == 1 {\n            // no parents required\n            Ok(())\n        } else {\n            // no existing parents\n            self.create_missing_parents(&deck.name, usn)\n        }\n    }\n\n    fn create_missing_parents(&mut self, name: &NativeDeckName, usn: Usn) -> Result<()> {\n        let mut machine_name = name.as_native_str();\n        while let Some(parent_name) = immediate_parent_name(machine_name) {\n            if self.storage.get_deck_id(parent_name)?.is_none() {\n                self.add_parent_deck(parent_name, usn)?;\n            }\n            machine_name = parent_name;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn first_existing_parent(\n        &self,\n        machine_name: &str,\n        recursion_level: usize,\n    ) -> Result<Option<Deck>> {\n        require!(recursion_level < 11, \"deck nesting level too deep\");\n        if let Some(parent_name) = immediate_parent_name(machine_name) {\n            if let Some(parent_did) = self.storage.get_deck_id(parent_name)? {\n                self.storage.get_deck(parent_did)\n            } else {\n                self.first_existing_parent(parent_name, recursion_level + 1)\n            }\n        } else {\n            Ok(None)\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/counts.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) struct DueCounts {\n    pub new: u32,\n    pub review: u32,\n    /// interday+intraday\n    pub learning: u32,\n\n    pub intraday_learning: u32,\n    pub interday_learning: u32,\n    pub total_cards: u32,\n}\n\nimpl Deck {\n    /// Return the studied counts if studied today.\n    /// May be negative if user has extended limits.\n    pub(crate) fn new_rev_counts(&self, today: u32) -> (i32, i32) {\n        if self.common.last_day_studied == today {\n            (self.common.new_studied, self.common.review_studied)\n        } else {\n            (0, 0)\n        }\n    }\n}\n\nimpl Collection {\n    /// Get due counts for decks at the given timestamp.\n    pub(crate) fn due_counts(\n        &mut self,\n        days_elapsed: u32,\n        learn_cutoff: u32,\n    ) -> Result<HashMap<DeckId, DueCounts>> {\n        self.storage.due_counts(days_elapsed, learn_cutoff)\n    }\n\n    pub(crate) fn counts_for_deck_today(\n        &mut self,\n        did: DeckId,\n    ) -> Result<anki_proto::scheduler::CountsForDeckTodayResponse> {\n        let today = self.current_due_day(0)?;\n        let mut deck = self.storage.get_deck(did)?.or_not_found(did)?;\n        deck.reset_stats_if_day_changed(today);\n        Ok(anki_proto::scheduler::CountsForDeckTodayResponse {\n            new: deck.common.new_studied,\n            review: deck.common.review_studied,\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/current.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::Arc;\n\nuse crate::config::ConfigKey;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn set_current_deck(&mut self, deck: DeckId) -> Result<OpOutput<()>> {\n        self.transact(Op::SetCurrentDeck, |col| col.set_current_deck_inner(deck))\n    }\n\n    /// Fetch the current deck, falling back to the default if the previously\n    /// selected deck is invalid.\n    pub fn get_current_deck(&mut self) -> Result<Arc<Deck>> {\n        if let Some(deck) = self.get_deck(self.get_current_deck_id())? {\n            return Ok(deck);\n        }\n        self.get_deck(DeckId(1))?.or_not_found(DeckId(1))\n    }\n}\n\nimpl Collection {\n    /// The returned id may reference a deck that does not exist;\n    /// prefer using get_current_deck() instead.\n    pub(crate) fn get_current_deck_id(&self) -> DeckId {\n        self.get_config_optional(ConfigKey::CurrentDeckId)\n            .unwrap_or(DeckId(1))\n    }\n\n    fn set_current_deck_inner(&mut self, deck: DeckId) -> Result<()> {\n        if self.set_current_deck_id(deck)? {\n            self.state.card_queues = None;\n        }\n        Ok(())\n    }\n\n    fn set_current_deck_id(&mut self, did: DeckId) -> Result<bool> {\n        self.set_config(ConfigKey::CurrentDeckId, &did)\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/filtered.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse strum::IntoEnumIterator;\n\nuse super::DeckCommon;\nuse super::FilteredDeck;\nuse super::FilteredSearchOrder;\nuse super::FilteredSearchTerm;\nuse crate::prelude::*;\n\nimpl Deck {\n    pub fn new_filtered() -> Deck {\n        let mut filt = FilteredDeck::default();\n        filt.search_terms.push(FilteredSearchTerm {\n            search: \"\".into(),\n            limit: 100,\n            order: FilteredSearchOrder::Random as i32,\n        });\n        filt.search_terms.push(FilteredSearchTerm {\n            search: \"\".into(),\n            limit: 20,\n            order: FilteredSearchOrder::Due as i32,\n        });\n        filt.preview_again_secs = 60;\n        filt.preview_hard_secs = 600;\n        filt.reschedule = true;\n        Deck {\n            id: DeckId(0),\n            name: NativeDeckName::from_native_str(\"\"),\n            mtime_secs: TimestampSecs(0),\n            usn: Usn(0),\n            common: DeckCommon {\n                study_collapsed: true,\n                browser_collapsed: true,\n                ..Default::default()\n            },\n            kind: DeckKind::Filtered(filt),\n        }\n    }\n\n    pub(crate) fn is_filtered(&self) -> bool {\n        matches!(self.kind, DeckKind::Filtered(_))\n    }\n}\n\npub fn search_order_labels(tr: &I18n) -> Vec<String> {\n    FilteredSearchOrder::iter()\n        .map(|v| search_order_label(v, tr))\n        .collect()\n}\n\nfn search_order_label(order: FilteredSearchOrder, tr: &I18n) -> String {\n    match order {\n        FilteredSearchOrder::OldestReviewedFirst => tr.decks_oldest_seen_first(),\n        FilteredSearchOrder::Random => tr.decks_random(),\n        FilteredSearchOrder::IntervalsAscending => tr.decks_increasing_intervals(),\n        FilteredSearchOrder::IntervalsDescending => tr.decks_decreasing_intervals(),\n        FilteredSearchOrder::Lapses => tr.decks_most_lapses(),\n        FilteredSearchOrder::Added => tr.decks_order_added(),\n        FilteredSearchOrder::Due => tr.decks_order_due(),\n        FilteredSearchOrder::ReverseAdded => tr.decks_latest_added_first(),\n        FilteredSearchOrder::RetrievabilityAscending => {\n            tr.deck_config_sort_order_retrievability_ascending()\n        }\n        FilteredSearchOrder::RetrievabilityDescending => {\n            tr.deck_config_sort_order_retrievability_descending()\n        }\n        FilteredSearchOrder::RelativeOverdueness => tr.decks_relative_overdueness(),\n    }\n    .into()\n}\n"
  },
  {
    "path": "rslib/src/decks/limits.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::iter::Peekable;\n\nuse anki_proto::decks::deck::normal::DayLimit;\nuse id_tree::InsertBehavior;\nuse id_tree::Node;\nuse id_tree::NodeId;\nuse id_tree::Tree;\n\nuse super::Deck;\nuse super::NormalDeck;\nuse crate::deckconfig::DeckConfig;\nuse crate::deckconfig::DeckConfigId;\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, Copy)]\npub(crate) enum LimitKind {\n    Review,\n    New,\n}\n\n/// The deck's review limit for today, or its regular one, if any is\n/// configured.\npub fn current_review_limit(deck: &NormalDeck, today: u32) -> Option<u32> {\n    review_limit_today(deck, today).or(deck.review_limit)\n}\n\n/// The deck's new limit for today, or its regular one, if any is\n/// configured.\npub fn current_new_limit(deck: &NormalDeck, today: u32) -> Option<u32> {\n    new_limit_today(deck, today).or(deck.new_limit)\n}\n\n/// The deck's review limit for today.\npub fn review_limit_today(deck: &NormalDeck, today: u32) -> Option<u32> {\n    deck.review_limit_today\n        .and_then(|day_limit| limit_if_today(day_limit, today))\n}\n\n/// The deck's new limit for today.\npub fn new_limit_today(deck: &NormalDeck, today: u32) -> Option<u32> {\n    deck.new_limit_today\n        .and_then(|day_limit| limit_if_today(day_limit, today))\n}\n\npub fn limit_if_today(limit: DayLimit, today: u32) -> Option<u32> {\n    (limit.today == today).then_some(limit.limit)\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub(crate) struct RemainingLimits {\n    pub(crate) review: u32,\n    pub(crate) new: u32,\n    pub(crate) cap_new_to_review: bool,\n}\n\nimpl RemainingLimits {\n    pub(crate) fn new(\n        deck: &Deck,\n        config: Option<&DeckConfig>,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n    ) -> Self {\n        if let Ok(normal) = deck.normal() {\n            if let Some(config) = config {\n                return Self::new_for_normal_deck(\n                    deck,\n                    today,\n                    new_cards_ignore_review_limit,\n                    normal,\n                    config,\n                );\n            }\n        }\n        Self::default()\n    }\n\n    fn new_for_normal_deck(\n        deck: &Deck,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n        normal: &NormalDeck,\n        config: &DeckConfig,\n    ) -> RemainingLimits {\n        Self::new_for_normal_deck_v3(deck, today, new_cards_ignore_review_limit, normal, config)\n    }\n\n    fn new_for_normal_deck_v3(\n        deck: &Deck,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n        normal: &NormalDeck,\n        config: &DeckConfig,\n    ) -> RemainingLimits {\n        let mut review_limit =\n            current_review_limit(normal, today).unwrap_or(config.inner.reviews_per_day) as i32;\n        let mut new_limit =\n            current_new_limit(normal, today).unwrap_or(config.inner.new_per_day) as i32;\n        let (new_today_count, review_today_count) = deck.new_rev_counts(today);\n\n        review_limit -= review_today_count;\n        new_limit -= new_today_count;\n        if !new_cards_ignore_review_limit {\n            review_limit -= new_today_count;\n            new_limit = new_limit.min(review_limit);\n        }\n\n        Self {\n            review: review_limit.max(0) as u32,\n            new: new_limit.max(0) as u32,\n            cap_new_to_review: !new_cards_ignore_review_limit,\n        }\n    }\n\n    pub(crate) fn get(&self, kind: LimitKind) -> u32 {\n        match kind {\n            LimitKind::Review => self.review,\n            LimitKind::New => self.new,\n        }\n    }\n\n    pub(crate) fn cap_to(&mut self, limits: RemainingLimits) {\n        self.review = self.review.min(limits.review);\n        self.new = self.new.min(limits.new);\n    }\n\n    /// True if some limit was decremented to 0.\n    fn decrement(&mut self, kind: LimitKind) -> DecrementResult {\n        let before = *self;\n        match kind {\n            LimitKind::Review => {\n                self.review = self.review.saturating_sub(1);\n                if self.cap_new_to_review {\n                    self.new = self.new.min(self.review);\n                }\n            }\n            LimitKind::New => self.new = self.new.saturating_sub(1),\n        }\n        DecrementResult::new(&before, self)\n    }\n}\n\nstruct DecrementResult {\n    count_reached_zero: bool,\n}\n\nimpl DecrementResult {\n    fn new(before: &RemainingLimits, after: &RemainingLimits) -> Self {\n        Self {\n            count_reached_zero: before.review > 0 && after.review == 0\n                || before.new > 0 && after.new == 0,\n        }\n    }\n}\n\nimpl Default for RemainingLimits {\n    fn default() -> Self {\n        RemainingLimits {\n            review: 9999,\n            new: 9999,\n            cap_new_to_review: false,\n        }\n    }\n}\n\npub(crate) fn remaining_limits_map<'a>(\n    decks: impl Iterator<Item = &'a Deck>,\n    config: &'a HashMap<DeckConfigId, DeckConfig>,\n    today: u32,\n    new_cards_ignore_review_limit: bool,\n) -> HashMap<DeckId, RemainingLimits> {\n    decks\n        .map(|deck| {\n            (\n                deck.id,\n                RemainingLimits::new(\n                    deck,\n                    deck.config_id().and_then(|id| config.get(&id)),\n                    today,\n                    new_cards_ignore_review_limit,\n                ),\n            )\n        })\n        .collect()\n}\n\n/// Wrapper of [RemainingLimits] with some additional meta data.\n#[derive(Debug, Clone, Copy)]\nstruct NodeLimits {\n    /// absolute level in the deck hierarchy\n    level: usize,\n    limits: RemainingLimits,\n}\n\nimpl NodeLimits {\n    fn new(\n        deck: &Deck,\n        config: &HashMap<DeckConfigId, DeckConfig>,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n    ) -> Self {\n        Self {\n            level: deck.name.components().count(),\n            limits: RemainingLimits::new(\n                deck,\n                deck.config_id().and_then(|id| config.get(&id)),\n                today,\n                new_cards_ignore_review_limit,\n            ),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct LimitTreeMap {\n    /// A tree representing the remaining limits of the active deck hierarchy.\n    //\n    // As long as we never (1) allow a tree without a root, (2) remove nodes,\n    // and (3) have more than 1 tree, it's safe to unwrap on Tree::get() and\n    // Tree::root_node_id(), even if we clone Nodes.\n    tree: Tree<NodeLimits>,\n    /// A map to access the tree node of a deck.\n    map: HashMap<DeckId, NodeId>,\n}\n\nimpl LimitTreeMap {\n    /// [Deck]s must be sorted by name.\n    pub(crate) fn build(\n        decks: &[Deck],\n        config: &HashMap<DeckConfigId, DeckConfig>,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n    ) -> Self {\n        let root_limits = NodeLimits::new(&decks[0], config, today, new_cards_ignore_review_limit);\n        let mut tree = Tree::new();\n        let root_id = tree\n            .insert(Node::new(root_limits), InsertBehavior::AsRoot)\n            .unwrap();\n\n        let mut map = HashMap::new();\n        map.insert(decks[0].id, root_id.clone());\n\n        let mut limits = Self { tree, map };\n        let mut remaining_decks = decks[1..].iter().peekable();\n        limits.add_child_nodes(\n            root_id,\n            &mut remaining_decks,\n            config,\n            today,\n            new_cards_ignore_review_limit,\n        );\n\n        limits\n    }\n\n    /// Recursively appends descendants to the provided parent [Node], and adds\n    /// them to the [HashMap].\n    /// Given [Deck]s are assumed to arrive in depth-first order.\n    /// The tree-from-deck-list logic is taken from\n    /// [crate::decks::tree::add_child_nodes].\n    fn add_child_nodes<'d>(\n        &mut self,\n        parent_node_id: NodeId,\n        remaining_decks: &mut Peekable<impl Iterator<Item = &'d Deck>>,\n        config: &HashMap<DeckConfigId, DeckConfig>,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n    ) {\n        let parent = *self.tree.get(&parent_node_id).unwrap().data();\n        while let Some(deck) = remaining_decks.peek() {\n            match deck.name.components().count() {\n                l if l <= parent.level => {\n                    // next item is at a higher level\n                    break;\n                }\n                l if l == parent.level + 1 => {\n                    // next item is an immediate descendent of parent\n                    self.insert_child_node(\n                        deck,\n                        parent_node_id.clone(),\n                        config,\n                        today,\n                        new_cards_ignore_review_limit,\n                    );\n                    remaining_decks.next();\n                }\n                _ => {\n                    // next item is at a lower level\n                    if let Some(last_child_node_id) = self\n                        .tree\n                        .get(&parent_node_id)\n                        .unwrap()\n                        .children()\n                        .last()\n                        .cloned()\n                    {\n                        self.add_child_nodes(\n                            last_child_node_id,\n                            remaining_decks,\n                            config,\n                            today,\n                            new_cards_ignore_review_limit,\n                        )\n                    } else {\n                        // immediate parent is missing, skip the deck until a DB check is run\n                        remaining_decks.next();\n                    }\n                }\n            }\n        }\n    }\n\n    fn insert_child_node(\n        &mut self,\n        child_deck: &Deck,\n        parent_node_id: NodeId,\n        config: &HashMap<DeckConfigId, DeckConfig>,\n        today: u32,\n        new_cards_ignore_review_limit: bool,\n    ) {\n        let mut child_limits =\n            NodeLimits::new(child_deck, config, today, new_cards_ignore_review_limit);\n        child_limits\n            .limits\n            .cap_to(self.get_node_limits(&parent_node_id));\n        let child_node_id = self\n            .tree\n            .insert(\n                Node::new(child_limits),\n                InsertBehavior::UnderNode(&parent_node_id),\n            )\n            .unwrap();\n        self.map.insert(child_deck.id, child_node_id);\n    }\n\n    fn get_node_id(&self, deck_id: DeckId) -> Result<&NodeId> {\n        self.map\n            .get(&deck_id)\n            .or_invalid(\"deck not found in limits map\")\n    }\n\n    fn get_node_limits(&self, node_id: &NodeId) -> RemainingLimits {\n        self.tree.get(node_id).unwrap().data().limits\n    }\n\n    fn get_deck_limits(&self, deck_id: DeckId) -> Result<RemainingLimits> {\n        self.get_node_id(deck_id)\n            .map(|node_id| self.get_node_limits(node_id))\n    }\n\n    fn get_root_limits(&self) -> RemainingLimits {\n        self.get_node_limits(self.tree.root_node_id().unwrap())\n    }\n\n    pub(crate) fn root_limit_reached(&self, kind: LimitKind) -> bool {\n        self.get_root_limits().get(kind) == 0\n    }\n\n    pub(crate) fn limit_reached(&self, deck_id: DeckId, kind: LimitKind) -> Result<bool> {\n        Ok(self.get_deck_limits(deck_id)?.get(kind) == 0)\n    }\n\n    pub(crate) fn decrement_deck_and_parent_limits(\n        &mut self,\n        deck_id: DeckId,\n        kind: LimitKind,\n    ) -> Result<()> {\n        let node_id = self.get_node_id(deck_id)?.clone();\n        self.decrement_node_and_parent_limits(&node_id, kind);\n        Ok(())\n    }\n\n    fn decrement_node_and_parent_limits(&mut self, node_id: &NodeId, kind: LimitKind) {\n        let node = self.tree.get_mut(node_id).unwrap();\n        let parent = node.parent().cloned();\n\n        let limits = &mut node.data_mut().limits;\n        if limits.decrement(kind).count_reached_zero {\n            let limits = *limits;\n            self.cap_node_and_descendants(node_id, limits);\n        };\n\n        if let Some(parent_id) = parent {\n            self.decrement_node_and_parent_limits(&parent_id, kind)\n        }\n    }\n\n    fn cap_node_and_descendants(&mut self, node_id: &NodeId, limits: RemainingLimits) {\n        let node = self.tree.get_mut(node_id).unwrap();\n        node.data_mut().limits.cap_to(limits);\n        for child_id in node.children().clone() {\n            self.cap_node_and_descendants(&child_id, limits);\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod addupdate;\nmod counts;\nmod current;\npub mod filtered;\npub(crate) mod limits;\nmod name;\nmod remove;\nmod reparent;\nmod schema11;\nmod service;\nmod stats;\npub mod tree;\npub(crate) mod undo;\n\nuse std::sync::Arc;\n\npub use anki_proto::decks::deck::filtered::search_term::Order as FilteredSearchOrder;\npub use anki_proto::decks::deck::filtered::SearchTerm as FilteredSearchTerm;\npub use anki_proto::decks::deck::kind_container::Kind as DeckKind;\npub use anki_proto::decks::deck::normal::DayLimit as NormalDeckDayLimit;\npub use anki_proto::decks::deck::Common as DeckCommon;\npub use anki_proto::decks::deck::Filtered as FilteredDeck;\npub use anki_proto::decks::deck::KindContainer as DeckKindContainer;\npub use anki_proto::decks::deck::Normal as NormalDeck;\npub use anki_proto::decks::Deck as DeckProto;\npub(crate) use counts::DueCounts;\npub(crate) use name::immediate_parent_name;\npub use name::NativeDeckName;\npub use schema11::DeckSchema11;\n\nuse crate::deckconfig::DeckConfig;\nuse crate::define_newtype;\nuse crate::error::FilteredDeckError;\nuse crate::markdown::render_markdown;\nuse crate::prelude::*;\nuse crate::text::sanitize_html_no_images;\n\ndefine_newtype!(DeckId, i64);\n\nimpl DeckId {\n    pub(crate) fn or(self, other: DeckId) -> Self {\n        if self.0 == 0 {\n            other\n        } else {\n            self\n        }\n    }\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub struct Deck {\n    pub id: DeckId,\n    pub name: NativeDeckName,\n    pub mtime_secs: TimestampSecs,\n    pub usn: Usn,\n    pub common: DeckCommon,\n    pub kind: DeckKind,\n}\n\nimpl Deck {\n    pub fn new_normal() -> Deck {\n        Deck {\n            id: DeckId(0),\n            name: NativeDeckName::from_native_str(\"\"),\n            mtime_secs: TimestampSecs(0),\n            usn: Usn(0),\n            common: DeckCommon {\n                study_collapsed: true,\n                browser_collapsed: true,\n                ..Default::default()\n            },\n            kind: DeckKind::Normal(NormalDeck {\n                config_id: 1,\n                // enable in the future\n                // markdown_description = true,\n                ..Default::default()\n            }),\n        }\n    }\n\n    /// Returns deck config ID if deck is a normal deck.\n    pub fn config_id(&self) -> Option<DeckConfigId> {\n        if let DeckKind::Normal(ref norm) = self.kind {\n            Some(DeckConfigId(norm.config_id))\n        } else {\n            None\n        }\n    }\n\n    /// Get the effective desired retention value for a deck.\n    /// Returns deck-specific desired retention if available, otherwise falls\n    /// back to config default.\n    pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 {\n        self.normal()\n            .ok()\n            .and_then(|d| d.desired_retention)\n            .unwrap_or(config.inner.desired_retention)\n    }\n\n    // used by tests at the moment\n\n    #[allow(dead_code)]\n    pub(crate) fn normal(&self) -> Result<&NormalDeck> {\n        match &self.kind {\n            DeckKind::Normal(normal) => Ok(normal),\n            _ => invalid_input!(\"deck not normal\"),\n        }\n    }\n\n    #[allow(dead_code)]\n    pub(crate) fn normal_mut(&mut self) -> Result<&mut NormalDeck> {\n        match &mut self.kind {\n            DeckKind::Normal(normal) => Ok(normal),\n            _ => invalid_input!(\"deck not normal\"),\n        }\n    }\n\n    pub(crate) fn filtered(&self) -> Result<&FilteredDeck> {\n        if let DeckKind::Filtered(filtered) = &self.kind {\n            Ok(filtered)\n        } else {\n            Err(FilteredDeckError::FilteredDeckRequired.into())\n        }\n    }\n\n    #[allow(dead_code)]\n    pub(crate) fn filtered_mut(&mut self) -> Result<&mut FilteredDeck> {\n        if let DeckKind::Filtered(filtered) = &mut self.kind {\n            Ok(filtered)\n        } else {\n            Err(FilteredDeckError::FilteredDeckRequired.into())\n        }\n    }\n\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.mtime_secs = TimestampSecs::now();\n        self.usn = usn;\n    }\n\n    pub fn rendered_description(&self) -> String {\n        if let DeckKind::Normal(normal) = &self.kind {\n            if normal.markdown_description {\n                let description = render_markdown(&normal.description);\n                // before allowing images, we'll need to handle relative image\n                // links on the various platforms\n                sanitize_html_no_images(&description)\n            } else {\n                String::new()\n            }\n        } else {\n            String::new()\n        }\n    }\n}\n\nimpl Collection {\n    pub fn get_or_create_normal_deck(&mut self, human_name: &str) -> Result<Deck> {\n        let name = NativeDeckName::from_human_name(human_name);\n        if let Some(did) = self.storage.get_deck_id(name.as_native_str())? {\n            self.storage.get_deck(did).map(|opt| opt.unwrap())\n        } else {\n            let mut deck = Deck::new_normal();\n            deck.name = name;\n            self.add_or_update_deck(&mut deck)?;\n            Ok(deck)\n        }\n    }\n}\n\nimpl Collection {\n    pub fn get_deck(&mut self, did: DeckId) -> Result<Option<Arc<Deck>>> {\n        if let Some(deck) = self.state.deck_cache.get(&did) {\n            return Ok(Some(deck.clone()));\n        }\n        if let Some(deck) = self.storage.get_deck(did)? {\n            let deck = Arc::new(deck);\n            self.state.deck_cache.insert(did, deck.clone());\n            Ok(Some(deck))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub(crate) fn default_deck_is_empty(&self) -> Result<bool> {\n        self.storage.deck_is_empty(DeckId(1))\n    }\n\n    /// Get a deck based on its human name. If you have a machine name,\n    /// use the method in storage instead.\n    pub fn get_deck_id(&self, human_name: &str) -> Result<Option<DeckId>> {\n        self.storage\n            .get_deck_id(NativeDeckName::from_human_name(human_name).as_native_str())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::prelude::*;\n    use crate::search::SortMode;\n\n    fn sorted_names(col: &Collection) -> Vec<String> {\n        col.storage\n            .get_all_deck_names()\n            .unwrap()\n            .into_iter()\n            .map(|d| d.1)\n            .collect()\n    }\n\n    #[test]\n    fn adding_updating() -> Result<()> {\n        let mut col = Collection::new();\n\n        let deck1 = col.get_or_create_normal_deck(\"foo\")?;\n        let deck2 = col.get_or_create_normal_deck(\"FOO\")?;\n        assert_eq!(deck1.id, deck2.id);\n        assert_eq!(sorted_names(&col), vec![\"Default\", \"foo\"]);\n\n        // missing parents should be automatically created, and case should match\n        // existing parents\n        let _deck3 = col.get_or_create_normal_deck(\"FOO::BAR::BAZ\")?;\n        assert_eq!(\n            sorted_names(&col),\n            vec![\"Default\", \"foo\", \"foo::BAR\", \"foo::BAR::BAZ\"]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn renaming() -> Result<()> {\n        let mut col = Collection::new();\n\n        let _ = col.get_or_create_normal_deck(\"foo::bar::baz\")?;\n        let mut top_deck = col.get_or_create_normal_deck(\"foo\")?;\n        top_deck.name = NativeDeckName::from_native_str(\"other\");\n        col.add_or_update_deck(&mut top_deck)?;\n        assert_eq!(\n            sorted_names(&col),\n            vec![\"Default\", \"other\", \"other::bar\", \"other::bar::baz\"]\n        );\n\n        // should do the right thing in the middle of the tree as well\n        let mut middle = col.get_or_create_normal_deck(\"other::bar\")?;\n        middle.name = NativeDeckName::from_native_str(\"quux\\x1ffoo\");\n        col.add_or_update_deck(&mut middle)?;\n        assert_eq!(\n            sorted_names(&col),\n            vec![\"Default\", \"other\", \"quux\", \"quux::foo\", \"quux::foo::baz\"]\n        );\n\n        // add another child\n        let _ = col.get_or_create_normal_deck(\"quux::foo::baz2\");\n\n        // quux::foo -> quux::foo::baz::four\n        // means quux::foo::baz2 should be quux::foo::baz::four::baz2\n        // and a new quux::foo should have been created\n        middle.name = NativeDeckName::from_native_str(\"quux\\x1ffoo\\x1fbaz\\x1ffour\");\n        col.add_or_update_deck(&mut middle)?;\n        assert_eq!(\n            sorted_names(&col),\n            vec![\n                \"Default\",\n                \"other\",\n                \"quux\",\n                \"quux::foo\",\n                \"quux::foo::baz\",\n                \"quux::foo::baz::four\",\n                \"quux::foo::baz::four::baz\",\n                \"quux::foo::baz::four::baz2\"\n            ]\n        );\n\n        // should handle name conflicts\n        middle.name = NativeDeckName::from_native_str(\"other\");\n        col.add_or_update_deck(&mut middle)?;\n        assert_eq!(middle.name.as_native_str(), \"other+\");\n\n        // public function takes human name\n        col.rename_deck(middle.id, \"one::two\")?;\n        assert_eq!(\n            sorted_names(&col),\n            vec![\n                \"Default\",\n                \"one\",\n                \"one::two\",\n                \"one::two::baz\",\n                \"one::two::baz2\",\n                \"other\",\n                \"quux\",\n                \"quux::foo\",\n                \"quux::foo::baz\",\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn default() -> Result<()> {\n        // deleting the default deck will remove cards, but bring the deck back\n        // as a top level deck\n        let mut col = Collection::new();\n\n        let mut default = col.get_or_create_normal_deck(\"default\")?;\n        default.name = NativeDeckName::from_native_str(\"one\\x1ftwo\");\n        col.add_or_update_deck(&mut default)?;\n\n        // create a non-default deck confusingly named \"default\"\n        let _fake_default = col.get_or_create_normal_deck(\"default\")?;\n\n        // add a card to the real default\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, default.id)?;\n        assert_ne!(col.search_cards(\"\", SortMode::NoOrder)?, vec![]);\n\n        // add a subdeck\n        let _ = col.get_or_create_normal_deck(\"one::two::three\")?;\n\n        // delete top level\n        let top = col.get_or_create_normal_deck(\"one\")?;\n        col.remove_decks_and_child_decks(&[top.id])?;\n\n        // should have come back as \"Default+\" due to conflict\n        assert_eq!(sorted_names(&col), vec![\"default\", \"Default+\"]);\n\n        // and the cards it contained should have been removed\n        assert_eq!(col.search_cards(\"\", SortMode::NoOrder)?, vec![]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/name.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::borrow::Cow;\n\nuse itertools::Itertools;\n\nuse crate::prelude::*;\nuse crate::text::normalize_to_nfc;\n\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub struct NativeDeckName(String);\n\nimpl NativeDeckName {\n    /// Create from a '::'-separated string\n    pub fn from_human_name(name: impl AsRef<str>) -> Self {\n        NativeDeckName(\n            name.as_ref()\n                .split(\"::\")\n                .map(normalized_deck_name_component)\n                .join(\"\\x1f\"),\n        )\n    }\n\n    /// Return a '::'-separated string.\n    pub fn human_name(&self) -> String {\n        self.0.replace('\\x1f', \"::\")\n    }\n\n    pub(crate) fn add_suffix(&mut self, suffix: &str) {\n        self.0 += suffix\n    }\n\n    /// Create from an '\\x1f'-separated string\n    pub(crate) fn from_native_str<N: Into<String>>(name: N) -> Self {\n        NativeDeckName(name.into())\n    }\n\n    /// Return a reference to the inner '\\x1f'-separated string.\n    pub(crate) fn as_native_str(&self) -> &str {\n        &self.0\n    }\n\n    pub(crate) fn components(&self) -> std::str::Split<'_, char> {\n        self.0.split('\\x1f')\n    }\n\n    /// Normalize the name's components if necessary. True if mutation took\n    /// place.\n    pub(crate) fn maybe_normalize(&mut self) -> bool {\n        let needs_normalization = self\n            .components()\n            .any(|comp| matches!(normalized_deck_name_component(comp), Cow::Owned(_)));\n        if needs_normalization {\n            self.0 = self\n                .components()\n                .map(normalized_deck_name_component)\n                .join(\"\\x1f\");\n        }\n        needs_normalization\n    }\n\n    /// Determine name to rename a deck to, when `self` is dropped on `target`.\n    /// `target` being unset represents a drop at the top or bottom of the deck\n    /// list. The returned name should be used to replace `self`.\n    pub(crate) fn reparented_name(&self, target: Option<&NativeDeckName>) -> Option<Self> {\n        let dragged_base = self.0.rsplit('\\x1f').next().unwrap();\n        let dragged_root = self.components().next().unwrap();\n        if let Some(target) = target {\n            let target_root = target.components().next().unwrap();\n            if target.0.starts_with(&self.0) && target_root == dragged_root {\n                // foo onto foo::bar, or foo onto itself -> no-op\n                None\n            } else {\n                // foo::bar onto baz -> baz::bar\n                Some(NativeDeckName(format!(\"{}\\x1f{}\", target.0, dragged_base)))\n            }\n        } else {\n            // foo::bar onto top level -> bar\n            Some(NativeDeckName(dragged_base.into()))\n        }\n    }\n\n    /// Replace the old parent's name with the new parent's name in self's name,\n    /// where the old parent's name is expected to be a prefix.\n    fn reparent(&mut self, old_parent: &NativeDeckName, new_parent: &NativeDeckName) {\n        self.0 = std::iter::once(new_parent.as_native_str())\n            .chain(self.components().skip(old_parent.components().count()))\n            .join(\"\\x1f\")\n    }\n}\n\nimpl std::fmt::Display for NativeDeckName {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.0.fmt(f)\n    }\n}\n\nimpl Deck {\n    pub fn human_name(&self) -> String {\n        self.name.human_name()\n    }\n}\n\nimpl Collection {\n    pub fn get_all_normal_deck_names(\n        &mut self,\n        skip_default: bool,\n    ) -> Result<Vec<(DeckId, String)>> {\n        Ok(self\n            .storage\n            .get_all_deck_names()?\n            .into_iter()\n            .filter(|node| {\n                if skip_default {\n                    node.0 != DeckId(1)\n                } else {\n                    true\n                }\n            })\n            .filter(|(id, _name)| match self.get_deck(*id) {\n                Ok(Some(deck)) => !deck.is_filtered(),\n                _ => true,\n            })\n            .collect())\n    }\n\n    pub fn rename_deck(&mut self, did: DeckId, new_human_name: &str) -> Result<OpOutput<()>> {\n        self.transact(Op::RenameDeck, |col| {\n            let existing_deck = col.storage.get_deck(did)?.or_not_found(did)?;\n            let mut deck = existing_deck.clone();\n            deck.name = NativeDeckName::from_human_name(new_human_name);\n            col.update_deck_inner(&mut deck, existing_deck, col.usn()?)\n        })\n    }\n\n    pub(super) fn rename_child_decks(\n        &mut self,\n        old: &Deck,\n        new_name: &NativeDeckName,\n        usn: Usn,\n    ) -> Result<()> {\n        let children = self.storage.child_decks(old)?;\n        for mut child in children {\n            let original = child.clone();\n            child.name.reparent(&old.name, new_name);\n            child.set_modified(usn);\n            self.update_single_deck_undoable(&mut child, original)?;\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn ensure_deck_name_unique(&self, deck: &mut Deck, usn: Usn) -> Result<()> {\n        loop {\n            match self.storage.get_deck_id(deck.name.as_native_str())? {\n                Some(did) if did == deck.id => break,\n                None => break,\n                _ => (),\n            }\n            deck.name.add_suffix(\"+\");\n            deck.set_modified(usn);\n        }\n\n        Ok(())\n    }\n\n    pub fn get_all_deck_names(&self, skip_default: bool) -> Result<Vec<(DeckId, String)>> {\n        if skip_default {\n            Ok(self\n                .storage\n                .get_all_deck_names()?\n                .into_iter()\n                .filter(|(id, _name)| id.0 != 1)\n                .collect())\n        } else {\n            self.storage.get_all_deck_names()\n        }\n    }\n\n    pub fn get_deck_and_child_names(&self, did: DeckId) -> Result<Vec<(DeckId, String)>> {\n        Ok(self\n            .storage\n            .deck_with_children(did)?\n            .iter()\n            .map(|deck| (deck.id, deck.name.human_name()))\n            .collect())\n    }\n}\n\nfn invalid_char_for_deck_component(c: char) -> bool {\n    c.is_ascii_control()\n}\n\nfn normalized_deck_name_component(comp: &str) -> Cow<'_, str> {\n    let mut out = normalize_to_nfc(comp);\n    if out.contains(invalid_char_for_deck_component) {\n        out = out.replace(invalid_char_for_deck_component, \"\").into();\n    }\n    let trimmed = out.trim_matches(|c: char| c.is_whitespace() || c == ':');\n    if trimmed.is_empty() {\n        \"blank\".to_string().into()\n    } else if trimmed.len() != out.len() {\n        trimmed.to_string().into()\n    } else {\n        out\n    }\n}\n\npub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> {\n    machine_name.rsplit_once('\\x1f').map(|t| t.0)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn parent() {\n        assert_eq!(immediate_parent_name(\"foo\"), None);\n        assert_eq!(immediate_parent_name(\"foo\\x1fbar\"), Some(\"foo\"));\n        assert_eq!(\n            immediate_parent_name(\"foo\\x1fbar\\x1fbaz\"),\n            Some(\"foo\\x1fbar\")\n        );\n    }\n\n    #[test]\n    fn from_human() {\n        fn native_name(name: &str) -> String {\n            NativeDeckName::from_human_name(name).0\n        }\n\n        assert_eq!(native_name(\"foo\"), \"foo\");\n        assert_eq!(native_name(\"foo::bar\"), \"foo\\x1fbar\");\n        assert_eq!(native_name(\"foo::::baz\"), \"foo\\x1fblank\\x1fbaz\");\n        // implicitly normalize\n        assert_eq!(native_name(\"fo\\x1fo::ba\\nr\"), \"foo\\x1fbar\");\n        assert_eq!(native_name(\"fo\\u{a}o\\x1fbar\"), \"foobar\");\n        assert_eq!(native_name(\"foo:::bar\"), \"foo\\x1fbar\");\n        assert_eq!(native_name(\"foo:::bar:baz: \"), \"foo\\x1fbar:baz\");\n    }\n\n    #[test]\n    fn normalize() {\n        fn normalize_res(name: &str) -> (bool, String) {\n            let mut name = NativeDeckName::from_native_str(name);\n            (name.maybe_normalize(), name.0)\n        }\n\n        assert_eq!(normalize_res(\"foo\\x1fbar\"), (false, \"foo\\x1fbar\".into()));\n        assert_eq!(\n            normalize_res(\"fo\\x1fo::ba\\nr\"),\n            (true, \"fo\\x1fo::bar\".into())\n        );\n        assert_eq!(normalize_res(\"fo\\u{a}obar\"), (true, \"foobar\".into()));\n    }\n\n    #[test]\n    fn drag_drop() {\n        // use custom separator to make the tests easier to read\n        fn n(s: &str) -> NativeDeckName {\n            NativeDeckName(s.replace(':', \"\\x1f\"))\n        }\n\n        #[allow(clippy::unnecessary_wraps)]\n        fn n_opt(s: &str) -> Option<NativeDeckName> {\n            Some(n(s))\n        }\n\n        fn reparented_name(drag: &str, drop: Option<&str>) -> Option<NativeDeckName> {\n            n(drag).reparented_name(drop.map(n).as_ref())\n        }\n\n        assert_eq!(reparented_name(\"drag\", Some(\"drop\")), n_opt(\"drop:drag\"));\n        assert_eq!(reparented_name(\"drag\", None), n_opt(\"drag\"));\n        assert_eq!(reparented_name(\"drag:child\", None), n_opt(\"child\"));\n        assert_eq!(\n            reparented_name(\"drag:child\", Some(\"drop:deck\")),\n            n_opt(\"drop:deck:child\")\n        );\n        assert_eq!(\n            reparented_name(\"drag:child\", Some(\"drag\")),\n            n_opt(\"drag:child\")\n        );\n        assert_eq!(\n            reparented_name(\"drag:child:grandchild\", Some(\"drag\")),\n            n_opt(\"drag:grandchild\")\n        );\n        // drops to child not supported\n        assert_eq!(reparented_name(\"drag\", Some(\"drag:child:grandchild\")), None);\n        // name doesn't change when deck dropped on itself\n        assert_eq!(reparented_name(\"foo:bar\", Some(\"foo:bar\")), None);\n        // names that are prefixes of the target are handled correctly\n        assert_eq!(reparented_name(\"a\", Some(\"ab\")), n_opt(\"ab:a\"));\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/remove.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckId]) -> Result<OpOutput<usize>> {\n        self.transact(Op::RemoveDeck, |col| {\n            let mut card_count = 0;\n            let usn = col.usn()?;\n            for did in dids {\n                if let Some(deck) = col.storage.get_deck(*did)? {\n                    let child_decks = col.storage.child_decks(&deck)?;\n\n                    // top level\n                    card_count += col.remove_single_deck(&deck, usn)?;\n\n                    // remove children\n                    for deck in child_decks {\n                        card_count += col.remove_single_deck(&deck, usn)?;\n                    }\n                }\n            }\n            Ok(card_count)\n        })\n    }\n\n    pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {\n        let card_count = match deck.kind {\n            DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?,\n            DeckKind::Filtered(_) => {\n                self.return_all_cards_in_filtered_deck(deck)?;\n                0\n            }\n        };\n        self.clear_aux_config_for_deck(deck.id)?;\n        if deck.id.0 == 1 {\n            // if the default deck is included, just ensure it's reset to the default\n            // name, as we've already removed its cards\n            let mut modified_default = deck.clone();\n            modified_default.name =\n                NativeDeckName::from_native_str(self.tr.deck_config_default_name());\n            self.prepare_deck_for_update(&mut modified_default, usn)?;\n            modified_default.set_modified(usn);\n            self.update_single_deck_undoable(&mut modified_default, deck.clone())?;\n        } else {\n            self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?;\n        }\n        Ok(card_count)\n    }\n}\n\nimpl Collection {\n    fn delete_all_cards_in_normal_deck(&mut self, did: DeckId) -> Result<usize> {\n        let cids = self.storage.all_cards_in_single_deck(did)?;\n        self.remove_cards_and_orphaned_notes(&cids)?;\n        Ok(cids.len())\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/reparent.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse crate::error::FilteredDeckError;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn reparent_decks(\n        &mut self,\n        deck_ids: &[DeckId],\n        new_parent: Option<DeckId>,\n    ) -> Result<OpOutput<usize>> {\n        self.transact(Op::ReparentDeck, |col| {\n            col.reparent_decks_inner(deck_ids, new_parent)\n        })\n    }\n\n    pub fn reparent_decks_inner(\n        &mut self,\n        deck_ids: &[DeckId],\n        new_parent: Option<DeckId>,\n    ) -> Result<usize> {\n        let usn = self.usn()?;\n        let target_deck;\n        let mut target_name = None;\n        if let Some(target) = new_parent {\n            if let Some(target) = self.storage.get_deck(target)? {\n                if target.is_filtered() {\n                    return Err(FilteredDeckError::MustBeLeafNode.into());\n                }\n                target_deck = target;\n                target_name = Some(&target_deck.name);\n            }\n        }\n\n        let mut count = 0;\n        for deck in deck_ids {\n            if let Some(mut deck) = self.storage.get_deck(*deck)? {\n                if let Some(new_name) = deck.name.reparented_name(target_name) {\n                    if new_name == deck.name {\n                        continue;\n                    }\n                    count += 1;\n                    let orig = deck.clone();\n\n                    // this is basically update_deck_inner(), except:\n                    // - we skip the normalization in prepare_for_update()\n                    // - we skip the match_or_create_parents() step\n                    // - we skip the final create_missing_parents(), as we don't allow parent->child\n                    //   renames\n\n                    deck.set_modified(usn);\n                    deck.name = new_name;\n                    self.ensure_deck_name_unique(&mut deck, usn)?;\n                    self.rename_child_decks(&orig, &deck.name, usn)?;\n                    self.update_single_deck_undoable(&mut deck, orig)?;\n                }\n            }\n        }\n\n        Ok(count)\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/schema11.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse anki_proto::decks::deck::normal::DayLimit;\nuse phf::phf_set;\nuse phf::Set;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_json::Value;\nuse serde_tuple::Serialize_tuple;\n\nuse super::DeckCommon;\nuse super::FilteredDeck;\nuse super::FilteredSearchTerm;\nuse super::NormalDeck;\nuse crate::notetype::schema11::parse_other_fields;\nuse crate::prelude::*;\nuse crate::serde::default_on_invalid;\nuse crate::serde::deserialize_bool_from_anything;\nuse crate::serde::deserialize_number_from_string;\n\n#[derive(Serialize, PartialEq, Debug, Clone)]\n#[serde(untagged)]\npub enum DeckSchema11 {\n    Normal(NormalDeckSchema11),\n    Filtered(FilteredDeckSchema11),\n}\n\n// serde doesn't support integer/bool enum tags, so we manually pick the correct\n// variant\nmod dynfix {\n    use serde::de;\n    use serde::de::Deserialize;\n    use serde::de::Deserializer;\n    use serde_json::Map;\n    use serde_json::Value;\n\n    use super::DeckSchema11;\n    use super::FilteredDeckSchema11;\n    use super::NormalDeckSchema11;\n\n    impl<'de> Deserialize<'de> for DeckSchema11 {\n        fn deserialize<D>(deserializer: D) -> Result<DeckSchema11, D::Error>\n        where\n            D: Deserializer<'de>,\n        {\n            let mut map = Map::deserialize(deserializer)?;\n\n            let (is_dyn, needs_fix) = map\n                .get(\"dyn\")\n                .ok_or_else(|| de::Error::missing_field(\"dyn\"))\n                .and_then(|v| {\n                    Ok(match v {\n                        Value::Bool(b) => (*b, true),\n                        Value::Number(n) => (n.as_i64().unwrap_or(0) == 1, false),\n                        _ => {\n                            // invalid type\n                            return Err(de::Error::custom(\"dyn was wrong type\"));\n                        }\n                    })\n                })?;\n\n            if needs_fix {\n                map.insert(\"dyn\".into(), Value::Number(u8::from(is_dyn).into()));\n            }\n\n            // remove an obsolete key\n            map.remove(\"return\");\n\n            let rest = Value::Object(map);\n            if is_dyn {\n                FilteredDeckSchema11::deserialize(rest)\n                    .map(DeckSchema11::Filtered)\n                    .map_err(de::Error::custom)\n            } else {\n                NormalDeckSchema11::deserialize(rest)\n                    .map(DeckSchema11::Normal)\n                    .map_err(de::Error::custom)\n            }\n        }\n    }\n}\n\nfn is_false(b: &bool) -> bool {\n    !b\n}\n\n#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]\npub struct DeckCommonSchema11 {\n    #[serde(deserialize_with = \"deserialize_number_from_string\")]\n    pub(crate) id: DeckId,\n    #[serde(\n        rename = \"mod\",\n        deserialize_with = \"deserialize_number_from_string\",\n        default\n    )]\n    pub(crate) mtime: TimestampSecs,\n    pub(crate) name: String,\n    pub(crate) usn: Usn,\n    #[serde(flatten)]\n    pub(crate) today: DeckTodaySchema11,\n    #[serde(rename = \"collapsed\")]\n    study_collapsed: bool,\n    #[serde(default, rename = \"browserCollapsed\")]\n    browser_collapsed: bool,\n    #[serde(default)]\n    desc: String,\n    #[serde(default, rename = \"md\", skip_serializing_if = \"is_false\")]\n    markdown_description: bool,\n    #[serde(rename = \"dyn\")]\n    dynamic: u8,\n    #[serde(flatten)]\n    other: HashMap<String, Value>,\n}\n\n#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct NormalDeckSchema11 {\n    #[serde(flatten)]\n    pub(crate) common: DeckCommonSchema11,\n\n    #[serde(deserialize_with = \"deserialize_number_from_string\")]\n    pub(crate) conf: i64,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    extend_new: i32,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    extend_rev: i32,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    review_limit: Option<u32>,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    new_limit: Option<u32>,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    review_limit_today: Option<DayLimit>,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    new_limit_today: Option<DayLimit>,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    desired_retention: Option<u32>,\n}\n\n#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct FilteredDeckSchema11 {\n    #[serde(flatten)]\n    common: DeckCommonSchema11,\n\n    #[serde(deserialize_with = \"deserialize_bool_from_anything\")]\n    resched: bool,\n    terms: Vec<FilteredSearchTermSchema11>,\n\n    // unused, but older clients require its existence\n    #[serde(default)]\n    separate: bool,\n\n    // old scheduler\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    delays: Option<Vec<f32>>,\n    // old scheduler\n    #[serde(default)]\n    preview_delay: u32,\n\n    // new scheduler\n    #[serde(default)]\n    preview_again_secs: u32,\n    #[serde(default)]\n    preview_hard_secs: u32,\n    #[serde(default)]\n    preview_good_secs: u32,\n}\n#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default, Clone)]\npub struct DeckTodaySchema11 {\n    #[serde(rename = \"lrnToday\")]\n    pub(crate) lrn: TodayAmountSchema11,\n    #[serde(rename = \"revToday\")]\n    pub(crate) rev: TodayAmountSchema11,\n    #[serde(rename = \"newToday\")]\n    pub(crate) new: TodayAmountSchema11,\n    #[serde(rename = \"timeToday\")]\n    pub(crate) time: TodayAmountSchema11,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq, Default, Clone)]\n#[serde(from = \"Vec<Value>\")]\npub struct TodayAmountSchema11 {\n    day: i32,\n    amount: i32,\n}\n\nimpl From<Vec<Value>> for TodayAmountSchema11 {\n    fn from(mut v: Vec<Value>) -> Self {\n        let amt = v.pop().and_then(|v| v.as_i64()).unwrap_or(0);\n        let day = v.pop().and_then(|v| v.as_i64()).unwrap_or(0);\n        TodayAmountSchema11 {\n            amount: amt as i32,\n            day: day as i32,\n        }\n    }\n}\n#[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq, Clone)]\npub struct FilteredSearchTermSchema11 {\n    search: String,\n    #[serde(deserialize_with = \"deserialize_number_from_string\")]\n    limit: i32,\n    order: i32,\n}\n\nimpl DeckSchema11 {\n    pub fn common(&self) -> &DeckCommonSchema11 {\n        match self {\n            DeckSchema11::Normal(d) => &d.common,\n            DeckSchema11::Filtered(d) => &d.common,\n        }\n    }\n\n    pub fn id(&self) -> DeckId {\n        self.common().id\n    }\n\n    pub fn name(&self) -> &str {\n        &self.common().name\n    }\n}\n\nimpl Default for DeckSchema11 {\n    fn default() -> Self {\n        DeckSchema11::Normal(NormalDeckSchema11::default())\n    }\n}\n\nimpl Default for NormalDeckSchema11 {\n    fn default() -> Self {\n        NormalDeckSchema11 {\n            common: DeckCommonSchema11 {\n                id: DeckId(0),\n                mtime: TimestampSecs(0),\n                name: \"\".to_string(),\n                usn: Usn(0),\n                study_collapsed: false,\n                browser_collapsed: false,\n                desc: \"\".to_string(),\n                today: Default::default(),\n                other: Default::default(),\n                dynamic: 0,\n                markdown_description: false,\n            },\n            conf: 1,\n            extend_new: 0,\n            extend_rev: 0,\n            review_limit: None,\n            new_limit: None,\n            review_limit_today: None,\n            new_limit_today: None,\n            desired_retention: None,\n        }\n    }\n}\n\n// schema 11 -> latest\n\nimpl From<DeckSchema11> for Deck {\n    fn from(deck: DeckSchema11) -> Self {\n        match deck {\n            DeckSchema11::Normal(d) => Deck {\n                id: d.common.id,\n                name: NativeDeckName::from_human_name(&d.common.name),\n                mtime_secs: d.common.mtime,\n                usn: d.common.usn,\n                common: (&d.common).into(),\n                kind: DeckKind::Normal(d.into()),\n            },\n            DeckSchema11::Filtered(d) => Deck {\n                id: d.common.id,\n                name: NativeDeckName::from_human_name(&d.common.name),\n                mtime_secs: d.common.mtime,\n                usn: d.common.usn,\n                common: (&d.common).into(),\n                kind: DeckKind::Filtered(d.into()),\n            },\n        }\n    }\n}\n\nimpl From<&DeckCommonSchema11> for DeckCommon {\n    fn from(common: &DeckCommonSchema11) -> Self {\n        let other = if common.other.is_empty() {\n            vec![]\n        } else {\n            serde_json::to_vec(&common.other).unwrap_or_default()\n        };\n        // since we're combining the day values into a single value,\n        // any items from an earlier day need to be reset\n        let mut today = common.today.clone();\n        // study will always update 'time', but custom study may only update\n        // 'rev' or 'new'\n        let max_day = today.time.day.max(today.new.day).max(today.rev.day);\n        if today.lrn.day != max_day {\n            today.lrn.amount = 0;\n        }\n        if today.rev.day != max_day {\n            today.rev.amount = 0;\n        }\n        if today.new.day != max_day {\n            today.new.amount = 0;\n        }\n        DeckCommon {\n            study_collapsed: common.study_collapsed,\n            browser_collapsed: common.browser_collapsed,\n            last_day_studied: max_day as u32,\n            new_studied: today.new.amount,\n            review_studied: today.rev.amount,\n            learning_studied: today.lrn.amount,\n            milliseconds_studied: common.today.time.amount,\n            other,\n        }\n    }\n}\n\nimpl From<NormalDeckSchema11> for NormalDeck {\n    fn from(deck: NormalDeckSchema11) -> Self {\n        NormalDeck {\n            config_id: deck.conf,\n            extend_new: deck.extend_new.max(0) as u32,\n            extend_review: deck.extend_rev.max(0) as u32,\n            markdown_description: deck.common.markdown_description,\n            description: deck.common.desc,\n            review_limit: deck.review_limit,\n            new_limit: deck.new_limit,\n            review_limit_today: deck.review_limit_today,\n            new_limit_today: deck.new_limit_today,\n            desired_retention: deck.desired_retention.map(|v| v as f32 / 100.0),\n        }\n    }\n}\n\nimpl From<FilteredDeckSchema11> for FilteredDeck {\n    fn from(deck: FilteredDeckSchema11) -> Self {\n        FilteredDeck {\n            reschedule: deck.resched,\n            search_terms: deck.terms.into_iter().map(Into::into).collect(),\n            delays: deck.delays.unwrap_or_default(),\n            preview_delay: deck.preview_delay,\n            preview_again_secs: deck.preview_again_secs,\n            preview_hard_secs: deck.preview_hard_secs,\n            preview_good_secs: deck.preview_good_secs,\n        }\n    }\n}\n\nimpl From<FilteredSearchTermSchema11> for FilteredSearchTerm {\n    fn from(term: FilteredSearchTermSchema11) -> Self {\n        FilteredSearchTerm {\n            search: term.search,\n            limit: term.limit.max(0) as u32,\n            order: term.order,\n        }\n    }\n}\n\n// latest -> schema 11\n\nimpl From<Deck> for DeckSchema11 {\n    fn from(deck: Deck) -> Self {\n        match deck.kind {\n            DeckKind::Normal(ref norm) => DeckSchema11::Normal(NormalDeckSchema11 {\n                conf: norm.config_id,\n                extend_new: norm.extend_new as i32,\n                extend_rev: norm.extend_review as i32,\n                review_limit: norm.review_limit,\n                new_limit: norm.new_limit,\n                review_limit_today: norm.review_limit_today,\n                new_limit_today: norm.new_limit_today,\n                desired_retention: norm.desired_retention.map(|v| (v * 100.0) as u32),\n                common: deck.into(),\n            }),\n            DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 {\n                resched: filt.reschedule,\n                terms: filt.search_terms.iter().map(|v| v.clone().into()).collect(),\n                separate: true,\n                delays: if filt.delays.is_empty() {\n                    None\n                } else {\n                    Some(filt.delays.clone())\n                },\n                preview_delay: filt.preview_delay,\n                preview_again_secs: filt.preview_again_secs,\n                preview_hard_secs: filt.preview_hard_secs,\n                preview_good_secs: filt.preview_good_secs,\n                common: deck.into(),\n            }),\n        }\n    }\n}\n\nimpl From<Deck> for DeckCommonSchema11 {\n    fn from(deck: Deck) -> Self {\n        DeckCommonSchema11 {\n            id: deck.id,\n            mtime: deck.mtime_secs,\n            name: deck.human_name(),\n            usn: deck.usn,\n            today: (&deck).into(),\n            study_collapsed: deck.common.study_collapsed,\n            browser_collapsed: deck.common.browser_collapsed,\n            dynamic: matches!(deck.kind, DeckKind::Filtered(_)).into(),\n            markdown_description: match &deck.kind {\n                DeckKind::Normal(n) => n.markdown_description,\n                DeckKind::Filtered(_) => false,\n            },\n            desc: match deck.kind {\n                DeckKind::Normal(n) => n.description,\n                DeckKind::Filtered(_) => String::new(),\n            },\n            other: parse_other_fields(&deck.common.other, &RESERVED_DECK_KEYS),\n        }\n    }\n}\n\nstatic RESERVED_DECK_KEYS: Set<&'static str> = phf_set! {\n    \"usn\",\n    \"revToday\",\n    \"newLimit\",\n    \"dyn\",\n    \"reviewLimit\",\n    \"newToday\",\n    \"timeToday\",\n    \"reviewLimitToday\",\n    \"extendNew\",\n    \"mod\",\n    \"newLimitToday\",\n    \"desc\",\n    \"name\",\n    \"lrnToday\",\n    \"conf\",\n    \"browserCollapsed\",\n    \"extendRev\",\n    \"id\",\n    \"collapsed\",\n    \"desiredRetention\",\n};\n\nimpl From<&Deck> for DeckTodaySchema11 {\n    fn from(deck: &Deck) -> Self {\n        let day = deck.common.last_day_studied as i32;\n        let c = &deck.common;\n        DeckTodaySchema11 {\n            lrn: TodayAmountSchema11 {\n                day,\n                amount: c.learning_studied,\n            },\n            rev: TodayAmountSchema11 {\n                day,\n                amount: c.review_studied,\n            },\n            new: TodayAmountSchema11 {\n                day,\n                amount: c.new_studied,\n            },\n            time: TodayAmountSchema11 {\n                day,\n                amount: c.milliseconds_studied,\n            },\n        }\n    }\n}\n\nimpl From<FilteredSearchTerm> for FilteredSearchTermSchema11 {\n    fn from(term: FilteredSearchTerm) -> Self {\n        FilteredSearchTermSchema11 {\n            search: term.search,\n            limit: term.limit as i32,\n            order: term.order,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use itertools::Itertools;\n\n    use super::*;\n\n    #[test]\n    fn all_reserved_fields_are_removed() -> Result<()> {\n        let key_source = DeckSchema11::default();\n        let mut deck = Deck::new_normal();\n        deck.common.other = serde_json::to_vec(&key_source)?;\n        let DeckSchema11::Normal(s11) = DeckSchema11::from(deck) else {\n            panic!()\n        };\n\n        let empty: &[&String] = &[];\n        assert_eq!(&s11.common.other.keys().collect_vec(), empty);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::decks::deck::kind_container::Kind as DeckKind;\nuse anki_proto::generic;\n\nuse crate::collection::Collection;\nuse crate::decks::filtered::search_order_labels;\nuse crate::decks::Deck;\nuse crate::decks::DeckId;\nuse crate::decks::DeckSchema11;\nuse crate::decks::NativeDeckName;\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::OrInvalid;\nuse crate::error::OrNotFound;\nuse crate::prelude::TimestampSecs;\nuse crate::prelude::Usn;\nuse crate::scheduler::filtered::FilteredDeckForUpdate;\n\nimpl crate::services::DecksService for Collection {\n    fn new_deck(&mut self) -> error::Result<anki_proto::decks::Deck> {\n        Ok(Deck::new_normal().into())\n    }\n\n    fn add_deck(\n        &mut self,\n        deck: anki_proto::decks::Deck,\n    ) -> error::Result<anki_proto::collection::OpChangesWithId> {\n        let mut deck: Deck = deck.try_into()?;\n        Ok(self.add_deck(&mut deck)?.map(|_| deck.id.0).into())\n    }\n\n    fn add_deck_legacy(\n        &mut self,\n        input: generic::Json,\n    ) -> error::Result<anki_proto::collection::OpChangesWithId> {\n        let schema11: DeckSchema11 = serde_json::from_slice(&input.json)?;\n        let mut deck: Deck = schema11.into();\n\n        let output = self.add_deck(&mut deck)?;\n        Ok(output.map(|_| deck.id.0).into())\n    }\n\n    fn add_or_update_deck_legacy(\n        &mut self,\n        input: anki_proto::decks::AddOrUpdateDeckLegacyRequest,\n    ) -> error::Result<anki_proto::decks::DeckId> {\n        let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?;\n        let mut deck: Deck = schema11.into();\n        if input.preserve_usn_and_mtime {\n            self.transact_no_undo(|col| {\n                let usn = col.usn()?;\n                col.add_or_update_single_deck_with_existing_id(&mut deck, usn)\n            })?;\n        } else {\n            self.add_or_update_deck(&mut deck)?;\n        }\n        Ok(anki_proto::decks::DeckId { did: deck.id.0 })\n    }\n\n    fn deck_tree(\n        &mut self,\n        input: anki_proto::decks::DeckTreeRequest,\n    ) -> error::Result<anki_proto::decks::DeckTreeNode> {\n        let now = if input.now == 0 {\n            None\n        } else {\n            Some(TimestampSecs(input.now))\n        };\n        self.deck_tree(now)\n    }\n\n    fn deck_tree_legacy(&mut self) -> error::Result<generic::Json> {\n        let tree = self.legacy_deck_tree()?;\n        serde_json::to_vec(&tree)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_all_decks_legacy(&mut self) -> error::Result<generic::Json> {\n        let decks = self.storage.get_all_decks_as_schema11()?;\n        serde_json::to_vec(&decks)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_deck_id_by_name(\n        &mut self,\n        input: generic::String,\n    ) -> error::Result<anki_proto::decks::DeckId> {\n        self.get_deck_id(&input.val).and_then(|d| {\n            d.or_not_found(input.val)\n                .map(|d| anki_proto::decks::DeckId { did: d.0 })\n        })\n    }\n\n    fn get_deck(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> error::Result<anki_proto::decks::Deck> {\n        let did = input.into();\n        Ok(self.storage.get_deck(did)?.or_not_found(did)?.into())\n    }\n\n    fn update_deck(\n        &mut self,\n        input: anki_proto::decks::Deck,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let mut deck = Deck::try_from(input)?;\n        self.update_deck(&mut deck).map(Into::into)\n    }\n\n    fn update_deck_legacy(\n        &mut self,\n        input: generic::Json,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let deck: DeckSchema11 = serde_json::from_slice(&input.json)?;\n        let mut deck = deck.into();\n        self.update_deck(&mut deck).map(Into::into)\n    }\n\n    fn get_deck_legacy(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> error::Result<generic::Json> {\n        let did = input.into();\n\n        let deck: DeckSchema11 = self.storage.get_deck(did)?.or_not_found(did)?.into();\n        serde_json::to_vec(&deck)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_deck_names(\n        &mut self,\n        input: anki_proto::decks::GetDeckNamesRequest,\n    ) -> error::Result<anki_proto::decks::DeckNames> {\n        let skip_default = input.skip_empty_default && self.default_deck_is_empty()?;\n        let names = if input.include_filtered {\n            self.get_all_deck_names(skip_default)?\n        } else {\n            self.get_all_normal_deck_names(skip_default)?\n        };\n        Ok(deck_names_to_proto(names))\n    }\n\n    fn get_deck_and_child_names(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> error::Result<anki_proto::decks::DeckNames> {\n        Collection::get_deck_and_child_names(self, input.did.into()).map(deck_names_to_proto)\n    }\n\n    fn new_deck_legacy(&mut self, input: generic::Bool) -> error::Result<generic::Json> {\n        let deck = if input.val {\n            Deck::new_filtered()\n        } else {\n            Deck::new_normal()\n        };\n        let schema11: DeckSchema11 = deck.into();\n        serde_json::to_vec(&schema11)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn remove_decks(\n        &mut self,\n        input: anki_proto::decks::DeckIds,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.remove_decks_and_child_decks(&input.dids.into_iter().map(DeckId).collect::<Vec<_>>())\n            .map(Into::into)\n    }\n\n    fn reparent_decks(\n        &mut self,\n        input: anki_proto::decks::ReparentDecksRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        let deck_ids: Vec<_> = input.deck_ids.into_iter().map(Into::into).collect();\n        let new_parent = if input.new_parent == 0 {\n            None\n        } else {\n            Some(input.new_parent.into())\n        };\n        self.reparent_decks(&deck_ids, new_parent).map(Into::into)\n    }\n\n    fn rename_deck(\n        &mut self,\n        input: anki_proto::decks::RenameDeckRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.rename_deck(input.deck_id.into(), &input.new_name)\n            .map(Into::into)\n    }\n\n    fn get_or_create_filtered_deck(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> error::Result<anki_proto::decks::FilteredDeckForUpdate> {\n        self.get_or_create_filtered_deck(input.into())\n            .map(Into::into)\n    }\n\n    fn add_or_update_filtered_deck(\n        &mut self,\n        input: anki_proto::decks::FilteredDeckForUpdate,\n    ) -> error::Result<anki_proto::collection::OpChangesWithId> {\n        self.add_or_update_filtered_deck(input.into())\n            .map(|out| out.map(i64::from))\n            .map(Into::into)\n    }\n\n    fn filtered_deck_order_labels(&mut self) -> error::Result<generic::StringList> {\n        Ok(search_order_labels(&self.tr).into())\n    }\n\n    fn set_deck_collapsed(\n        &mut self,\n        input: anki_proto::decks::SetDeckCollapsedRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.set_deck_collapsed(input.deck_id.into(), input.collapsed, input.scope())\n            .map(Into::into)\n    }\n\n    fn set_current_deck(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.set_current_deck(input.did.into()).map(Into::into)\n    }\n\n    fn get_current_deck(&mut self) -> error::Result<anki_proto::decks::Deck> {\n        self.get_current_deck().map(|deck| (*deck).clone().into())\n    }\n}\n\nimpl From<anki_proto::decks::DeckId> for DeckId {\n    fn from(did: anki_proto::decks::DeckId) -> Self {\n        DeckId(did.did)\n    }\n}\n\nimpl From<DeckId> for anki_proto::decks::DeckId {\n    fn from(did: DeckId) -> Self {\n        anki_proto::decks::DeckId { did: did.0 }\n    }\n}\n\nimpl From<FilteredDeckForUpdate> for anki_proto::decks::FilteredDeckForUpdate {\n    fn from(deck: FilteredDeckForUpdate) -> Self {\n        anki_proto::decks::FilteredDeckForUpdate {\n            id: deck.id.into(),\n            name: deck.human_name,\n            config: Some(deck.config),\n            allow_empty: deck.allow_empty,\n        }\n    }\n}\n\nimpl From<anki_proto::decks::FilteredDeckForUpdate> for FilteredDeckForUpdate {\n    fn from(deck: anki_proto::decks::FilteredDeckForUpdate) -> Self {\n        FilteredDeckForUpdate {\n            id: deck.id.into(),\n            human_name: deck.name,\n            config: deck.config.unwrap_or_default(),\n            allow_empty: deck.allow_empty,\n        }\n    }\n}\n\nimpl From<Deck> for anki_proto::decks::Deck {\n    fn from(d: Deck) -> Self {\n        anki_proto::decks::Deck {\n            id: d.id.0,\n            name: d.name.human_name(),\n            mtime_secs: d.mtime_secs.0,\n            usn: d.usn.0,\n            common: Some(d.common),\n            kind: Some(kind_from_inline(d.kind)),\n        }\n    }\n}\n\nimpl TryFrom<anki_proto::decks::Deck> for Deck {\n    type Error = AnkiError;\n\n    fn try_from(d: anki_proto::decks::Deck) -> error::Result<Self, Self::Error> {\n        Ok(Deck {\n            id: DeckId(d.id),\n            name: NativeDeckName::from_human_name(&d.name),\n            mtime_secs: TimestampSecs(d.mtime_secs),\n            usn: Usn(d.usn),\n            common: d.common.unwrap_or_default(),\n            kind: kind_to_inline(d.kind.or_invalid(\"missing kind\")?),\n        })\n    }\n}\n\nfn kind_to_inline(kind: anki_proto::decks::deck::Kind) -> DeckKind {\n    match kind {\n        anki_proto::decks::deck::Kind::Normal(normal) => DeckKind::Normal(normal),\n        anki_proto::decks::deck::Kind::Filtered(filtered) => DeckKind::Filtered(filtered),\n    }\n}\n\nfn kind_from_inline(k: DeckKind) -> anki_proto::decks::deck::Kind {\n    match k {\n        DeckKind::Normal(n) => anki_proto::decks::deck::Kind::Normal(n),\n        DeckKind::Filtered(f) => anki_proto::decks::deck::Kind::Filtered(f),\n    }\n}\n\nfn deck_name_to_proto((id, name): (DeckId, String)) -> anki_proto::decks::DeckNameId {\n    anki_proto::decks::DeckNameId { id: id.0, name }\n}\n\nfn deck_names_to_proto(names: Vec<(DeckId, String)>) -> anki_proto::decks::DeckNames {\n    anki_proto::decks::DeckNames {\n        entries: names.into_iter().map(deck_name_to_proto).collect(),\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/stats.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse super::DeckCommon;\nuse crate::prelude::*;\n\nimpl Deck {\n    pub(super) fn reset_stats_if_day_changed(&mut self, today: u32) {\n        let c = &mut self.common;\n        if c.last_day_studied != today {\n            c.new_studied = 0;\n            c.learning_studied = 0;\n            c.review_studied = 0;\n            c.milliseconds_studied = 0;\n            c.last_day_studied = today;\n        }\n    }\n}\n\nimpl Collection {\n    /// Apply input delta to deck, and its parents.\n    /// Caller should ensure transaction.\n    pub(crate) fn update_deck_stats(\n        &mut self,\n        today: u32,\n        usn: Usn,\n        input: anki_proto::scheduler::UpdateStatsRequest,\n    ) -> Result<()> {\n        let did = input.deck_id.into();\n        let mutator = |c: &mut DeckCommon| {\n            c.new_studied += input.new_delta;\n            c.review_studied += input.review_delta;\n            c.milliseconds_studied += input.millisecond_delta;\n        };\n        if let Some(mut deck) = self.storage.get_deck(did)? {\n            self.update_deck_stats_single(today, usn, &mut deck, mutator)?;\n            for mut deck in self.storage.parent_decks(&deck)? {\n                self.update_deck_stats_single(today, usn, &mut deck, mutator)?;\n            }\n        }\n        Ok(())\n    }\n\n    /// Modify the deck's limits by adjusting the 'done today' count.\n    /// Positive values increase the limit, negative value decrease it.\n    /// If global parent limits are enabled, the deck's parents are adjusted as\n    /// well.\n    /// Caller should ensure a transaction.\n    pub(crate) fn extend_limits(\n        &mut self,\n        today: u32,\n        usn: Usn,\n        did: DeckId,\n        new_delta: i32,\n        review_delta: i32,\n    ) -> Result<()> {\n        let mutator = |c: &mut DeckCommon| {\n            c.new_studied -= new_delta;\n            c.review_studied -= review_delta;\n        };\n        if let Some(mut deck) = self.storage.get_deck(did)? {\n            self.update_deck_stats_single(today, usn, &mut deck, mutator)?;\n            if self.get_config_bool(BoolKey::ApplyAllParentLimits) {\n                for mut parent in self.storage.parent_decks(&deck)? {\n                    self.update_deck_stats_single(today, usn, &mut parent, mutator)?;\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\nimpl Collection {\n    fn update_deck_stats_single<F>(\n        &mut self,\n        today: u32,\n        usn: Usn,\n        deck: &mut Deck,\n        mutator: F,\n    ) -> Result<()>\n    where\n        F: FnOnce(&mut DeckCommon),\n    {\n        let original = deck.clone();\n        deck.reset_stats_if_day_changed(today);\n        mutator(&mut deck.common);\n        deck.set_modified(usn);\n        self.update_single_deck_undoable(deck, original)\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/tree.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::iter::Peekable;\nuse std::ops::AddAssign;\n\npub use anki_proto::decks::set_deck_collapsed_request::Scope as DeckCollapseScope;\nuse anki_proto::decks::DeckTreeNode;\nuse serde_tuple::Serialize_tuple;\nuse unicase::UniCase;\n\nuse super::limits::remaining_limits_map;\nuse super::limits::RemainingLimits;\nuse super::DueCounts;\nuse crate::ops::OpOutput;\nuse crate::prelude::*;\nuse crate::undo::Op;\n\nfn deck_names_to_tree(names: impl Iterator<Item = (DeckId, String)>) -> DeckTreeNode {\n    let mut top = DeckTreeNode::default();\n    let mut it = names.peekable();\n\n    add_child_nodes(&mut it, &mut top);\n\n    top\n}\n\nfn add_child_nodes(\n    names: &mut Peekable<impl Iterator<Item = (DeckId, String)>>,\n    parent: &mut DeckTreeNode,\n) {\n    while let Some((id, name)) = names.peek() {\n        let split_name: Vec<_> = name.split(\"::\").collect();\n        // protobuf refuses to decode messages with 100+ levels of nesting, and\n        // broken collections with such nesting have been found in the wild\n        let capped_len = split_name.len().min(99) as u32;\n        match capped_len {\n            l if l <= parent.level => {\n                // next item is at a higher level\n                return;\n            }\n            l if l == parent.level + 1 => {\n                // next item is an immediate descendent of parent\n                parent.children.push(DeckTreeNode {\n                    deck_id: id.0,\n                    name: (*split_name.last().unwrap()).into(),\n                    children: vec![],\n                    level: parent.level + 1,\n                    ..Default::default()\n                });\n                names.next();\n            }\n            _ => {\n                // next item is at a lower level\n                if let Some(last_child) = parent.children.last_mut() {\n                    add_child_nodes(names, last_child)\n                } else {\n                    // immediate parent is missing, skip the deck until a DB check is run\n                    names.next();\n                }\n            }\n        }\n    }\n}\n\nfn add_collapsed_and_filtered(\n    node: &mut DeckTreeNode,\n    decks: &HashMap<DeckId, Deck>,\n    browser: bool,\n) {\n    if let Some(deck) = decks.get(&DeckId(node.deck_id)) {\n        node.collapsed = if browser {\n            deck.common.browser_collapsed\n        } else {\n            deck.common.study_collapsed\n        };\n        node.filtered = deck.is_filtered();\n    }\n    for child in &mut node.children {\n        add_collapsed_and_filtered(child, decks, browser);\n    }\n}\n\nfn add_counts(node: &mut DeckTreeNode, counts: &HashMap<DeckId, DueCounts>) {\n    if let Some(counts) = counts.get(&DeckId(node.deck_id)) {\n        node.new_count = counts.new;\n        node.review_count = counts.review;\n        node.learn_count = counts.learning;\n        node.intraday_learning = counts.intraday_learning;\n        node.interday_learning_uncapped = counts.interday_learning;\n        node.new_uncapped = counts.new;\n        node.review_uncapped = counts.review;\n        node.total_in_deck = counts.total_cards;\n    }\n    for child in &mut node.children {\n        add_counts(child, counts);\n    }\n}\n\n/// A temporary container used during count summation and limit application.\n#[derive(Default, Clone)]\nstruct NodeCountsV3 {\n    new: u32,\n    review: u32,\n    intraday_learning: u32,\n    interday_learning: u32,\n    total: u32,\n}\n\nimpl NodeCountsV3 {\n    fn capped(&self, remaining: &RemainingLimits) -> Self {\n        let mut capped = self.clone();\n        // apply review limit to interday learning\n        capped.interday_learning = capped.interday_learning.min(remaining.review);\n        let mut remaining_reviews = remaining.review.saturating_sub(capped.interday_learning);\n        // any remaining review limit is applied to reviews\n        capped.review = capped.review.min(remaining_reviews);\n        capped.new = capped.new.min(remaining.new);\n        if remaining.cap_new_to_review {\n            remaining_reviews = remaining_reviews.saturating_sub(capped.review);\n            capped.new = capped.new.min(remaining_reviews);\n        }\n        capped\n    }\n}\n\nimpl AddAssign for NodeCountsV3 {\n    fn add_assign(&mut self, rhs: Self) {\n        self.new += rhs.new;\n        self.review += rhs.review;\n        self.intraday_learning += rhs.intraday_learning;\n        self.interday_learning += rhs.interday_learning;\n        self.total += rhs.total;\n    }\n}\n\n/// Adjust new, review and learning counts based on the daily limits.\n/// As part of this process, the separate interday and intraday learning\n/// counts are combined after the limits have been applied.\nfn sum_counts_and_apply_limits_v3(\n    node: &mut DeckTreeNode,\n    limits: &HashMap<DeckId, RemainingLimits>,\n    mut parent_limits: Option<RemainingLimits>,\n) -> NodeCountsV3 {\n    let mut remaining = limits\n        .get(&DeckId(node.deck_id))\n        .copied()\n        .unwrap_or_default();\n    if let Some(parent_remaining) = parent_limits {\n        remaining.cap_to(parent_remaining);\n        parent_limits.replace(remaining);\n    }\n\n    // initialize with this node's values\n    let mut this_node_uncapped = NodeCountsV3 {\n        new: node.new_count,\n        review: node.review_count,\n        intraday_learning: node.intraday_learning,\n        interday_learning: node.interday_learning_uncapped,\n        total: node.total_in_deck,\n    };\n    let mut total_including_children = node.total_in_deck;\n\n    // add capped child counts / uncapped total\n    for child in &mut node.children {\n        this_node_uncapped += sum_counts_and_apply_limits_v3(child, limits, parent_limits);\n        total_including_children += child.total_including_children;\n    }\n\n    let this_node_capped = this_node_uncapped.capped(&remaining);\n\n    node.new_count = this_node_capped.new;\n    node.review_count = this_node_capped.review;\n    node.learn_count = this_node_capped.intraday_learning + this_node_capped.interday_learning;\n    node.total_including_children = total_including_children;\n\n    this_node_capped\n}\n\nfn hide_default_deck(node: &mut DeckTreeNode) {\n    for (idx, child) in node.children.iter().enumerate() {\n        // we can hide the default if it has no children\n        if child.deck_id == 1 && child.children.is_empty() {\n            if child.level == 1 && node.children.len() == 1 {\n                // can't remove if there are no other decks\n            } else {\n                // safe to remove\n                _ = node.children.remove(idx);\n            }\n            return;\n        }\n    }\n}\n\n/// Locate provided deck in tree, and return it.\npub fn get_deck_in_tree(tree: DeckTreeNode, deck_id: DeckId) -> Option<DeckTreeNode> {\n    if tree.deck_id == deck_id.0 {\n        return Some(tree);\n    }\n    for child in tree.children {\n        if let Some(node) = get_deck_in_tree(child, deck_id) {\n            return Some(node);\n        }\n    }\n\n    None\n}\n\npub(crate) fn sum_deck_tree_node<T: AddAssign>(\n    node: &DeckTreeNode,\n    map: fn(&DeckTreeNode) -> T,\n) -> T {\n    let mut output = map(node);\n    for child in &node.children {\n        output += sum_deck_tree_node(child, map)\n    }\n    output\n}\n\n#[derive(Serialize_tuple)]\npub(crate) struct LegacyDueCounts {\n    name: String,\n    deck_id: i64,\n    review: u32,\n    learn: u32,\n    new: u32,\n    children: Vec<LegacyDueCounts>,\n}\n\nimpl From<DeckTreeNode> for LegacyDueCounts {\n    fn from(n: DeckTreeNode) -> Self {\n        LegacyDueCounts {\n            name: n.name,\n            deck_id: n.deck_id,\n            review: n.review_count,\n            learn: n.learn_count,\n            new: n.new_count,\n            children: n.children.into_iter().map(From::from).collect(),\n        }\n    }\n}\n\nimpl Collection {\n    /// Get the deck tree.\n    /// - If `timestamp` is provided, due counts for the provided timestamp will\n    ///   be populated.\n    /// - Buried cards from previous days will be unburied if necessary. Because\n    ///   this does not happen for future stamps, future due numbers may not be\n    ///   accurate.\n    pub fn deck_tree(&mut self, timestamp: Option<TimestampSecs>) -> Result<DeckTreeNode> {\n        let names = self.storage.get_all_deck_names()?;\n        let mut tree = deck_names_to_tree(names.into_iter());\n\n        let decks_map = self.storage.get_decks_map()?;\n\n        add_collapsed_and_filtered(&mut tree, &decks_map, timestamp.is_none());\n        if self.default_deck_is_empty()? {\n            hide_default_deck(&mut tree);\n        }\n\n        if let Some(timestamp) = timestamp {\n            // cards buried on previous days need to be unburied for the current\n            // day's counts to be accurate\n            let timing_today = self.timing_today()?;\n            self.unbury_if_day_rolled_over(timing_today)?;\n\n            let timing_at_stamp = self.timing_for_timestamp(timestamp)?;\n            let days_elapsed = timing_at_stamp.days_elapsed;\n            let learn_cutoff = (timestamp.0 as u32) + self.learn_ahead_secs();\n            let new_cards_ignore_review_limit =\n                self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit);\n            let parent_limits = self\n                .get_config_bool(BoolKey::ApplyAllParentLimits)\n                .then(Default::default);\n            let counts = self.due_counts(days_elapsed, learn_cutoff)?;\n            let dconf = self.storage.get_deck_config_map()?;\n            add_counts(&mut tree, &counts);\n            let limits = remaining_limits_map(\n                decks_map.values(),\n                &dconf,\n                days_elapsed,\n                new_cards_ignore_review_limit,\n            );\n            sum_counts_and_apply_limits_v3(&mut tree, &limits, parent_limits);\n        }\n\n        Ok(tree)\n    }\n\n    pub fn current_deck_tree(&mut self) -> Result<Option<DeckTreeNode>> {\n        let target = self.get_current_deck_id();\n        let tree = self.deck_tree(Some(TimestampSecs::now()))?;\n        Ok(get_deck_in_tree(tree, target))\n    }\n\n    pub fn set_deck_collapsed(\n        &mut self,\n        did: DeckId,\n        collapsed: bool,\n        scope: DeckCollapseScope,\n    ) -> Result<OpOutput<()>> {\n        self.transact(Op::SkipUndo, |col| {\n            if let Some(mut deck) = col.storage.get_deck(did)? {\n                let original = deck.clone();\n                let c = &mut deck.common;\n                match scope {\n                    DeckCollapseScope::Reviewer => c.study_collapsed = collapsed,\n                    DeckCollapseScope::Browser => c.browser_collapsed = collapsed,\n                };\n                col.update_deck_inner(&mut deck, original, col.usn()?)?;\n            }\n            Ok(())\n        })\n    }\n}\n\nimpl Collection {\n    pub(crate) fn legacy_deck_tree(&mut self) -> Result<LegacyDueCounts> {\n        let tree = self.deck_tree(Some(TimestampSecs::now()))?;\n        Ok(LegacyDueCounts::from(tree))\n    }\n\n    pub(crate) fn add_missing_deck_names(&mut self, names: &[(DeckId, String)]) -> Result<usize> {\n        let mut parents = HashSet::new();\n        let mut missing = 0;\n        for (_id, name) in names {\n            parents.insert(UniCase::new(name.as_str()));\n            if let Some((immediate_parent, _)) = name.rsplit_once(\"::\") {\n                let immediate_parent_uni = UniCase::new(immediate_parent);\n                if !parents.contains(&immediate_parent_uni) {\n                    self.get_or_create_normal_deck(immediate_parent)?;\n                    parents.insert(immediate_parent_uni);\n                    missing += 1;\n                }\n            }\n        }\n        Ok(missing)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::deckconfig::DeckConfigId;\n    use crate::error::Result;\n\n    #[test]\n    fn wellformed() -> Result<()> {\n        let mut col = Collection::new();\n\n        col.get_or_create_normal_deck(\"1\")?;\n        col.get_or_create_normal_deck(\"2\")?;\n        col.get_or_create_normal_deck(\"2::a\")?;\n        col.get_or_create_normal_deck(\"2::b\")?;\n        col.get_or_create_normal_deck(\"2::c\")?;\n        col.get_or_create_normal_deck(\"2::c::A\")?;\n        col.get_or_create_normal_deck(\"3\")?;\n\n        let tree = col.deck_tree(None)?;\n\n        assert_eq!(tree.children.len(), 3);\n\n        assert_eq!(tree.children[1].name, \"2\");\n        assert_eq!(tree.children[1].children[0].name, \"a\");\n        assert_eq!(tree.children[1].children[2].name, \"c\");\n        assert_eq!(tree.children[1].children[2].children[0].name, \"A\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn malformed() -> Result<()> {\n        let mut col = Collection::new();\n\n        col.get_or_create_normal_deck(\"1\")?;\n        col.get_or_create_normal_deck(\"2::3::4\")?;\n\n        // remove the top parent and middle parent\n        col.storage.remove_deck(col.get_deck_id(\"2\")?.unwrap())?;\n        col.storage.remove_deck(col.get_deck_id(\"2::3\")?.unwrap())?;\n\n        let tree = col.deck_tree(None)?;\n        assert_eq!(tree.children.len(), 1);\n\n        Ok(())\n    }\n\n    #[test]\n    fn counts() -> Result<()> {\n        let mut col = Collection::new();\n\n        let mut parent_deck = col.get_or_create_normal_deck(\"Default\")?;\n        let mut child_deck = col.get_or_create_normal_deck(\"Default::one\")?;\n\n        // add some new cards\n        let nt = col.get_notetype_by_name(\"Cloze\")?.unwrap();\n        let mut note = nt.new_note();\n        note.set_field(0, \"{{c1::}} {{c2::}} {{c3::}} {{c4::}}\")?;\n        col.add_note(&mut note, child_deck.id)?;\n\n        let tree = col.deck_tree(Some(TimestampSecs::now()))?;\n        assert_eq!(tree.children[0].new_count, 4);\n        assert_eq!(tree.children[0].children[0].new_count, 4);\n\n        // simulate answering a card\n        child_deck.common.new_studied = 1;\n        col.add_or_update_deck(&mut child_deck)?;\n        parent_deck.common.new_studied = 1;\n        col.add_or_update_deck(&mut parent_deck)?;\n\n        // with the default limit of 20, there should still be 4 due\n        let tree = col.deck_tree(Some(TimestampSecs::now()))?;\n        assert_eq!(tree.children[0].new_count, 4);\n        assert_eq!(tree.children[0].children[0].new_count, 4);\n\n        // set the limit to 4, which should mean 3 are left\n        let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap();\n        conf.inner.new_per_day = 4;\n        col.add_or_update_deck_config(&mut conf)?;\n\n        let tree = col.deck_tree(Some(TimestampSecs::now()))?;\n        assert_eq!(tree.children[0].new_count, 3);\n        assert_eq!(tree.children[0].children[0].new_count, 3);\n\n        Ok(())\n    }\n\n    #[test]\n    fn nested_counts_v3() -> Result<()> {\n        fn create_deck_with_new_limit(col: &mut Collection, name: &str, new_limit: u32) -> Deck {\n            let mut deck = col.get_or_create_normal_deck(name).unwrap();\n            let mut conf = DeckConfig::default();\n            conf.inner.new_per_day = new_limit;\n            col.add_or_update_deck_config(&mut conf).unwrap();\n            deck.normal_mut().unwrap().config_id = conf.id.0;\n            col.add_or_update_deck(&mut deck).unwrap();\n            deck\n        }\n\n        let mut col = Collection::new();\n\n        let parent_deck = create_deck_with_new_limit(&mut col, \"Default\", 8);\n        let child_deck = create_deck_with_new_limit(&mut col, \"Default::child\", 4);\n        let grandchild_1 = create_deck_with_new_limit(&mut col, \"Default::child::grandchild_1\", 2);\n        let grandchild_2 = create_deck_with_new_limit(&mut col, \"Default::child::grandchild_2\", 1);\n\n        // add 2 new cards to each deck\n        let nt = col.get_notetype_by_name(\"Cloze\")?.unwrap();\n        let mut note = nt.new_note();\n        note.set_field(0, \"{{c1::}} {{c2::}}\")?;\n        col.add_note(&mut note, parent_deck.id)?;\n        note.id.0 = 0;\n        col.add_note(&mut note, child_deck.id)?;\n        note.id.0 = 0;\n        col.add_note(&mut note, grandchild_1.id)?;\n        note.id.0 = 0;\n        col.add_note(&mut note, grandchild_2.id)?;\n\n        let parent = &col.deck_tree(Some(TimestampSecs::now()))?.children[0];\n        // grandchildren: own cards, limited by own new limits\n        assert_eq!(parent.children[0].children[0].new_count, 2);\n        assert_eq!(parent.children[0].children[1].new_count, 1);\n        // child: cards from self and children, limited by own new limit\n        assert_eq!(parent.children[0].new_count, 4);\n        // parent: cards from self and all subdecks, all limits in the hierarchy are\n        // respected\n        assert_eq!(parent.new_count, 6);\n        assert_eq!(parent.total_including_children, 8);\n        assert_eq!(parent.total_in_deck, 2);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/decks/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\n\npub(crate) enum UndoableDeckChange {\n    Added(Box<Deck>),\n    Updated(Box<Deck>),\n    Removed(Box<Deck>),\n    GraveAdded(Box<(DeckId, Usn)>),\n    GraveRemoved(Box<(DeckId, Usn)>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_deck_change(&mut self, change: UndoableDeckChange) -> Result<()> {\n        match change {\n            UndoableDeckChange::Added(deck) => self.remove_deck_undoable(*deck),\n            UndoableDeckChange::Updated(mut deck) => {\n                let current = self\n                    .storage\n                    .get_deck(deck.id)?\n                    .or_invalid(\"deck disappeared\")?;\n                self.update_single_deck_undoable(&mut deck, current)\n            }\n            UndoableDeckChange::Removed(deck) => self.restore_deleted_deck(*deck),\n            UndoableDeckChange::GraveAdded(e) => self.remove_deck_grave(e.0, e.1),\n            UndoableDeckChange::GraveRemoved(e) => self.add_deck_grave_undoable(e.0, e.1),\n        }\n    }\n\n    pub(crate) fn remove_deck_and_add_grave_undoable(\n        &mut self,\n        deck: Deck,\n        usn: Usn,\n    ) -> Result<()> {\n        self.state.deck_cache.clear();\n        self.add_deck_grave_undoable(deck.id, usn)?;\n        self.storage.remove_deck(deck.id)?;\n        self.save_undo(UndoableDeckChange::Removed(Box::new(deck)));\n        Ok(())\n    }\n}\n\nimpl Collection {\n    pub(super) fn add_deck_undoable(&mut self, deck: &mut Deck) -> Result<(), AnkiError> {\n        self.storage.add_deck(deck)?;\n        self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone())));\n        Ok(())\n    }\n\n    pub(super) fn add_or_update_deck_with_existing_id_undoable(\n        &mut self,\n        deck: &mut Deck,\n    ) -> Result<(), AnkiError> {\n        self.state.deck_cache.clear();\n        self.storage.add_or_update_deck_with_existing_id(deck)?;\n        self.save_undo(UndoableDeckChange::Added(Box::new(deck.clone())));\n        Ok(())\n    }\n\n    /// Update an individual, existing deck. Caller is responsible for ensuring\n    /// deck is normalized, matches parents, is not a duplicate name, and\n    /// bumping mtime. Clears deck cache.\n    pub(super) fn update_single_deck_undoable(\n        &mut self,\n        deck: &mut Deck,\n        original: Deck,\n    ) -> Result<()> {\n        self.state.deck_cache.clear();\n        self.save_undo(UndoableDeckChange::Updated(Box::new(original)));\n        self.storage.update_deck(deck)\n    }\n\n    fn restore_deleted_deck(&mut self, deck: Deck) -> Result<()> {\n        self.storage.add_or_update_deck_with_existing_id(&deck)?;\n        self.save_undo(UndoableDeckChange::Added(Box::new(deck)));\n        Ok(())\n    }\n\n    fn remove_deck_undoable(&mut self, deck: Deck) -> Result<()> {\n        self.state.deck_cache.clear();\n        self.storage.remove_deck(deck.id)?;\n        self.save_undo(UndoableDeckChange::Removed(Box::new(deck)));\n        Ok(())\n    }\n\n    fn add_deck_grave_undoable(&mut self, did: DeckId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableDeckChange::GraveAdded(Box::new((did, usn))));\n        self.storage.add_deck_grave(did, usn)\n    }\n\n    fn remove_deck_grave(&mut self, did: DeckId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableDeckChange::GraveRemoved(Box::new((did, usn))));\n        self.storage.remove_deck_grave(did)\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/db.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::str::Utf8Error;\n\nuse anki_i18n::I18n;\nuse rusqlite::types::FromSqlError;\nuse rusqlite::Error;\nuse snafu::Snafu;\n\nuse super::AnkiError;\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\n#[snafu(display(\"{kind:?}: {info}\"))]\npub struct DbError {\n    pub info: String,\n    pub kind: DbErrorKind,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum DbErrorKind {\n    FileTooNew,\n    FileTooOld,\n    MissingEntity,\n    Corrupt,\n    Locked,\n    Utf8,\n    Other,\n}\n\nimpl AnkiError {\n    pub(crate) fn db_error(info: impl Into<String>, kind: DbErrorKind) -> Self {\n        AnkiError::DbError {\n            source: DbError {\n                info: info.into(),\n                kind,\n            },\n        }\n    }\n}\n\nimpl From<Error> for AnkiError {\n    fn from(err: Error) -> Self {\n        if let Error::SqliteFailure(error, Some(reason)) = &err {\n            if error.code == rusqlite::ErrorCode::DatabaseBusy {\n                return AnkiError::DbError {\n                    source: DbError {\n                        info: \"\".to_string(),\n                        kind: DbErrorKind::Locked,\n                    },\n                };\n            }\n            if reason.contains(\"regex parse error\") {\n                return AnkiError::InvalidRegex {\n                    info: reason.to_owned(),\n                };\n            }\n        } else if let Error::FromSqlConversionFailure(_, _, err) = &err {\n            if let Some(_err) = err.downcast_ref::<Utf8Error>() {\n                return AnkiError::DbError {\n                    source: DbError {\n                        info: \"\".to_string(),\n                        kind: DbErrorKind::Utf8,\n                    },\n                };\n            }\n        }\n        AnkiError::DbError {\n            source: DbError {\n                info: format!(\"{err:?}\"),\n                kind: DbErrorKind::Other,\n            },\n        }\n    }\n}\n\nimpl From<FromSqlError> for AnkiError {\n    fn from(err: FromSqlError) -> Self {\n        if let FromSqlError::Other(ref err) = err {\n            if let Some(_err) = err.downcast_ref::<Utf8Error>() {\n                return AnkiError::DbError {\n                    source: DbError {\n                        info: \"\".to_string(),\n                        kind: DbErrorKind::Utf8,\n                    },\n                };\n            }\n        }\n        AnkiError::DbError {\n            source: DbError {\n                info: format!(\"{err:?}\"),\n                kind: DbErrorKind::Other,\n            },\n        }\n    }\n}\n\nimpl DbError {\n    pub fn message(&self, _tr: &I18n) -> String {\n        match self.kind {\n            DbErrorKind::Corrupt => self.info.clone(),\n            // fixme: i18n\n            DbErrorKind::Locked => \"Anki already open, or media currently syncing.\".into(),\n            _ => format!(\"{self:?}\"),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/filtered.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_i18n::I18n;\nuse snafu::Snafu;\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\npub enum FilteredDeckError {\n    MustBeLeafNode,\n    CanNotMoveCardsInto,\n    SearchReturnedNoCards,\n    FilteredDeckRequired,\n}\n\nimpl FilteredDeckError {\n    pub fn message(&self, tr: &I18n) -> String {\n        match self {\n            FilteredDeckError::MustBeLeafNode => tr.errors_filtered_parent_deck(),\n            FilteredDeckError::CanNotMoveCardsInto => {\n                tr.browsing_cards_cant_be_manually_moved_into()\n            }\n            FilteredDeckError::SearchReturnedNoCards => tr.decks_filtered_deck_search_empty(),\n            FilteredDeckError::FilteredDeckRequired => tr.errors_filtered_deck_required(),\n        }\n        .into()\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\npub enum CustomStudyError {\n    NoMatchingCards,\n    ExistingDeck,\n}\n\nimpl CustomStudyError {\n    pub fn message(&self, tr: &I18n) -> String {\n        match self {\n            Self::NoMatchingCards => tr.custom_study_no_cards_matched_the_criteria_you(),\n            Self::ExistingDeck => tr.custom_study_must_rename_deck(),\n        }\n        .into()\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/invalid_input.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse snafu::Backtrace;\nuse snafu::OptionExt;\nuse snafu::ResultExt;\nuse snafu::Snafu;\n\nuse crate::prelude::*;\n\n/// General-purpose error for unexpected [Err]s, [None]s, and other\n/// violated constraints.\n#[derive(Debug, Snafu)]\n#[snafu(visibility(pub), display(\"{message}\"), whatever)]\npub struct InvalidInputError {\n    pub message: String,\n    #[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, Some)))]\n    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,\n    pub backtrace: Option<Backtrace>,\n}\n\nimpl InvalidInputError {\n    pub fn message(&self) -> String {\n        self.message.clone()\n    }\n\n    pub fn context(&self) -> String {\n        if let Some(source) = &self.source {\n            format!(\"{source}\")\n        } else {\n            String::new()\n        }\n    }\n}\n\nimpl PartialEq for InvalidInputError {\n    fn eq(&self, other: &Self) -> bool {\n        self.message == other.message\n    }\n}\n\nimpl Eq for InvalidInputError {}\n\n/// Allows generating [AnkiError::InvalidInput] from [None] and the\n/// typical [Err].\npub trait OrInvalid {\n    type Value;\n    fn or_invalid(self, message: impl Into<String>) -> Result<Self::Value>;\n}\n\nimpl<T> OrInvalid for Option<T> {\n    type Value = T;\n\n    fn or_invalid(self, message: impl Into<String>) -> Result<T> {\n        self.whatever_context::<_, InvalidInputError>(message)\n            .map_err(Into::into)\n    }\n}\n\nimpl<T, E: std::error::Error + Send + Sync + 'static> OrInvalid for Result<T, E> {\n    type Value = T;\n\n    fn or_invalid(self, message: impl Into<String>) -> Result<T> {\n        self.whatever_context::<_, InvalidInputError>(message)\n            .map_err(Into::into)\n    }\n}\n\n/// Returns an [AnkiError::InvalidInput] with the provided format string and an\n/// optional underlying error.\n#[macro_export]\nmacro_rules! invalid_input {\n    ($fmt:literal$(, $($arg:expr),* $(,)?)?) => {\n        return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput {\n            source: snafu::FromString::without_source(\n                format!($fmt$(, $($arg),*)*),\n            )\n        }})\n    };\n    ($source:expr, $fmt:literal$(, $($arg:expr),* $(,)?)?) => {\n        return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput {\n            source: snafu::FromString::with_source(\n                core::convert::Into::into($source),\n                format!($fmt$(, $($arg),*)*),\n            )\n        }})\n    };\n}\n\n/// Returns an [AnkiError::InvalidInput] unless the condition is true.\n#[macro_export]\nmacro_rules! require {\n    ($condition:expr, $fmt:literal$(, $($arg:expr),* $(,)?)?) => {\n        if !$condition {\n            return core::result::Result::Err({ $crate::error::AnkiError::InvalidInput {\n                source: snafu::FromString::without_source(\n                    format!($fmt$(, $($arg),*)*),\n                )\n            }});\n        }\n    };\n}\n"
  },
  {
    "path": "rslib/src/error/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod db;\nmod filtered;\nmod invalid_input;\npub(crate) mod network;\nmod not_found;\nmod search;\n#[cfg(windows)]\npub mod windows;\n\nuse anki_i18n::I18n;\nuse anki_io::FileIoError;\nuse anki_io::FileOp;\npub use db::DbError;\npub use db::DbErrorKind;\npub use filtered::CustomStudyError;\npub use filtered::FilteredDeckError;\npub use network::NetworkError;\npub use network::NetworkErrorKind;\npub use network::SyncError;\npub use network::SyncErrorKind;\npub use search::ParseError;\npub use search::SearchErrorKind;\nuse snafu::Snafu;\n\npub use self::invalid_input::InvalidInputError;\npub use self::invalid_input::OrInvalid;\npub use self::not_found::NotFoundError;\npub use self::not_found::OrNotFound;\nuse crate::import_export::ImportError;\nuse crate::links::HelpPage;\n\npub type Result<T, E = AnkiError> = std::result::Result<T, E>;\n\n#[derive(Debug, PartialEq, Snafu)]\npub enum AnkiError {\n    #[snafu(context(false))]\n    InvalidInput {\n        source: InvalidInputError,\n    },\n    TemplateError {\n        info: String,\n    },\n    #[snafu(context(false))]\n    CardTypeError {\n        source: CardTypeError,\n    },\n    #[snafu(context(false))]\n    FileIoError {\n        source: FileIoError,\n    },\n    #[snafu(context(false))]\n    DbError {\n        source: DbError,\n    },\n    #[snafu(context(false))]\n    NetworkError {\n        source: NetworkError,\n    },\n    #[snafu(context(false))]\n    SyncError {\n        source: SyncError,\n    },\n    JsonError {\n        info: String,\n    },\n    ProtoError {\n        info: String,\n    },\n    ParseNumError,\n    Interrupted,\n    CollectionNotOpen,\n    CollectionAlreadyOpen,\n    #[snafu(context(false))]\n    NotFound {\n        source: NotFoundError,\n    },\n    /// Indicates an absent card or note, but (unlike [AnkiError::NotFound]) in\n    /// a non-critical context like the browser table, where deleted ids are\n    /// deliberately not removed.\n    Deleted,\n    Existing,\n    #[snafu(context(false))]\n    FilteredDeckError {\n        source: FilteredDeckError,\n    },\n    #[snafu(context(false))]\n    SearchError {\n        source: SearchErrorKind,\n    },\n    InvalidRegex {\n        info: String,\n    },\n    UndoEmpty,\n    MultipleNotetypesSelected,\n    DatabaseCheckRequired,\n    MediaCheckRequired,\n    #[snafu(context(false))]\n    CustomStudyError {\n        source: CustomStudyError,\n    },\n    #[snafu(context(false))]\n    ImportError {\n        source: ImportError,\n    },\n    InvalidId,\n    #[cfg(windows)]\n    #[snafu(context(false))]\n    WindowsError {\n        source: windows::WindowsError,\n    },\n    InvalidMethodIndex,\n    InvalidServiceIndex,\n    FsrsParamsInvalid,\n    /// Returned by fsrs-rs; may happen even if 400+ reviews\n    FsrsInsufficientData,\n    /// Generated by our backend if count < 400\n    FsrsInsufficientReviews {\n        count: usize,\n    },\n    FsrsUnableToDetermineDesiredRetention,\n    SchedulerUpgradeRequired,\n    InvalidCertificateFormat,\n}\n\n// error helpers\nimpl AnkiError {\n    pub fn message(&self, tr: &I18n) -> String {\n        match self {\n            AnkiError::SyncError { source } => source.message(tr),\n            AnkiError::NetworkError { source } => source.message(tr),\n            AnkiError::TemplateError { info: source } => {\n                // already localized\n                source.into()\n            }\n            AnkiError::CardTypeError { source } => {\n                let header =\n                    tr.card_templates_invalid_template_number(source.ordinal + 1, &source.notetype);\n                let details = match &source.source {\n                    CardTypeErrorDetails::TemplateParseError => tr.card_templates_see_preview(),\n                    CardTypeErrorDetails::NoSuchField { field } => {\n                        tr.card_templates_field_not_found(field)\n                    }\n                    CardTypeErrorDetails::NoFrontField => tr.card_templates_no_front_field(),\n                    CardTypeErrorDetails::Duplicate { index } => {\n                        tr.card_templates_identical_front(index + 1)\n                    }\n                    CardTypeErrorDetails::MissingCloze => tr.card_templates_missing_cloze(),\n                };\n                format!(\"{header}<br>{details}\")\n            }\n            AnkiError::DbError { source } => source.message(tr),\n            AnkiError::SearchError { source } => source.message(tr),\n            AnkiError::ParseNumError => tr.errors_parse_number_fail().into(),\n            AnkiError::FilteredDeckError { source } => source.message(tr),\n            AnkiError::InvalidRegex { info: source } => format!(\"<pre>{source}</pre>\"),\n            AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(),\n            AnkiError::DatabaseCheckRequired => tr.errors_please_check_database().into(),\n            AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(),\n            AnkiError::CustomStudyError { source } => source.message(tr),\n            AnkiError::ImportError { source } => source.message(tr),\n            AnkiError::Deleted => tr.browsing_row_deleted().into(),\n            AnkiError::InvalidId => tr.errors_please_check_database().into(),\n            AnkiError::JsonError { .. }\n            | AnkiError::ProtoError { .. }\n            | AnkiError::Interrupted\n            | AnkiError::CollectionNotOpen\n            | AnkiError::CollectionAlreadyOpen\n            | AnkiError::Existing\n            | AnkiError::InvalidServiceIndex\n            | AnkiError::InvalidMethodIndex\n            | AnkiError::UndoEmpty\n            | AnkiError::InvalidCertificateFormat => format!(\"{self:?}\"),\n            AnkiError::FileIoError { source } => source.message(),\n            AnkiError::InvalidInput { source } => source.message(),\n            AnkiError::NotFound { source } => source.message(tr),\n            AnkiError::FsrsInsufficientData => tr.deck_config_not_enough_history().into(),\n            AnkiError::FsrsInsufficientReviews { count } => {\n                tr.deck_config_must_have_400_reviews(*count).into()\n            }\n            AnkiError::FsrsParamsInvalid => tr.deck_config_invalid_parameters().into(),\n            AnkiError::SchedulerUpgradeRequired => {\n                tr.scheduling_update_required().replace(\"V2\", \"v3\")\n            }\n            #[cfg(windows)]\n            AnkiError::WindowsError { source } => format!(\"{source:?}\"),\n            AnkiError::FsrsUnableToDetermineDesiredRetention => tr\n                .deck_config_unable_to_determine_desired_retention()\n                .into(),\n        }\n    }\n\n    pub fn help_page(&self) -> Option<HelpPage> {\n        match self {\n            Self::CardTypeError {\n                source: CardTypeError { source, .. },\n            } => Some(match source {\n                CardTypeErrorDetails::TemplateParseError => HelpPage::CardTypeTemplateError,\n                CardTypeErrorDetails::NoSuchField { field: _ } => HelpPage::CardTypeTemplateError,\n                CardTypeErrorDetails::Duplicate { .. } => HelpPage::CardTypeDuplicate,\n                CardTypeErrorDetails::NoFrontField => HelpPage::CardTypeNoFrontField,\n                CardTypeErrorDetails::MissingCloze => HelpPage::CardTypeMissingCloze,\n            }),\n            _ => None,\n        }\n    }\n\n    pub fn context(&self) -> String {\n        match self {\n            Self::InvalidInput { source } => source.context(),\n            Self::NotFound { source } => source.context(),\n            _ => String::new(),\n        }\n    }\n\n    pub fn backtrace(&self) -> String {\n        match self {\n            Self::InvalidInput { source } => {\n                if let Some(bt) = snafu::ErrorCompat::backtrace(source) {\n                    return format!(\"{bt}\");\n                }\n            }\n            Self::NotFound { source } => {\n                if let Some(bt) = snafu::ErrorCompat::backtrace(source) {\n                    return format!(\"{bt}\");\n                }\n            }\n            _ => (),\n        }\n        String::new()\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum TemplateError {\n    NoClosingBrackets(String),\n    ConditionalNotClosed(String),\n    ConditionalNotOpen {\n        closed: String,\n        currently_open: Option<String>,\n    },\n    FieldNotFound {\n        filters: String,\n        field: String,\n    },\n    NoSuchConditional(String),\n}\n\nimpl From<serde_json::Error> for AnkiError {\n    fn from(err: serde_json::Error) -> Self {\n        AnkiError::JsonError {\n            info: err.to_string(),\n        }\n    }\n}\n\nimpl From<prost::EncodeError> for AnkiError {\n    fn from(err: prost::EncodeError) -> Self {\n        AnkiError::ProtoError {\n            info: err.to_string(),\n        }\n    }\n}\n\nimpl From<prost::DecodeError> for AnkiError {\n    fn from(err: prost::DecodeError) -> Self {\n        AnkiError::ProtoError {\n            info: err.to_string(),\n        }\n    }\n}\n\nimpl From<tempfile::PathPersistError> for AnkiError {\n    fn from(e: tempfile::PathPersistError) -> Self {\n        FileIoError::from(e).into()\n    }\n}\n\nimpl From<tempfile::PersistError> for AnkiError {\n    fn from(e: tempfile::PersistError) -> Self {\n        FileIoError::from(e).into()\n    }\n}\n\nimpl From<regex::Error> for AnkiError {\n    fn from(err: regex::Error) -> Self {\n        AnkiError::InvalidRegex {\n            info: err.to_string(),\n        }\n    }\n}\n\n// stopgap; implicit mapping should be phased out in favor of manual\n// context attachment\nimpl From<std::io::Error> for AnkiError {\n    fn from(source: std::io::Error) -> Self {\n        FileIoError {\n            path: std::path::PathBuf::new(),\n            op: FileOp::Unknown,\n            source,\n        }\n        .into()\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\n#[snafu(visibility(pub))]\npub struct CardTypeError {\n    pub notetype: String,\n    pub ordinal: usize,\n    pub source: CardTypeErrorDetails,\n}\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\n#[snafu(visibility(pub))]\npub enum CardTypeErrorDetails {\n    TemplateParseError,\n    Duplicate { index: usize },\n    NoFrontField,\n    NoSuchField { field: String },\n    MissingCloze,\n}\n"
  },
  {
    "path": "rslib/src/error/network.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_i18n::I18n;\nuse reqwest::StatusCode;\nuse snafu::Snafu;\n\nuse super::AnkiError;\nuse crate::sync::collection::sanity::SanityCheckCounts;\nuse crate::sync::error::HttpError;\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\n#[snafu(visibility(pub(crate)))]\npub struct NetworkError {\n    pub info: String,\n    pub kind: NetworkErrorKind,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum NetworkErrorKind {\n    Offline,\n    Timeout,\n    ProxyAuth,\n    Other,\n}\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\n#[snafu(display(\"{kind:?}: {info}\"))]\npub struct SyncError {\n    pub info: String,\n    pub kind: SyncErrorKind,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub enum SyncErrorKind {\n    Conflict,\n    ServerError,\n    ClientTooOld,\n    AuthFailed,\n    ServerMessage,\n    ClockIncorrect,\n    Other,\n    ResyncRequired,\n    DatabaseCheckRequired,\n    SyncNotStarted,\n    UploadTooLarge,\n    SanityCheckFailed {\n        client: Option<SanityCheckCounts>,\n        server: Option<SanityCheckCounts>,\n    },\n}\n\nimpl AnkiError {\n    pub(crate) fn sync_error(info: impl Into<String>, kind: SyncErrorKind) -> Self {\n        AnkiError::SyncError {\n            source: SyncError {\n                info: info.into(),\n                kind,\n            },\n        }\n    }\n\n    pub(crate) fn server_message<S: Into<String>>(msg: S) -> AnkiError {\n        AnkiError::sync_error(msg, SyncErrorKind::ServerMessage)\n    }\n}\n\nimpl From<&reqwest::Error> for AnkiError {\n    fn from(err: &reqwest::Error) -> Self {\n        let url = err.url().map(|url| url.as_str()).unwrap_or(\"\");\n        let str_err = format!(\"{err}\");\n        // strip url from error to avoid exposing keys\n        let info = str_err.replace(url, \"\");\n\n        if err.is_timeout() {\n            AnkiError::NetworkError {\n                source: NetworkError {\n                    info,\n                    kind: NetworkErrorKind::Timeout,\n                },\n            }\n        } else if err.is_status() {\n            error_for_status_code(info, err.status().unwrap())\n        } else {\n            guess_reqwest_error(info)\n        }\n    }\n}\n\nimpl From<reqwest::Error> for AnkiError {\n    fn from(err: reqwest::Error) -> Self {\n        (&err).into()\n    }\n}\n\nfn error_for_status_code(info: String, code: StatusCode) -> AnkiError {\n    use reqwest::StatusCode as S;\n    match code {\n        S::PROXY_AUTHENTICATION_REQUIRED => AnkiError::NetworkError {\n            source: NetworkError {\n                info,\n                kind: NetworkErrorKind::ProxyAuth,\n            },\n        },\n        S::CONFLICT => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: SyncErrorKind::Conflict,\n            },\n        },\n        S::FORBIDDEN => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: SyncErrorKind::AuthFailed,\n            },\n        },\n        S::NOT_IMPLEMENTED => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: SyncErrorKind::ClientTooOld,\n            },\n        },\n        S::INTERNAL_SERVER_ERROR | S::BAD_GATEWAY | S::GATEWAY_TIMEOUT | S::SERVICE_UNAVAILABLE => {\n            AnkiError::SyncError {\n                source: SyncError {\n                    info,\n                    kind: SyncErrorKind::ServerError,\n                },\n            }\n        }\n        S::BAD_REQUEST => AnkiError::SyncError {\n            source: SyncError {\n                info,\n                kind: SyncErrorKind::DatabaseCheckRequired,\n            },\n        },\n        _ => AnkiError::NetworkError {\n            source: NetworkError {\n                info,\n                kind: NetworkErrorKind::Other,\n            },\n        },\n    }\n}\n\nfn guess_reqwest_error(mut info: String) -> AnkiError {\n    if info.contains(\"dns error: cancelled\") {\n        return AnkiError::Interrupted;\n    }\n    let kind = if info.contains(\"unreachable\") || info.contains(\"dns\") {\n        NetworkErrorKind::Offline\n    } else if info.contains(\"timed out\") {\n        NetworkErrorKind::Timeout\n    } else {\n        if info.contains(\"invalid type\") {\n            info = format!(\n                \"{} {} {}\\n\\n{}\",\n                \"Please force a one-way sync in the Preferences screen to bring your devices into sync.\",\n                \"Then, please use the Check Database feature, and sync to your other devices.\",\n                \"If problems persist, please post on the support forum.\",\n                info,\n            );\n        }\n\n        NetworkErrorKind::Other\n    };\n    AnkiError::NetworkError {\n        source: NetworkError { info, kind },\n    }\n}\n\nimpl From<zip::result::ZipError> for AnkiError {\n    fn from(err: zip::result::ZipError) -> Self {\n        AnkiError::sync_error(err.to_string(), SyncErrorKind::Other)\n    }\n}\n\nimpl SyncError {\n    pub fn message(&self, tr: &I18n) -> String {\n        match self.kind {\n            SyncErrorKind::ServerMessage => self.info.clone().into(),\n            SyncErrorKind::Other => self.info.clone().into(),\n            SyncErrorKind::Conflict => tr.sync_conflict(),\n            SyncErrorKind::ServerError => tr.sync_server_error(),\n            SyncErrorKind::ClientTooOld => tr.sync_client_too_old(),\n            SyncErrorKind::AuthFailed => tr.sync_wrong_pass(),\n            SyncErrorKind::ResyncRequired => tr.sync_resync_required(),\n            SyncErrorKind::ClockIncorrect => tr.sync_clock_off(),\n            SyncErrorKind::DatabaseCheckRequired | SyncErrorKind::SanityCheckFailed { .. } => {\n                tr.sync_sanity_check_failed()\n            }\n            SyncErrorKind::SyncNotStarted => \"sync not started\".into(),\n            SyncErrorKind::UploadTooLarge => tr.sync_upload_too_large(&self.info),\n        }\n        .into()\n    }\n}\n\nimpl NetworkError {\n    pub fn message(&self, tr: &I18n) -> String {\n        let summary = match self.kind {\n            NetworkErrorKind::Offline => tr.network_offline(),\n            NetworkErrorKind::Timeout => tr.network_timeout(),\n            NetworkErrorKind::ProxyAuth => tr.network_proxy_auth(),\n            NetworkErrorKind::Other => tr.network_other(),\n        };\n        let details = tr.network_details(self.info.as_str());\n        format!(\"{summary}\\n\\n{details}\")\n    }\n}\n\n// This needs rethinking; we should be attaching error context as errors are\n// encountered instead of trying to determine the problem later.\nimpl From<HttpError> for AnkiError {\n    fn from(err: HttpError) -> Self {\n        if let Some(reqwest_error) = err\n            .source\n            .as_ref()\n            .and_then(|source| source.downcast_ref::<reqwest::Error>())\n        {\n            reqwest_error.into()\n        } else if err.code == StatusCode::REQUEST_TIMEOUT {\n            NetworkError {\n                info: String::new(),\n                kind: NetworkErrorKind::Timeout,\n            }\n            .into()\n        } else {\n            AnkiError::sync_error(format!(\"{err:?}\"), SyncErrorKind::Other)\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/not_found.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::any;\nuse std::fmt;\n\nuse convert_case::Case;\nuse convert_case::Casing;\nuse snafu::Backtrace;\nuse snafu::OptionExt;\nuse snafu::Snafu;\n\nuse crate::prelude::*;\n\n/// Something was unexpectedly missing from the database.\n#[derive(Debug, Snafu)]\n#[snafu(visibility(pub))]\npub struct NotFoundError {\n    pub type_name: String,\n    pub identifier: String,\n    pub backtrace: Option<Backtrace>,\n}\n\nimpl NotFoundError {\n    pub fn message(&self, tr: &I18n) -> String {\n        format!(\n            \"{} No such {}: '{}'\",\n            tr.errors_inconsistent_db_state(),\n            self.type_name,\n            self.identifier\n        )\n    }\n\n    pub fn context(&self) -> String {\n        format!(\"No such {}: '{}'\", self.type_name, self.identifier)\n    }\n}\n\nimpl PartialEq for NotFoundError {\n    fn eq(&self, other: &Self) -> bool {\n        self.type_name == other.type_name && self.identifier == other.identifier\n    }\n}\n\nimpl Eq for NotFoundError {}\n\n/// Allows generating [AnkiError::NotFound] from [None].\npub trait OrNotFound {\n    type Value;\n    fn or_not_found(self, identifier: impl fmt::Display) -> Result<Self::Value>;\n}\n\nimpl<T> OrNotFound for Option<T> {\n    type Value = T;\n\n    fn or_not_found(self, identifier: impl fmt::Display) -> Result<Self::Value> {\n        self.with_context(|| NotFoundSnafu {\n            type_name: unqualified_lowercase_type_name::<Self::Value>(),\n            identifier: format!(\"{identifier}\"),\n        })\n        .map_err(Into::into)\n    }\n}\n\nfn unqualified_lowercase_type_name<T: ?Sized>() -> String {\n    any::type_name::<T>()\n        .split(\"::\")\n        .last()\n        .unwrap_or_default()\n        .to_case(Case::Lower)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn test_unqualified_lowercase_type_name() {\n        assert_eq!(unqualified_lowercase_type_name::<CardId>(), \"card id\");\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/search.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::num::ParseIntError;\n\nuse anki_i18n::I18n;\nuse nom::error::ErrorKind as NomErrorKind;\nuse nom::error::ParseError as NomParseError;\nuse snafu::Snafu;\n\nuse super::AnkiError;\n\n#[derive(Debug, PartialEq, Eq)]\npub enum ParseError<'a> {\n    Anki(&'a str, SearchErrorKind),\n    Nom(&'a str, NomErrorKind),\n}\n\n#[derive(Debug, PartialEq, Eq, Snafu)]\npub enum SearchErrorKind {\n    MisplacedAnd,\n    MisplacedOr,\n    EmptyGroup,\n    UnopenedGroup,\n    UnclosedGroup,\n    EmptyQuote,\n    UnclosedQuote,\n    MissingKey,\n    UnknownEscape { provided: String },\n    InvalidState { provided: String },\n    InvalidFlag,\n    InvalidPropProperty { provided: String },\n    InvalidPropOperator { provided: String },\n    InvalidNumber { provided: String, context: String },\n    InvalidWholeNumber { provided: String, context: String },\n    InvalidPositiveWholeNumber { provided: String, context: String },\n    InvalidNegativeWholeNumber { provided: String, context: String },\n    InvalidAnswerButton { provided: String, context: String },\n    Other { info: Option<String> },\n}\n\nimpl From<ParseError<'_>> for AnkiError {\n    fn from(err: ParseError) -> Self {\n        match err {\n            ParseError::Anki(_, kind) => AnkiError::SearchError { source: kind },\n            ParseError::Nom(_, _) => AnkiError::SearchError {\n                source: SearchErrorKind::Other { info: None },\n            },\n        }\n    }\n}\n\nimpl From<nom::Err<ParseError<'_>>> for AnkiError {\n    fn from(err: nom::Err<ParseError<'_>>) -> Self {\n        match err {\n            nom::Err::Error(e) => e.into(),\n            nom::Err::Failure(e) => e.into(),\n            nom::Err::Incomplete(_) => AnkiError::SearchError {\n                source: SearchErrorKind::Other { info: None },\n            },\n        }\n    }\n}\n\nimpl<'a> NomParseError<&'a str> for ParseError<'a> {\n    fn from_error_kind(input: &'a str, kind: NomErrorKind) -> Self {\n        ParseError::Nom(input, kind)\n    }\n\n    fn append(_: &str, _: NomErrorKind, other: Self) -> Self {\n        other\n    }\n}\n\nimpl From<ParseIntError> for AnkiError {\n    fn from(_err: ParseIntError) -> Self {\n        AnkiError::ParseNumError\n    }\n}\n\nimpl SearchErrorKind {\n    pub fn message(&self, tr: &I18n) -> String {\n        let reason = match self {\n            SearchErrorKind::MisplacedAnd => tr.search_misplaced_and(),\n            SearchErrorKind::MisplacedOr => tr.search_misplaced_or(),\n            SearchErrorKind::EmptyGroup => tr.search_empty_group(),\n            SearchErrorKind::UnopenedGroup => tr.search_unopened_group(),\n            SearchErrorKind::UnclosedGroup => tr.search_unclosed_group(),\n            SearchErrorKind::EmptyQuote => tr.search_empty_quote(),\n            SearchErrorKind::UnclosedQuote => tr.search_unclosed_quote(),\n            SearchErrorKind::MissingKey => tr.search_missing_key(),\n            SearchErrorKind::UnknownEscape { provided } => {\n                tr.search_unknown_escape(provided.replace('`', \"'\"))\n            }\n            SearchErrorKind::InvalidState { provided } => {\n                tr.search_invalid_argument(\"is:\", provided.replace('`', \"'\"))\n            }\n\n            SearchErrorKind::InvalidFlag => tr.search_invalid_flag_2(),\n            SearchErrorKind::InvalidPropProperty { provided } => {\n                tr.search_invalid_argument(\"prop:\", provided.replace('`', \"'\"))\n            }\n            SearchErrorKind::InvalidPropOperator { provided } => {\n                tr.search_invalid_prop_operator(provided.as_str())\n            }\n            SearchErrorKind::Other { info: Some(info) } => info.into(),\n            SearchErrorKind::Other { info: None } => tr.search_invalid_other(),\n            SearchErrorKind::InvalidNumber { provided, context } => {\n                tr.search_invalid_number(context.replace('`', \"'\"), provided.replace('`', \"'\"))\n            }\n\n            SearchErrorKind::InvalidWholeNumber { provided, context } => tr\n                .search_invalid_whole_number(context.replace('`', \"'\"), provided.replace('`', \"'\")),\n\n            SearchErrorKind::InvalidPositiveWholeNumber { provided, context } => tr\n                .search_invalid_positive_whole_number(\n                    context.replace('`', \"'\"),\n                    provided.replace('`', \"'\"),\n                ),\n\n            SearchErrorKind::InvalidNegativeWholeNumber { provided, context } => tr\n                .search_invalid_negative_whole_number(\n                    context.replace('`', \"'\"),\n                    provided.replace('`', \"'\"),\n                ),\n\n            SearchErrorKind::InvalidAnswerButton { provided, context } => tr\n                .search_invalid_answer_button(\n                    context.replace('`', \"'\"),\n                    provided.replace('`', \"'\"),\n                ),\n        };\n        tr.search_invalid_search(reason).into()\n    }\n}\n"
  },
  {
    "path": "rslib/src/error/windows.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse snafu::Snafu;\n\nuse super::AnkiError;\n\n#[derive(Debug, PartialEq, Snafu)]\n#[snafu(visibility(pub))]\npub struct WindowsError {\n    details: WindowsErrorDetails,\n    source: windows::core::Error,\n}\n\n#[derive(Debug, PartialEq)]\npub enum WindowsErrorDetails {\n    SettingVoice(windows::Media::SpeechSynthesis::VoiceInformation),\n    SettingRate(f32),\n    Synthesizing,\n    Other,\n}\n\nimpl From<windows::core::Error> for AnkiError {\n    fn from(source: windows::core::Error) -> Self {\n        AnkiError::WindowsError {\n            source: WindowsError {\n                source,\n                details: WindowsErrorDetails::Other,\n            },\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/findreplace.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\n\nuse regex::Regex;\n\nuse crate::collection::Collection;\nuse crate::error::Result;\nuse crate::notes::NoteId;\nuse crate::notes::TransformNoteOutput;\nuse crate::prelude::*;\nuse crate::text::normalize_to_nfc;\n\npub struct FindReplaceContext {\n    nids: Vec<NoteId>,\n    search: Regex,\n    replacement: String,\n    field_name: Option<String>,\n}\n\nenum FieldForNotetype {\n    Any,\n    Index(usize),\n    None,\n}\n\nimpl FindReplaceContext {\n    pub fn new(\n        nids: Vec<NoteId>,\n        search_re: &str,\n        repl: impl Into<String>,\n        field_name: Option<String>,\n    ) -> Result<Self> {\n        Ok(FindReplaceContext {\n            nids,\n            search: Regex::new(search_re)?,\n            replacement: repl.into(),\n            field_name,\n        })\n    }\n\n    fn replace_text<'a>(&self, text: &'a str) -> Cow<'a, str> {\n        self.search.replace_all(text, self.replacement.as_str())\n    }\n}\n\nimpl Collection {\n    pub fn find_and_replace(\n        &mut self,\n        nids: Vec<NoteId>,\n        search_re: &str,\n        repl: &str,\n        field_name: Option<String>,\n    ) -> Result<OpOutput<usize>> {\n        self.transact(Op::FindAndReplace, |col| {\n            let norm = col.get_config_bool(BoolKey::NormalizeNoteText);\n            let search = if norm {\n                normalize_to_nfc(search_re)\n            } else {\n                search_re.into()\n            };\n            let ctx = FindReplaceContext::new(nids, &search, repl, field_name)?;\n            col.find_and_replace_inner(ctx)\n        })\n    }\n\n    fn find_and_replace_inner(&mut self, ctx: FindReplaceContext) -> Result<usize> {\n        let mut last_ntid = None;\n        let mut field_for_notetype = FieldForNotetype::None;\n        self.transform_notes(&ctx.nids, |note, nt| {\n            if last_ntid != Some(nt.id) {\n                field_for_notetype = match ctx.field_name.as_ref() {\n                    None => FieldForNotetype::Any,\n                    Some(name) => match nt.get_field_ord(name) {\n                        None => FieldForNotetype::None,\n                        Some(ord) => FieldForNotetype::Index(ord),\n                    },\n                };\n                last_ntid = Some(nt.id);\n            }\n\n            let mut changed = false;\n            match field_for_notetype {\n                FieldForNotetype::Any => {\n                    for txt in note.fields_mut() {\n                        if let Cow::Owned(otxt) = ctx.replace_text(txt) {\n                            changed = true;\n                            *txt = otxt;\n                        }\n                    }\n                }\n                FieldForNotetype::Index(ord) => {\n                    if let Some(txt) = note.fields_mut().get_mut(ord) {\n                        if let Cow::Owned(otxt) = ctx.replace_text(txt) {\n                            changed = true;\n                            *txt = otxt;\n                        }\n                    }\n                }\n                FieldForNotetype::None => (),\n            }\n\n            Ok(TransformNoteOutput {\n                changed,\n                generate_cards: true,\n                mark_modified: true,\n                update_tags: false,\n            })\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::decks::DeckId;\n\n    #[test]\n    fn findreplace() -> Result<()> {\n        let mut col = Collection::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.set_field(0, \"one aaa\")?;\n        note.set_field(1, \"two aaa\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        let nt = col.get_notetype_by_name(\"Cloze\")?.unwrap();\n        let mut note2 = nt.new_note();\n        note2.set_field(0, \"three aaa\")?;\n        col.add_note(&mut note2, DeckId(1))?;\n\n        let nids = col.search_notes_unordered(\"\")?;\n        let out = col.find_and_replace(nids.clone(), \"(?i)AAA\", \"BBB\", None)?;\n        assert_eq!(out.output, 2);\n\n        let note = col.storage.get_note(note.id)?.unwrap();\n        // but the update should be limited to the specified field when it was available\n        assert_eq!(&note.fields()[..], &[\"one BBB\", \"two BBB\"]);\n\n        let note2 = col.storage.get_note(note2.id)?.unwrap();\n        assert_eq!(&note2.fields()[..], &[\"three BBB\", \"\"]);\n\n        assert_eq!(\n            col.storage.field_names_for_notes(&nids)?,\n            vec![\n                \"Back\".to_string(),\n                \"Back Extra\".into(),\n                \"Front\".into(),\n                \"Text\".into()\n            ]\n        );\n        let out = col.find_and_replace(nids, \"BBB\", \"ccc\", Some(\"Front\".into()))?;\n        // 1, because notes without the specified field should be skipped\n        assert_eq!(out.output, 1);\n\n        let note = col.storage.get_note(note.id)?.unwrap();\n        // the update should be limited to the specified field when it was available\n        assert_eq!(&note.fields()[..], &[\"one ccc\", \"two BBB\"]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/i18n/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\npub(crate) mod service;\n"
  },
  {
    "path": "rslib/src/i18n/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\n\nuse anki_i18n::I18n;\nuse anki_proto::generic;\nuse anki_proto::generic::Json;\nuse anki_proto::i18n::format_timespan_request::Context;\nuse anki_proto::i18n::FormatTimespanRequest;\nuse anki_proto::i18n::I18nResourcesRequest;\nuse anki_proto::i18n::TranslateStringRequest;\nuse fluent_bundle::FluentArgs;\nuse fluent_bundle::FluentValue;\n\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::scheduler::timespan::answer_button_time;\nuse crate::scheduler::timespan::time_span;\n\nimpl crate::services::I18nService for Collection {\n    fn translate_string(\n        &mut self,\n        input: TranslateStringRequest,\n    ) -> error::Result<generic::String> {\n        translate_string(&self.tr, input)\n    }\n\n    fn format_timespan(&mut self, input: FormatTimespanRequest) -> error::Result<generic::String> {\n        format_timespan(&self.tr, input)\n    }\n\n    fn i18n_resources(&mut self, input: I18nResourcesRequest) -> error::Result<Json> {\n        i18n_resources(&self.tr, input)\n    }\n}\n\npub(crate) fn translate_string(\n    tr: &I18n,\n    input: TranslateStringRequest,\n) -> error::Result<generic::String> {\n    let args = build_fluent_args(input.args);\n    Ok(tr\n        .translate_via_index(\n            input.module_index as usize,\n            input.message_index as usize,\n            args,\n        )\n        .into())\n}\n\npub(crate) fn format_timespan(\n    tr: &I18n,\n    input: FormatTimespanRequest,\n) -> error::Result<generic::String> {\n    Ok(match input.context() {\n        Context::Precise => time_span(input.seconds, tr, true),\n        Context::Intervals => time_span(input.seconds, tr, false),\n        Context::AnswerButtons => answer_button_time(input.seconds, tr),\n    }\n    .into())\n}\n\npub(crate) fn i18n_resources(\n    tr: &I18n,\n    input: I18nResourcesRequest,\n) -> error::Result<generic::Json> {\n    serde_json::to_vec(&tr.resources_for_js(&input.modules))\n        .map(Into::into)\n        .map_err(Into::into)\n}\n\nfn build_fluent_args(\n    input: HashMap<String, anki_proto::i18n::TranslateArgValue>,\n) -> FluentArgs<'static> {\n    let mut args = FluentArgs::new();\n    for (key, val) in input {\n        args.set(key, translate_arg_to_fluent_val(&val));\n    }\n    args\n}\n\nfn translate_arg_to_fluent_val(arg: &anki_proto::i18n::TranslateArgValue) -> FluentValue<'static> {\n    use anki_proto::i18n::translate_arg_value::Value as V;\n    match &arg.value {\n        Some(val) => match val {\n            V::Str(s) => FluentValue::String(s.to_owned().into()),\n            V::Number(f) => FluentValue::Number(f.into()),\n        },\n        None => FluentValue::String(\"\".into()),\n    }\n}\n"
  },
  {
    "path": "rslib/src/image_occlusion/imagedata.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::metadata;\nuse anki_io::read_file;\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionNote;\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::Value;\nuse anki_proto::image_occlusion::AddImageOcclusionNoteRequest;\nuse anki_proto::image_occlusion::GetImageForOcclusionResponse;\nuse anki_proto::image_occlusion::GetImageOcclusionNoteResponse;\nuse anki_proto::image_occlusion::ImageOcclusionFieldIndexes;\nuse anki_proto::notetypes::ImageOcclusionField;\nuse regex::Regex;\n\nuse crate::cloze::parse_image_occlusions;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn get_image_for_occlusion(&mut self, path: &str) -> Result<GetImageForOcclusionResponse> {\n        let mut metadata = GetImageForOcclusionResponse {\n            ..Default::default()\n        };\n        metadata.data = read_file(path)?;\n        Ok(metadata)\n    }\n\n    pub fn add_image_occlusion_note(\n        &mut self,\n        req: AddImageOcclusionNoteRequest,\n    ) -> Result<OpOutput<()>> {\n        // image file\n        let image_bytes = read_file(&req.image_path)?;\n        let image_filename = Path::new(&req.image_path)\n            .file_name()\n            .or_not_found(\"expected filename\")?\n            .to_str()\n            .unwrap()\n            .to_string();\n\n        let mgr = MediaManager::new(&self.media_folder, &self.media_db)?;\n        let actual_image_name_after_adding = mgr.add_file(&image_filename, &image_bytes)?;\n\n        let image_tag = format!(r#\"<img src=\"{}\">\"#, &actual_image_name_after_adding);\n\n        let current_deck = self.get_current_deck()?;\n        let notetype_id: NotetypeId = req.notetype_id.into();\n        self.transact(Op::ImageOcclusion, |col| {\n            let nt = if notetype_id.0 == 0 {\n                // when testing via .html page, use first available notetype\n                col.add_image_occlusion_notetype_inner()?;\n                col.get_first_io_notetype()?\n                    .or_invalid(\"expected an i/o notetype to exist\")?\n            } else {\n                col.get_io_notetype_by_id(notetype_id)?\n            };\n\n            let mut note = nt.new_note();\n            let idxs = nt.get_io_field_indexes()?;\n            note.set_field(idxs.occlusions as usize, req.occlusions)?;\n            note.set_field(idxs.image as usize, image_tag)?;\n            note.set_field(idxs.header as usize, req.header)?;\n            note.set_field(idxs.back_extra as usize, req.back_extra)?;\n            note.tags = req.tags;\n            col.add_note_inner(&mut note, current_deck.id)?;\n\n            Ok(())\n        })\n    }\n\n    pub fn get_image_occlusion_note(\n        &mut self,\n        note_id: NoteId,\n    ) -> Result<GetImageOcclusionNoteResponse> {\n        let value = match self.get_image_occlusion_note_inner(note_id) {\n            Ok(note) => Value::Note(note),\n            Err(err) => Value::Error(format!(\"{err:?}\")),\n        };\n        Ok(GetImageOcclusionNoteResponse { value: Some(value) })\n    }\n\n    pub fn get_image_occlusion_note_inner(\n        &mut self,\n        note_id: NoteId,\n    ) -> Result<ImageOcclusionNote> {\n        let note = self.storage.get_note(note_id)?.or_not_found(note_id)?;\n        let mut cloze_note = ImageOcclusionNote::default();\n\n        let fields = note.fields();\n\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_not_found(note.notetype_id)?;\n        let idxs = nt.get_io_field_indexes()?;\n\n        cloze_note.occlusions = parse_image_occlusions(fields[idxs.occlusions as usize].as_str());\n        cloze_note.occlude_inactive = cloze_note.occlusions.iter().any(|oc| {\n            oc.shapes.iter().any(|sh| {\n                sh.properties\n                    .iter()\n                    .find(|p| p.name == \"oi\")\n                    .is_some_and(|p| p.value == \"1\")\n            })\n        });\n        cloze_note.header.clone_from(&fields[idxs.header as usize]);\n        cloze_note\n            .back_extra\n            .clone_from(&fields[idxs.back_extra as usize]);\n        cloze_note.image_data = \"\".into();\n        cloze_note.tags.clone_from(&note.tags);\n\n        let image_file_name = &fields[idxs.image as usize];\n        let src = self\n            .extract_img_src(image_file_name)\n            .unwrap_or_else(|| \"\".to_owned());\n        let final_path = self.media_folder.join(src);\n\n        if self.is_image_file(&final_path)? {\n            cloze_note.image_data = read_file(&final_path)?;\n            cloze_note.image_file_name = final_path\n                .file_name()\n                .or_not_found(\"expected filename\")?\n                .to_str()\n                .unwrap()\n                .to_string();\n        }\n\n        Ok(cloze_note)\n    }\n\n    pub fn update_image_occlusion_note(\n        &mut self,\n        note_id: NoteId,\n        occlusions: &str,\n        header: &str,\n        back_extra: &str,\n        tags: Vec<String>,\n    ) -> Result<OpOutput<()>> {\n        let mut note = self.storage.get_note(note_id)?.or_not_found(note_id)?;\n        self.transact(Op::ImageOcclusion, |col| {\n            let nt = col\n                .get_notetype(note.notetype_id)?\n                .or_not_found(note.notetype_id)?;\n            let idxs = nt.get_io_field_indexes()?;\n            note.set_field(idxs.occlusions as usize, occlusions)?;\n            note.set_field(idxs.header as usize, header)?;\n            note.set_field(idxs.back_extra as usize, back_extra)?;\n            note.tags = tags;\n            col.update_note_inner(&mut note)?;\n            Ok(())\n        })\n    }\n\n    fn extract_img_src(&mut self, html: &str) -> Option<String> {\n        let re = Regex::new(r#\"<img\\s+[^>]*src\\s*=\\s*\"([^\"]+)\"#).unwrap();\n        re.captures(html).map(|cap| cap[1].to_owned())\n    }\n\n    fn is_image_file(&mut self, path: &PathBuf) -> Result<bool> {\n        let file_path = Path::new(&path);\n        let supported_extensions = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"ico\", \"avif\"];\n\n        if file_path.exists() {\n            let meta = metadata(file_path)?;\n            if meta.is_file() {\n                if let Some(ext_osstr) = file_path.extension() {\n                    if let Some(ext_str) = ext_osstr.to_str() {\n                        if supported_extensions.contains(&ext_str.to_lowercase().as_str()) {\n                            return Ok(true);\n                        }\n                    }\n                }\n            }\n        }\n\n        Ok(false)\n    }\n}\n\nimpl Notetype {\n    pub(crate) fn get_io_field_indexes(&self) -> Result<ImageOcclusionFieldIndexes> {\n        get_field_indexes_by_tag(self).or_else(|_| {\n            if self.fields.len() < 4 {\n                return Err(AnkiError::DatabaseCheckRequired);\n            }\n            Ok(ImageOcclusionFieldIndexes {\n                occlusions: 0,\n                image: 1,\n                header: 2,\n                back_extra: 3,\n            })\n        })\n    }\n}\n\nfn get_field_indexes_by_tag(nt: &Notetype) -> Result<ImageOcclusionFieldIndexes> {\n    Ok(ImageOcclusionFieldIndexes {\n        occlusions: get_field_index(nt, ImageOcclusionField::Occlusions)?,\n        image: get_field_index(nt, ImageOcclusionField::Image)?,\n        header: get_field_index(nt, ImageOcclusionField::Header)?,\n        back_extra: get_field_index(nt, ImageOcclusionField::BackExtra)?,\n    })\n}\n\nfn get_field_index(nt: &Notetype, field: ImageOcclusionField) -> Result<u32> {\n    nt.fields\n        .iter()\n        .enumerate()\n        .find(|(_idx, f)| f.config.tag == Some(field as u32))\n        .map(|(idx, _)| idx as u32)\n        .ok_or(AnkiError::DatabaseCheckRequired)\n}\n"
  },
  {
    "path": "rslib/src/image_occlusion/imageocclusion.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Write;\n\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionProperty;\nuse anki_proto::image_occlusion::get_image_occlusion_note_response::ImageOcclusionShape;\nuse htmlescape::encode_attribute;\nuse nom::bytes::complete::escaped;\nuse nom::bytes::complete::is_not;\nuse nom::bytes::complete::tag;\nuse nom::character::complete::char;\nuse nom::error::ErrorKind;\nuse nom::sequence::preceded;\nuse nom::sequence::separated_pair;\nuse nom::Parser;\n\nfn unescape(text: &str) -> String {\n    text.replace(\"\\\\:\", \":\")\n}\n\npub fn parse_image_cloze(text: &str) -> Option<ImageOcclusionShape> {\n    if let Some((shape, _)) = text.split_once(':') {\n        let mut properties = vec![];\n        let mut remaining = &text[shape.len()..];\n        while let Ok((rem, (name, value))) = separated_pair::<_, _, _, (_, ErrorKind), _, _, _>(\n            preceded(tag(\":\"), is_not(\"=\")),\n            tag(\"=\"),\n            escaped(is_not(\"\\\\:\"), '\\\\', char(':')),\n        )\n        .parse(remaining)\n        {\n            remaining = rem;\n            let value = unescape(value);\n            properties.push(ImageOcclusionProperty {\n                name: name.to_string(),\n                value,\n            })\n        }\n\n        return Some(ImageOcclusionShape {\n            shape: shape.to_string(),\n            properties,\n        });\n    }\n\n    None\n}\n\n// convert text like\n// rect:left=.2325:top=.3261:width=.202:height=.0975\n// to something like\n// result = \"data-shape=\"rect\" data-left=\"399.01\" data-top=\"99.52\"\n// data-width=\"167.09\" data-height=\"33.78\"\npub fn get_image_cloze_data(text: &str) -> String {\n    let mut result = String::new();\n\n    if let Some(occlusion) = parse_image_cloze(text) {\n        if !occlusion.shape.is_empty()\n            && matches!(\n                occlusion.shape.as_str(),\n                \"rect\" | \"ellipse\" | \"polygon\" | \"text\"\n            )\n        {\n            result.push_str(&format!(\"data-shape=\\\"{}\\\" \", occlusion.shape));\n        }\n        for property in occlusion.properties {\n            match property.name.as_str() {\n                \"left\" | \"top\" | \"angle\" | \"fill\" => {\n                    if !property.value.is_empty() {\n                        result.push_str(&format!(\"data-{}=\\\"{}\\\" \", property.name, property.value));\n                    }\n                }\n                \"width\" => {\n                    if !is_empty_or_zero(&property.value) {\n                        result.push_str(&format!(\"data-width=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"height\" => {\n                    if !is_empty_or_zero(&property.value) {\n                        result.push_str(&format!(\"data-height=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"rx\" => {\n                    if !is_empty_or_zero(&property.value) {\n                        result.push_str(&format!(\"data-rx=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"ry\" => {\n                    if !is_empty_or_zero(&property.value) {\n                        result.push_str(&format!(\"data-ry=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"points\" => {\n                    if !property.value.is_empty() {\n                        let mut point_str = String::new();\n                        for point_pair in property.value.split(' ') {\n                            let Some((x, y)) = point_pair.split_once(',') else {\n                                continue;\n                            };\n                            write!(&mut point_str, \"{x},{y} \").unwrap();\n                        }\n                        // remove the trailing space\n                        point_str.pop();\n                        if !point_str.is_empty() {\n                            result.push_str(&format!(\"data-points=\\\"{point_str}\\\" \"));\n                        }\n                    }\n                }\n                \"oi\" => {\n                    if !property.value.is_empty() {\n                        result.push_str(&format!(\"data-occludeInactive=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"text\" => {\n                    if !property.value.is_empty() {\n                        result.push_str(&format!(\n                            \"data-text=\\\"{}\\\" \",\n                            encode_attribute(&property.value)\n                        ));\n                    }\n                }\n                \"scale\" => {\n                    if !is_empty_or_zero(&property.value) {\n                        result.push_str(&format!(\"data-scale=\\\"{}\\\" \", property.value));\n                    }\n                }\n                \"fs\" => {\n                    if !property.value.is_empty() {\n                        result.push_str(&format!(\"data-font-size=\\\"{}\\\" \", property.value));\n                    }\n                }\n                _ => {}\n            }\n        }\n    }\n\n    result\n}\n\nfn is_empty_or_zero(text: &str) -> bool {\n    text.is_empty() || text == \"0\"\n}\n\n//----------------------------------------\n// Tests\n//----------------------------------------\n\n#[test]\nfn test_get_image_cloze_data() {\n    assert_eq!(\n        get_image_cloze_data(\"rect:left=10:top=20:width=30:height=10\"),\n        format!(\n            r#\"data-shape=\"rect\" data-left=\"10\" data-top=\"20\" data-width=\"30\" data-height=\"10\" \"#,\n        )\n    );\n    assert_eq!(\n        get_image_cloze_data(\"ellipse:left=15:top=20:width=10:height=20:rx=10:ry=5\"),\n        r#\"data-shape=\"ellipse\" data-left=\"15\" data-top=\"20\" data-width=\"10\" data-height=\"20\" data-rx=\"10\" data-ry=\"5\" \"#,\n    );\n    assert_eq!(\n        get_image_cloze_data(\"polygon:points=0,0 10,10 20,0\"),\n        r#\"data-shape=\"polygon\" data-points=\"0,0 10,10 20,0\" \"#,\n    );\n    assert_eq!(\n        get_image_cloze_data(\"text:text=foo\\\\:bar:left=10\"),\n        r#\"data-shape=\"text\" data-text=\"foo&#x3A;bar\" data-left=\"10\" \"#,\n    );\n}\n"
  },
  {
    "path": "rslib/src/image_occlusion/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod imagedata;\npub mod imageocclusion;\npub(crate) mod notetype;\nmod service;\n"
  },
  {
    "path": "rslib/src/image_occlusion/notetype.css",
    "content": "#image-occlusion-canvas {\n    --inactive-shape-color: #ffeba2;\n    --active-shape-color: #ff8e8e;\n    --inactive-shape-border: 1px #212121;\n    --active-shape-border: 1px #212121;\n    --highlight-shape-color: #ff8e8e00;\n    --highlight-shape-border: 1px #ff8e8e;\n}\n\n.card {\n    font-family: arial;\n    font-size: 20px;\n    text-align: center;\n    color: black;\n    background-color: white;\n}\n"
  },
  {
    "path": "rslib/src/image_occlusion/notetype.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::Arc;\n\nuse anki_proto::notetypes::stock_notetype::OriginalStockKind;\nuse anki_proto::notetypes::ImageOcclusionField;\n\nuse crate::notetype::stock::empty_stock;\nuse crate::notetype::Notetype;\nuse crate::notetype::NotetypeKind;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn add_image_occlusion_notetype(&mut self) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateNotetype, |col| {\n            col.add_image_occlusion_notetype_inner()\n        })\n    }\n\n    pub fn add_image_occlusion_notetype_inner(&mut self) -> Result<()> {\n        if self.get_first_io_notetype()?.is_none() {\n            let mut nt = image_occlusion_notetype(&self.tr);\n            let usn = self.usn()?;\n            nt.set_modified(usn);\n            let current_id = self.get_current_notetype_id();\n            self.add_notetype_inner(&mut nt, usn, false)?;\n            if let Some(current_id) = current_id {\n                // preserve previous default\n                self.set_current_notetype_id(current_id)?;\n            }\n        }\n        Ok(())\n    }\n\n    /// Returns the I/O notetype with the provided id, checking to make sure it\n    /// is valid.\n    pub(crate) fn get_io_notetype_by_id(\n        &mut self,\n        notetype_id: NotetypeId,\n    ) -> Result<Arc<Notetype>> {\n        let nt = self.get_notetype(notetype_id)?.or_not_found(notetype_id)?;\n        io_notetype_if_valid(nt)\n    }\n\n    pub(crate) fn get_first_io_notetype(&mut self) -> Result<Option<Arc<Notetype>>> {\n        for nt in self.get_all_notetypes()? {\n            if nt.config.original_stock_kind() == OriginalStockKind::ImageOcclusion {\n                if let Ok(nt) = io_notetype_if_valid(nt) {\n                    return Ok(Some(nt));\n                }\n            }\n        }\n\n        Ok(None)\n    }\n}\n\npub(crate) fn image_occlusion_notetype(tr: &I18n) -> Notetype {\n    const IMAGE_CLOZE_CSS: &str = include_str!(\"notetype.css\");\n    let mut nt = empty_stock(\n        NotetypeKind::Cloze,\n        OriginalStockKind::ImageOcclusion,\n        tr.notetypes_image_occlusion_name(),\n    );\n    nt.config.css = IMAGE_CLOZE_CSS.to_string();\n\n    let occlusion = tr.notetypes_occlusion();\n    let mut config = nt.add_field(occlusion.as_ref());\n    config.tag = Some(ImageOcclusionField::Occlusions as u32);\n    config.prevent_deletion = true;\n\n    let image = tr.notetypes_image();\n    config = nt.add_field(image.as_ref());\n    config.tag = Some(ImageOcclusionField::Image as u32);\n    config.prevent_deletion = true;\n\n    let header = tr.notetypes_header();\n    config = nt.add_field(header.as_ref());\n    config.tag = Some(ImageOcclusionField::Header as u32);\n    config.prevent_deletion = true;\n\n    let back_extra = tr.notetypes_back_extra_field();\n    config = nt.add_field(back_extra.as_ref());\n    config.tag = Some(ImageOcclusionField::BackExtra as u32);\n    config.prevent_deletion = true;\n\n    let comments = tr.notetypes_comments_field();\n    config = nt.add_field(comments.as_ref());\n    config.tag = Some(ImageOcclusionField::Comments as u32);\n    config.prevent_deletion = false;\n\n    let err_loading = tr.notetypes_error_loading_image_occlusion();\n    let qfmt = format!(\n        r#\"{{{{#{header}}}}}<div>{{{{{header}}}}}</div>{{{{/{header}}}}}\n<div style=\"display: none\">{{{{cloze:{occlusion}}}}}</div>\n<div id=\"err\"></div>\n<div id=\"image-occlusion-container\">\n    {{{{{image}}}}}\n    <canvas id=\"image-occlusion-canvas\"></canvas>\n</div>\n<script>\ntry {{\n    anki.imageOcclusion.setup();\n}} catch (exc) {{\n    document.getElementById(\"err\").innerHTML = `{err_loading}<br><br>${{exc}}`;\n}}\n</script>\n\"#\n    );\n\n    let toggle_masks = tr.notetypes_toggle_masks();\n    let afmt = format!(\n        r#\"{qfmt}\n<div><button id=\"toggle\">{toggle_masks}</button></div>\n{{{{#{back_extra}}}}}<div>{{{{{back_extra}}}}}</div>{{{{/{back_extra}}}}}\n\"#,\n    );\n    nt.add_template(nt.name.clone(), qfmt, afmt);\n    nt\n}\n\nfn io_notetype_if_valid(nt: Arc<Notetype>) -> Result<Arc<Notetype>> {\n    if nt.config.original_stock_kind() != OriginalStockKind::ImageOcclusion {\n        invalid_input!(\"Not an image occlusion notetype\");\n    }\n    if nt.fields.len() < 4 {\n        return Err(AnkiError::TemplateError {\n            info: \"IO notetype must have 4+ fields\".to_string(),\n        });\n    }\n    Ok(nt)\n}\n"
  },
  {
    "path": "rslib/src/image_occlusion/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::image_occlusion::AddImageOcclusionNoteRequest;\nuse anki_proto::image_occlusion::GetImageForOcclusionRequest;\nuse anki_proto::image_occlusion::GetImageForOcclusionResponse;\nuse anki_proto::image_occlusion::GetImageOcclusionFieldsRequest;\nuse anki_proto::image_occlusion::GetImageOcclusionFieldsResponse;\nuse anki_proto::image_occlusion::GetImageOcclusionNoteRequest;\nuse anki_proto::image_occlusion::GetImageOcclusionNoteResponse;\nuse anki_proto::image_occlusion::UpdateImageOcclusionNoteRequest;\n\nuse crate::collection::Collection;\nuse crate::error::Result;\nuse crate::prelude::*;\n\nimpl crate::services::ImageOcclusionService for Collection {\n    fn get_image_for_occlusion(\n        &mut self,\n        input: GetImageForOcclusionRequest,\n    ) -> Result<GetImageForOcclusionResponse> {\n        self.get_image_for_occlusion(&input.path)\n    }\n\n    fn add_image_occlusion_note(\n        &mut self,\n        input: AddImageOcclusionNoteRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.add_image_occlusion_note(input).map(Into::into)\n    }\n\n    fn get_image_occlusion_note(\n        &mut self,\n        input: GetImageOcclusionNoteRequest,\n    ) -> Result<GetImageOcclusionNoteResponse> {\n        self.get_image_occlusion_note(input.note_id.into())\n    }\n\n    fn update_image_occlusion_note(\n        &mut self,\n        input: UpdateImageOcclusionNoteRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.update_image_occlusion_note(\n            input.note_id.into(),\n            &input.occlusions,\n            &input.header,\n            &input.back_extra,\n            input.tags,\n        )\n        .map(Into::into)\n    }\n\n    fn add_image_occlusion_notetype(&mut self) -> Result<anki_proto::collection::OpChanges> {\n        self.add_image_occlusion_notetype().map(Into::into)\n    }\n\n    fn get_image_occlusion_fields(\n        &mut self,\n        input: GetImageOcclusionFieldsRequest,\n    ) -> Result<GetImageOcclusionFieldsResponse> {\n        let ntid = NotetypeId::from(input.notetype_id);\n        let nt = self.get_notetype(ntid)?.or_not_found(ntid)?;\n        Ok(GetImageOcclusionFieldsResponse {\n            fields: Some(nt.get_io_field_indexes()?),\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/gather.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse anki_io::filename_is_safe;\nuse itertools::Itertools;\n\nuse super::ExportProgress;\nuse crate::decks::immediate_parent_name;\nuse crate::decks::NormalDeck;\nuse crate::latex::extract_latex;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::revlog::RevlogEntry;\nuse crate::search::CardTableGuard;\nuse crate::search::NoteTableGuard;\nuse crate::text::extract_media_refs;\n\n#[derive(Debug, Default)]\npub(super) struct ExchangeData {\n    pub(super) decks: Vec<Deck>,\n    pub(super) notes: Vec<Note>,\n    pub(super) cards: Vec<Card>,\n    pub(super) notetypes: Vec<Notetype>,\n    pub(super) revlog: Vec<RevlogEntry>,\n    pub(super) deck_configs: Vec<DeckConfig>,\n    pub(super) media_filenames: HashSet<String>,\n    pub(super) days_elapsed: u32,\n    pub(super) creation_utc_offset: Option<i32>,\n}\n\nimpl ExchangeData {\n    pub(super) fn gather_data(\n        &mut self,\n        col: &mut Collection,\n        search: impl TryIntoSearch,\n        with_scheduling: bool,\n        with_deck_configs: bool,\n    ) -> Result<()> {\n        self.days_elapsed = col.timing_today()?.days_elapsed;\n        self.creation_utc_offset = col.get_creation_utc_offset();\n        let (notes, guard) = col.gather_notes(search)?;\n        self.notes = notes;\n        let (cards, guard) = guard.col.gather_cards()?;\n        self.cards = cards;\n        self.decks = guard.col.gather_decks(with_scheduling, !with_scheduling)?;\n        self.notetypes = guard.col.gather_notetypes()?;\n\n        let allow_filtered = self.enables_filtered_decks();\n\n        if with_scheduling {\n            self.revlog = guard.col.gather_revlog()?;\n            if !allow_filtered {\n                self.restore_cards_from_filtered_decks();\n            }\n        } else {\n            self.reset_cards_and_notes(guard.col);\n        };\n\n        if with_deck_configs {\n            self.deck_configs = guard.col.gather_deck_configs(&self.decks)?;\n        }\n        self.reset_decks(!with_deck_configs, !with_scheduling, allow_filtered);\n\n        self.check_ids()\n    }\n\n    pub(super) fn gather_media_names(\n        &mut self,\n        progress: &mut ThrottlingProgressHandler<ExportProgress>,\n    ) -> Result<()> {\n        let mut inserter = |name: String| {\n            if filename_is_safe(&name) {\n                self.media_filenames.insert(name);\n            }\n        };\n        let mut progress = progress.incrementor(ExportProgress::Notes);\n        let svg_getter = svg_getter(&self.notetypes);\n        for note in self.notes.iter() {\n            progress.increment()?;\n            gather_media_names_from_note(note, &mut inserter, &svg_getter);\n        }\n        for notetype in self.notetypes.iter() {\n            notetype.gather_media_names(&mut inserter);\n        }\n        Ok(())\n    }\n\n    fn reset_cards_and_notes(&mut self, col: &Collection) {\n        self.remove_system_tags();\n        self.reset_cards(col);\n    }\n\n    fn remove_system_tags(&mut self) {\n        const SYSTEM_TAGS: [&str; 2] = [\"marked\", \"leech\"];\n        for note in self.notes.iter_mut() {\n            note.tags = std::mem::take(&mut note.tags)\n                .into_iter()\n                .filter(|tag| !SYSTEM_TAGS.iter().any(|s| tag.eq_ignore_ascii_case(s)))\n                .collect();\n        }\n    }\n\n    fn reset_decks(\n        &mut self,\n        reset_config_ids: bool,\n        reset_study_info: bool,\n        allow_filtered: bool,\n    ) {\n        for deck in self.decks.iter_mut() {\n            if reset_study_info {\n                deck.common = Default::default();\n            }\n            match &mut deck.kind {\n                DeckKind::Normal(normal) => {\n                    if reset_config_ids {\n                        normal.config_id = 1;\n                    }\n                    if reset_study_info {\n                        normal.extend_new = 0;\n                        normal.extend_review = 0;\n                        normal.review_limit = None;\n                        normal.review_limit_today = None;\n                        normal.new_limit = None;\n                        normal.new_limit_today = None;\n                    }\n                }\n                DeckKind::Filtered(_) if reset_study_info || !allow_filtered => {\n                    deck.kind = DeckKind::Normal(NormalDeck {\n                        config_id: 1,\n                        ..Default::default()\n                    })\n                }\n                DeckKind::Filtered(_) => (),\n            }\n        }\n    }\n\n    /// Because the legacy exporter relied on the importer handling filtered\n    /// decks by converting them into regular ones, there are two scenarios to\n    /// watch out for:\n    /// 1. If exported without scheduling, cards have been reset, but their deck\n    ///    ids may point to filtered decks.\n    /// 2. If exported with scheduling, cards have not been reset, but their\n    ///    original deck ids may point to missing decks.\n    fn enables_filtered_decks(&self) -> bool {\n        self.cards\n            .iter()\n            .all(|c| self.card_and_its_deck_are_normal(c) || self.original_deck_exists(c))\n    }\n\n    fn card_and_its_deck_are_normal(&self, card: &Card) -> bool {\n        card.original_deck_id.0 == 0\n            && self\n                .decks\n                .iter()\n                .find(|d| d.id == card.deck_id)\n                .map(|d| !d.is_filtered())\n                .unwrap_or_default()\n    }\n\n    fn original_deck_exists(&self, card: &Card) -> bool {\n        card.original_deck_id.0 == 1 || self.decks.iter().any(|d| d.id == card.original_deck_id)\n    }\n\n    fn reset_cards(&mut self, col: &Collection) {\n        let mut position = col.get_next_card_position();\n        for card in self.cards.iter_mut() {\n            // schedule_as_new() removes cards from filtered decks, but we want to\n            // leave cards in their current deck, which gets converted to a regular one\n            let deck_id = card.deck_id;\n            if card.schedule_as_new(position, true, true) {\n                position += 1;\n            }\n            card.flags = 0;\n            card.deck_id = deck_id;\n        }\n    }\n\n    fn restore_cards_from_filtered_decks(&mut self) {\n        for card in self.cards.iter_mut() {\n            if card.is_filtered() {\n                // instead of moving between decks, the deck is converted to a regular one\n                card.original_deck_id = card.deck_id;\n                card.remove_from_filtered_deck_restoring_queue();\n            }\n        }\n    }\n\n    fn check_ids(&self) -> Result<()> {\n        let tomorrow = TimestampMillis::now().adding_secs(86_400).0;\n        if self\n            .cards\n            .iter()\n            .map(|card| card.id.0)\n            .chain(self.notes.iter().map(|note| note.id.0))\n            .chain(self.revlog.iter().map(|entry| entry.id.0))\n            .any(|timestamp| timestamp > tomorrow)\n        {\n            Err(AnkiError::InvalidId)\n        } else {\n            Ok(())\n        }\n    }\n}\n\nfn gather_media_names_from_note(\n    note: &Note,\n    inserter: &mut impl FnMut(String),\n    svg_getter: &impl Fn(NotetypeId) -> bool,\n) {\n    for field in note.fields() {\n        for media_ref in extract_media_refs(field) {\n            inserter(media_ref.fname_decoded.to_string());\n        }\n\n        for latex in extract_latex(field, svg_getter(note.notetype_id)).1 {\n            inserter(latex.fname);\n        }\n    }\n}\n\nfn svg_getter(notetypes: &[Notetype]) -> impl Fn(NotetypeId) -> bool {\n    let svg_map: HashMap<NotetypeId, bool> = notetypes\n        .iter()\n        .map(|nt| (nt.id, nt.config.latex_svg))\n        .collect();\n    move |nt_id| svg_map.get(&nt_id).copied().unwrap_or_default()\n}\n\nimpl Collection {\n    fn gather_notes(\n        &mut self,\n        search: impl TryIntoSearch,\n    ) -> Result<(Vec<Note>, NoteTableGuard<'_>)> {\n        let guard = self.search_notes_into_table(search)?;\n        guard\n            .col\n            .storage\n            .all_searched_notes()\n            .map(|notes| (notes, guard))\n    }\n\n    fn gather_cards(&mut self) -> Result<(Vec<Card>, CardTableGuard<'_>)> {\n        let guard = self.search_cards_of_notes_into_table()?;\n        guard\n            .col\n            .storage\n            .all_searched_cards()\n            .map(|cards| (cards, guard))\n    }\n\n    /// If with_original, also gather all original decks of cards in filtered\n    /// decks, so they don't have to be converted to regular decks on import.\n    /// If skip_default, skip exporting the default deck to avoid\n    /// changing the importing client's defaults.\n    fn gather_decks(&mut self, with_original: bool, skip_default: bool) -> Result<Vec<Deck>> {\n        let decks = if with_original {\n            self.storage.get_decks_and_original_for_search_cards()\n        } else {\n            self.storage.get_decks_for_search_cards()\n        }?;\n        let parents = self.get_parent_decks(&decks)?;\n        Ok(decks\n            .into_iter()\n            .chain(parents)\n            .filter(|deck| !(skip_default && deck.id.0 == 1))\n            .collect())\n    }\n\n    fn get_parent_decks(&mut self, decks: &[Deck]) -> Result<Vec<Deck>> {\n        let mut parent_names: HashSet<String> = decks\n            .iter()\n            .map(|deck| deck.name.as_native_str().to_owned())\n            .collect();\n        let mut parents = Vec::new();\n        for deck in decks {\n            self.add_parent_decks(deck.name.as_native_str(), &mut parent_names, &mut parents)?;\n        }\n        Ok(parents)\n    }\n\n    fn add_parent_decks(\n        &mut self,\n        name: &str,\n        parent_names: &mut HashSet<String>,\n        parents: &mut Vec<Deck>,\n    ) -> Result<()> {\n        if let Some(parent_name) = immediate_parent_name(name) {\n            if parent_names.insert(parent_name.to_owned()) {\n                if let Some(parent) = self.storage.get_deck_by_name(parent_name)? {\n                    parents.push(parent);\n                    self.add_parent_decks(parent_name, parent_names, parents)?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    fn gather_notetypes(&mut self) -> Result<Vec<Notetype>> {\n        self.storage.get_notetypes_for_search_notes()\n    }\n\n    fn gather_revlog(&mut self) -> Result<Vec<RevlogEntry>> {\n        self.storage.get_revlog_entries_for_searched_cards()\n    }\n\n    fn gather_deck_configs(&mut self, decks: &[Deck]) -> Result<Vec<DeckConfig>> {\n        decks\n            .iter()\n            .filter_map(|deck| deck.config_id())\n            .unique()\n            .map(|config_id| {\n                self.storage\n                    .get_deck_config(config_id)?\n                    .or_not_found(config_id)\n            })\n            .collect()\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::search::SearchNode;\n\n    #[test]\n    fn should_gather_valid_notes() {\n        let mut data = ExchangeData::default();\n        let mut col = Collection::new();\n\n        let note = NoteAdder::basic(&mut col).add(&mut col);\n        data.gather_data(&mut col, SearchNode::WholeCollection, true, true)\n            .unwrap();\n\n        assert_eq!(data.notes, [note]);\n    }\n\n    #[test]\n    fn should_err_if_note_has_invalid_id() {\n        let mut data = ExchangeData::default();\n        let mut col = Collection::new();\n        let now_micros = TimestampMillis::now().0 * 1000;\n\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.id = NoteId(now_micros);\n        col.add_note_only_with_id_undoable(&mut note).unwrap();\n\n        assert!(data\n            .gather_data(&mut col, SearchNode::WholeCollection, true, true)\n            .is_err());\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/insert.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::gather::ExchangeData;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\n\nimpl Collection {\n    pub(super) fn insert_data(&mut self, data: &ExchangeData) -> Result<()> {\n        self.transact_no_undo(|col| {\n            col.insert_decks(&data.decks)?;\n            col.insert_notes(&data.notes)?;\n            col.insert_cards(&data.cards)?;\n            col.insert_notetypes(&data.notetypes)?;\n            col.insert_revlog(&data.revlog)?;\n            col.insert_deck_configs(&data.deck_configs)\n        })\n    }\n\n    fn insert_decks(&self, decks: &[Deck]) -> Result<()> {\n        for deck in decks {\n            self.storage.add_or_update_deck_with_existing_id(deck)?;\n        }\n        Ok(())\n    }\n\n    fn insert_notes(&self, notes: &[Note]) -> Result<()> {\n        for note in notes {\n            self.storage.add_or_update_note(note)?;\n        }\n        Ok(())\n    }\n\n    fn insert_cards(&self, cards: &[Card]) -> Result<()> {\n        for card in cards {\n            self.storage.add_or_update_card(card)?;\n        }\n        Ok(())\n    }\n\n    fn insert_notetypes(&self, notetypes: &[Notetype]) -> Result<()> {\n        for notetype in notetypes {\n            self.storage\n                .add_or_update_notetype_with_existing_id(notetype)?;\n        }\n        Ok(())\n    }\n\n    fn insert_revlog(&self, revlog: &[RevlogEntry]) -> Result<()> {\n        for entry in revlog {\n            self.storage.add_revlog_entry(entry, false)?;\n        }\n        Ok(())\n    }\n\n    fn insert_deck_configs(&self, configs: &[DeckConfig]) -> Result<()> {\n        for config in configs {\n            self.storage\n                .add_or_update_deck_config_with_existing_id(config)?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod gather;\nmod insert;\npub mod package;\nmod service;\npub mod text;\n\npub use anki_proto::import_export::import_response::Log as NoteLog;\npub use anki_proto::import_export::import_response::Note as LogNote;\nuse snafu::Snafu;\n\nuse crate::prelude::*;\nuse crate::text::newlines_to_spaces;\nuse crate::text::strip_html_preserving_media_filenames;\nuse crate::text::truncate_to_char_boundary;\nuse crate::text::CowMapping;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum ImportProgress {\n    #[default]\n    Extracting,\n    File,\n    Gathering,\n    Media(usize),\n    MediaCheck(usize),\n    Notes(usize),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum ExportProgress {\n    #[default]\n    File,\n    Gathering,\n    Notes(usize),\n    Cards(usize),\n    Media(usize),\n}\n\nimpl Note {\n    pub(crate) fn into_log_note(self) -> LogNote {\n        LogNote {\n            id: Some(anki_proto::notes::NoteId { nid: self.id.0 }),\n            fields: self\n                .into_fields()\n                .into_iter()\n                .map(|field| {\n                    let mut reduced = strip_html_preserving_media_filenames(&field)\n                        .map_cow(newlines_to_spaces)\n                        .get_owned()\n                        .unwrap_or(field);\n                    truncate_to_char_boundary(&mut reduced, 80);\n                    reduced\n                })\n                .collect(),\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Clone, Snafu)]\npub enum ImportError {\n    Corrupt,\n    TooNew,\n    MediaImportFailed {\n        info: String,\n    },\n    NoFieldColumn,\n    EmptyFile,\n    /// Two notetypes could not be merged because one was a regular one and the\n    /// other one a cloze notetype.\n    NotetypeKindMergeConflict,\n}\n\nimpl ImportError {\n    pub(crate) fn message(&self, tr: &I18n) -> String {\n        match self {\n            ImportError::Corrupt => tr.importing_the_provided_file_is_not_a(),\n            ImportError::TooNew => tr.errors_collection_too_new(),\n            ImportError::MediaImportFailed { info } => {\n                tr.importing_failed_to_import_media_file(info)\n            }\n            ImportError::NoFieldColumn => tr.importing_file_must_contain_field_column(),\n            ImportError::EmptyFile => tr.importing_file_empty(),\n            ImportError::NotetypeKindMergeConflict => {\n                tr.importing_cannot_merge_notetypes_of_different_kinds()\n            }\n        }\n        .into()\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/export.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::atomic_rename;\nuse anki_io::new_tempfile;\nuse anki_io::new_tempfile_in_parent_of;\n\nuse super::super::meta::MetaExt;\nuse crate::collection::CollectionBuilder;\nuse crate::import_export::gather::ExchangeData;\nuse crate::import_export::package::colpkg::export::export_collection;\nuse crate::import_export::package::media::MediaIter;\nuse crate::import_export::package::ExportAnkiPackageOptions;\nuse crate::import_export::package::Meta;\nuse crate::import_export::ExportProgress;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\n\nimpl Collection {\n    /// Returns number of exported notes.\n    pub fn export_apkg(\n        &mut self,\n        out_path: impl AsRef<Path>,\n        options: ExportAnkiPackageOptions,\n        search: impl TryIntoSearch,\n        media_fn: Option<Box<dyn FnOnce(HashSet<String>) -> MediaIter>>,\n    ) -> Result<usize> {\n        let mut progress = self.new_progress_handler();\n        let temp_apkg = new_tempfile_in_parent_of(out_path.as_ref())?;\n        let mut temp_col = new_tempfile()?;\n        let temp_col_path = temp_col\n            .path()\n            .to_str()\n            .or_invalid(\"non-unicode filename\")?;\n        let meta = if options.legacy {\n            Meta::new_legacy()\n        } else {\n            Meta::new()\n        };\n        let data =\n            self.export_into_collection_file(&meta, temp_col_path, options, search, &mut progress)?;\n\n        progress.set(ExportProgress::File)?;\n        let media = if let Some(media_fn) = media_fn {\n            media_fn(data.media_filenames)\n        } else {\n            MediaIter::from_file_list(data.media_filenames, self.media_folder.clone())\n        };\n        let col_size = temp_col.as_file().metadata()?.len() as usize;\n\n        export_collection(\n            meta,\n            temp_apkg.path(),\n            &mut temp_col,\n            col_size,\n            media,\n            &self.tr,\n            &mut progress,\n        )?;\n        atomic_rename(temp_apkg, out_path.as_ref(), true)?;\n        Ok(data.notes.len())\n    }\n\n    fn export_into_collection_file(\n        &mut self,\n        meta: &Meta,\n        path: &str,\n        options: ExportAnkiPackageOptions,\n        search: impl TryIntoSearch,\n        progress: &mut ThrottlingProgressHandler<ExportProgress>,\n    ) -> Result<ExchangeData> {\n        let mut data = ExchangeData::default();\n        progress.set(ExportProgress::Gathering)?;\n        data.gather_data(\n            self,\n            search,\n            options.with_scheduling,\n            options.with_deck_configs,\n        )?;\n        if options.with_media {\n            data.gather_media_names(progress)?;\n        }\n\n        let mut temp_col = Collection::new_minimal(path)?;\n        progress.set(ExportProgress::File)?;\n        temp_col.insert_data(&data)?;\n        temp_col.set_creation_stamp(self.storage.creation_stamp()?)?;\n        temp_col.set_creation_utc_offset(data.creation_utc_offset)?;\n        temp_col.close(Some(meta.schema_version()))?;\n\n        Ok(data)\n    }\n\n    fn new_minimal(path: impl Into<PathBuf>) -> Result<Self> {\n        let col = CollectionBuilder::new(path).build()?;\n        col.storage.db.execute_batch(\"DELETE FROM notetypes\")?;\n        Ok(col)\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/import/cards.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::mem;\n\nuse super::Context;\nuse super::TemplateMap;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::config::SchedulerVersion;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\n\ntype CardAsNidAndOrd = (NoteId, u16);\n\nstruct CardContext<'a> {\n    target_col: &'a mut Collection,\n    usn: Usn,\n\n    imported_notes: &'a HashMap<NoteId, NoteId>,\n    notetype_map: &'a HashMap<NoteId, NotetypeId>,\n    remapped_templates: &'a HashMap<NotetypeId, TemplateMap>,\n    remapped_decks: &'a HashMap<DeckId, DeckId>,\n\n    /// The number of days the source collection is ahead of the target\n    /// collection\n    collection_delta: i32,\n    scheduler_version: SchedulerVersion,\n    existing_cards: HashSet<CardAsNidAndOrd>,\n    existing_card_ids: HashSet<CardId>,\n\n    imported_cards: HashMap<CardId, CardId>,\n}\n\nimpl<'c> CardContext<'c> {\n    fn new<'a: 'c>(\n        usn: Usn,\n        days_elapsed: u32,\n        target_col: &'a mut Collection,\n        imported_notes: &'a HashMap<NoteId, NoteId>,\n        notetype_map: &'a HashMap<NoteId, NotetypeId>,\n        remapped_templates: &'a HashMap<NotetypeId, TemplateMap>,\n        imported_decks: &'a HashMap<DeckId, DeckId>,\n    ) -> Result<Self> {\n        let existing_cards = target_col.storage.all_cards_as_nid_and_ord()?;\n        let collection_delta = target_col.collection_delta(days_elapsed)?;\n        let scheduler_version = target_col.scheduler_info()?.version;\n        let existing_card_ids = target_col.storage.get_all_card_ids()?;\n        Ok(Self {\n            target_col,\n            usn,\n            imported_notes,\n            notetype_map,\n            remapped_templates,\n            remapped_decks: imported_decks,\n            existing_cards,\n            collection_delta,\n            scheduler_version,\n            existing_card_ids,\n            imported_cards: HashMap::new(),\n        })\n    }\n}\n\nimpl Collection {\n    /// How much `days_elapsed` is ahead of this collection.\n    fn collection_delta(&mut self, days_elapsed: u32) -> Result<i32> {\n        Ok(days_elapsed as i32 - self.timing_today()?.days_elapsed as i32)\n    }\n}\n\nimpl Context<'_> {\n    pub(super) fn import_cards_and_revlog(\n        &mut self,\n        imported_notes: &HashMap<NoteId, NoteId>,\n        notetype_map: &HashMap<NoteId, NotetypeId>,\n        remapped_templates: &HashMap<NotetypeId, TemplateMap>,\n        imported_decks: &HashMap<DeckId, DeckId>,\n    ) -> Result<()> {\n        let mut ctx = CardContext::new(\n            self.usn,\n            self.data.days_elapsed,\n            self.target_col,\n            imported_notes,\n            notetype_map,\n            remapped_templates,\n            imported_decks,\n        )?;\n        if ctx.scheduler_version == SchedulerVersion::V1 {\n            return Err(AnkiError::SchedulerUpgradeRequired);\n        }\n        ctx.import_cards(mem::take(&mut self.data.cards))?;\n        ctx.import_revlog(mem::take(&mut self.data.revlog))\n    }\n}\n\nimpl CardContext<'_> {\n    fn import_cards(&mut self, mut cards: Vec<Card>) -> Result<()> {\n        for card in &mut cards {\n            if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) {\n                self.add_card(card)?;\n            }\n            // TODO: could update existing card\n        }\n        Ok(())\n    }\n\n    fn import_revlog(&mut self, revlog: Vec<RevlogEntry>) -> Result<()> {\n        for mut entry in revlog {\n            if let Some(cid) = self.imported_cards.get(&entry.cid) {\n                entry.cid = *cid;\n                entry.usn = self.usn;\n                self.target_col.add_revlog_entry_if_unique_undoable(entry)?;\n            }\n        }\n        Ok(())\n    }\n\n    fn map_to_imported_note(&self, card: &mut Card) -> bool {\n        if let Some(nid) = self.imported_notes.get(&card.note_id) {\n            card.note_id = *nid;\n            true\n        } else {\n            false\n        }\n    }\n\n    fn card_ordinal_already_exists(&self, card: &Card) -> bool {\n        self.existing_cards\n            .contains(&(card.note_id, card.template_idx))\n    }\n\n    fn add_card(&mut self, card: &mut Card) -> Result<()> {\n        card.usn = self.usn;\n        self.remap_deck_ids(card);\n        self.remap_template_index(card);\n        card.shift_collection_relative_dates(self.collection_delta);\n        let old_id = self.uniquify_card_id(card);\n\n        self.target_col.add_card_if_unique_undoable(card)?;\n        self.existing_card_ids.insert(card.id);\n        self.imported_cards.insert(old_id, card.id);\n\n        Ok(())\n    }\n\n    fn uniquify_card_id(&mut self, card: &mut Card) -> CardId {\n        let original = card.id;\n        while self.existing_card_ids.contains(&card.id) {\n            card.id.0 += 999;\n        }\n        original\n    }\n\n    fn remap_deck_ids(&self, card: &mut Card) {\n        if let Some(did) = self.remapped_decks.get(&card.deck_id) {\n            card.deck_id = *did;\n        }\n        if let Some(did) = self.remapped_decks.get(&card.original_deck_id) {\n            card.original_deck_id = *did;\n        }\n    }\n\n    fn remap_template_index(&self, card: &mut Card) {\n        card.template_idx = self\n            .notetype_map\n            .get(&card.note_id)\n            .and_then(|ntid| self.remapped_templates.get(ntid))\n            .and_then(|map| map.get(&card.template_idx))\n            .copied()\n            .unwrap_or(card.template_idx);\n    }\n}\n\nimpl Card {\n    /// `delta` is the number days the card's source collection is ahead of the\n    /// target collection.\n    fn shift_collection_relative_dates(&mut self, delta: i32) {\n        if self.due_in_days_since_collection_creation() {\n            self.due -= delta;\n        }\n        if self.original_due_in_days_since_collection_creation() && self.original_due != 0 {\n            self.original_due -= delta;\n        }\n    }\n\n    fn due_in_days_since_collection_creation(&self) -> bool {\n        matches!(self.queue, CardQueue::Review | CardQueue::DayLearn)\n            || self.ctype == CardType::Review\n    }\n\n    fn original_due_in_days_since_collection_creation(&self) -> bool {\n        self.ctype == CardType::Review\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/import/decks.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::mem;\n\nuse super::Context;\nuse crate::decks::NormalDeck;\nuse crate::decks::NormalDeckDayLimit;\nuse crate::prelude::*;\n\nstruct DeckContext<'d> {\n    target_col: &'d mut Collection,\n    usn: Usn,\n    renamed_parents: Vec<(String, String)>,\n    imported_decks: HashMap<DeckId, DeckId>,\n    unique_suffix: String,\n    source_col_today: u32,\n}\n\nimpl<'d> DeckContext<'d> {\n    fn new<'a: 'd>(target_col: &'a mut Collection, usn: Usn, source_col_today: u32) -> Self {\n        Self {\n            target_col,\n            usn,\n            renamed_parents: Vec::new(),\n            imported_decks: HashMap::new(),\n            unique_suffix: TimestampSecs::now().to_string(),\n            source_col_today,\n        }\n    }\n}\n\nimpl Context<'_> {\n    pub(super) fn import_decks_and_configs(&mut self) -> Result<HashMap<DeckId, DeckId>> {\n        let mut ctx = DeckContext::new(self.target_col, self.usn, self.data.days_elapsed);\n        ctx.import_deck_configs(mem::take(&mut self.data.deck_configs))?;\n        ctx.import_decks(mem::take(&mut self.data.decks))?;\n        Ok(ctx.imported_decks)\n    }\n}\n\nimpl DeckContext<'_> {\n    fn import_deck_configs(&mut self, mut configs: Vec<DeckConfig>) -> Result<()> {\n        for config in &mut configs {\n            config.usn = self.usn;\n            self.target_col.add_deck_config_if_unique_undoable(config)?;\n        }\n        Ok(())\n    }\n\n    fn import_decks(&mut self, mut decks: Vec<Deck>) -> Result<()> {\n        // ensure parents are seen before children\n        decks.sort_unstable_by_key(|deck| deck.level());\n        for deck in &mut decks {\n            self.maybe_reparent(deck);\n            self.maybe_correct_day_limits(deck)?;\n            self.import_deck(deck)?;\n        }\n        Ok(())\n    }\n\n    fn import_deck(&mut self, deck: &mut Deck) -> Result<()> {\n        if let Some(original) = self.get_deck_by_name(deck)? {\n            if original.is_same_kind(deck) {\n                return self.update_deck(deck, original);\n            } else {\n                self.uniquify_name(deck);\n            }\n        }\n        self.ensure_valid_first_existing_parent(deck)?;\n        self.add_deck(deck)\n    }\n\n    fn maybe_reparent(&self, deck: &mut Deck) {\n        if let Some(new_name) = self.reparented_name(deck.name.as_native_str()) {\n            deck.name = NativeDeckName::from_native_str(new_name);\n        }\n    }\n\n    fn reparented_name(&self, name: &str) -> Option<String> {\n        self.renamed_parents\n            .iter()\n            .find_map(|(old_parent, new_parent)| {\n                name.starts_with(old_parent)\n                    .then(|| name.replacen(old_parent, new_parent, 1))\n            })\n    }\n\n    fn maybe_correct_day_limits(&mut self, deck: &mut Deck) -> Result<()> {\n        if let Ok(normal) = deck.normal_mut() {\n            let target_col_today = self.target_col.timing_today()?.days_elapsed;\n            let op = |mut limit: NormalDeckDayLimit| {\n                if limit.today == self.source_col_today {\n                    // imported deck has an active today limit, map it to target col\n                    limit.today = target_col_today;\n                    Some(limit)\n                } else if target_col_today > 0 {\n                    // imported deck's today limit was not active\n                    limit.today = limit.today.min(target_col_today - 1);\n                    Some(limit)\n                } else {\n                    // edge case where target collection is new (day 0), clear saved limit\n                    None\n                }\n            };\n            normal.new_limit_today = normal.new_limit_today.and_then(op);\n            normal.review_limit_today = normal.review_limit_today.and_then(op);\n        }\n        Ok(())\n    }\n\n    fn get_deck_by_name(&mut self, deck: &Deck) -> Result<Option<Deck>> {\n        self.target_col\n            .storage\n            .get_deck_by_name(deck.name.as_native_str())\n    }\n\n    fn uniquify_name(&mut self, deck: &mut Deck) {\n        let old_parent = format!(\"{}\\x1f\", deck.name.as_native_str());\n        deck.uniquify_name(&self.unique_suffix);\n        let new_parent = format!(\"{}\\x1f\", deck.name.as_native_str());\n        self.renamed_parents.push((old_parent, new_parent));\n    }\n\n    fn add_deck(&mut self, deck: &mut Deck) -> Result<()> {\n        let old_id = mem::take(&mut deck.id);\n        self.target_col.add_deck_inner(deck, self.usn)?;\n        self.imported_decks.insert(old_id, deck.id);\n        Ok(())\n    }\n\n    /// Caller must ensure decks are of the same kind.\n    fn update_deck(&mut self, deck: &Deck, original: Deck) -> Result<()> {\n        let mut new_deck = original.clone();\n        if let (Ok(new), Ok(old)) = (new_deck.normal_mut(), deck.normal()) {\n            update_normal_with_other(new, old);\n        } else if let (Ok(new), Ok(old)) = (new_deck.filtered_mut(), deck.filtered()) {\n            *new = old.clone();\n        } else {\n            invalid_input!(\"decks have different kinds\");\n        }\n        self.imported_decks.insert(deck.id, new_deck.id);\n        self.target_col\n            .update_deck_inner(&mut new_deck, original, self.usn)\n    }\n\n    fn ensure_valid_first_existing_parent(&mut self, deck: &mut Deck) -> Result<()> {\n        if let Some(ancestor) = self\n            .target_col\n            .first_existing_parent(deck.name.as_native_str(), 0)?\n        {\n            if ancestor.is_filtered() {\n                self.add_unique_default_deck(ancestor.name.as_native_str())?;\n                self.maybe_reparent(deck);\n            }\n        }\n        Ok(())\n    }\n\n    fn add_unique_default_deck(&mut self, name: &str) -> Result<()> {\n        let mut deck = Deck::new_normal();\n        deck.name = NativeDeckName::from_native_str(name);\n        self.uniquify_name(&mut deck);\n        self.target_col.add_deck_inner(&mut deck, self.usn)\n    }\n}\n\nimpl Deck {\n    fn uniquify_name(&mut self, suffix: &str) {\n        let new_name = format!(\"{} {}\", self.name.as_native_str(), suffix);\n        self.name = NativeDeckName::from_native_str(new_name);\n    }\n\n    fn level(&self) -> usize {\n        self.name.components().count()\n    }\n\n    fn is_same_kind(&self, other: &Self) -> bool {\n        self.is_filtered() == other.is_filtered()\n    }\n}\n\nfn update_normal_with_other(normal: &mut NormalDeck, other: &NormalDeck) {\n    if !other.description.is_empty() {\n        normal.markdown_description = other.markdown_description;\n        normal.description.clone_from(&other.description);\n    }\n    if other.config_id != 1 {\n        normal.config_id = other.config_id;\n    }\n    normal.review_limit = other.review_limit.or(normal.review_limit);\n    normal.new_limit = other.new_limit.or(normal.new_limit);\n    normal.review_limit_today = other.review_limit_today.or(normal.review_limit_today);\n    normal.new_limit_today = other.new_limit_today.or(normal.new_limit_today);\n}\n\n#[cfg(test)]\nmod test {\n    use std::collections::HashSet;\n\n    use super::*;\n\n    #[test]\n    fn parents() {\n        let mut col = Collection::new();\n\n        DeckAdder::new(\"filtered\").filtered(true).add(&mut col);\n        DeckAdder::new(\"PARENT\").add(&mut col);\n\n        let mut ctx = DeckContext::new(&mut col, Usn(1), 0);\n        ctx.unique_suffix = \"★\".to_string();\n\n        let imports = vec![\n            DeckAdder::new(\"unknown parent::child\").deck(),\n            DeckAdder::new(\"filtered::child\").deck(),\n            DeckAdder::new(\"parent::child\").deck(),\n            DeckAdder::new(\"NEW PARENT::child\").deck(),\n            DeckAdder::new(\"new parent\").deck(),\n        ];\n        ctx.import_decks(imports).unwrap();\n        let existing_decks: HashSet<_> = ctx\n            .target_col\n            .get_all_deck_names(true)\n            .unwrap()\n            .into_iter()\n            .map(|(_, name)| name)\n            .collect();\n\n        // missing parents get created\n        assert!(existing_decks.contains(\"unknown parent\"));\n        // ... and uniquified if their existing counterparts are filtered\n        assert!(existing_decks.contains(\"filtered ★\"));\n        assert!(existing_decks.contains(\"filtered ★::child\"));\n        // the case of existing parents is matched\n        assert!(existing_decks.contains(\"PARENT::child\"));\n        // the case of imported parents is matched, regardless of pass order\n        assert!(existing_decks.contains(\"new parent::child\"));\n    }\n\n    #[test]\n    fn day_limits_should_carry_over_correctly() {\n        let mut col = Collection::new();\n\n        let importing_col_today = col.timing_today().unwrap().days_elapsed;\n        let exporting_col_today = importing_col_today + 100;\n        let deck_name = \"blah\";\n\n        let mut exported_deck = DeckAdder::new(deck_name).filtered(false).deck();\n        let normal = exported_deck.normal_mut().unwrap();\n\n        normal.new_limit_today = Some(NormalDeckDayLimit {\n            limit: 123,\n            today: exporting_col_today,\n        });\n        normal.review_limit_today = Some(NormalDeckDayLimit {\n            limit: 456,\n            today: exporting_col_today - 100,\n        });\n\n        let mut ctx = DeckContext::new(&mut col, Usn(1), exporting_col_today);\n        ctx.import_decks(vec![exported_deck]).unwrap();\n\n        let imported_deck_id = ctx.target_col.get_deck_id(deck_name).unwrap().unwrap();\n        let imported_deck = ctx.target_col.get_deck(imported_deck_id).unwrap().unwrap();\n\n        let imported_deck = imported_deck.normal().unwrap();\n\n        // active day limit should carry over regardless of collection age\n        assert!(\n            matches!(imported_deck.new_limit_today, Some(NormalDeckDayLimit { limit: 123, today }) if today == importing_col_today)\n        );\n        // target_col's today is 0, expect the day limit to be cleared\n        assert!(imported_deck.review_limit_today.is_none())\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/import/media.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::fs::File;\nuse std::mem;\n\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\nuse zip::ZipArchive;\n\nuse super::super::super::meta::MetaExt;\nuse super::Context;\nuse crate::import_export::package::media::extract_media_entries;\nuse crate::import_export::package::media::MediaCopier;\nuse crate::import_export::package::media::SafeMediaEntry;\nuse crate::import_export::ImportProgress;\nuse crate::media::files::add_hash_suffix_to_file_stem;\nuse crate::media::files::sha1_of_reader;\nuse crate::media::Checksums;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\n\n/// Map of source media files, that do not already exist in the target.\n#[derive(Debug, Default)]\npub(super) struct MediaUseMap {\n    /// original, normalized filename → (refererenced on import material,\n    /// entry with possibly remapped filename)\n    checked: HashMap<String, (bool, SafeMediaEntry)>,\n    /// Static files (latex, underscored). Usage is not tracked, and if the name\n    /// already exists in the target, it is skipped regardless of content\n    /// equality.\n    unchecked: Vec<SafeMediaEntry>,\n}\n\nimpl Context<'_> {\n    pub(super) fn prepare_media(&mut self) -> Result<MediaUseMap> {\n        let media_entries = extract_media_entries(&self.meta, &mut self.archive)?;\n        if media_entries.is_empty() {\n            return Ok(MediaUseMap::default());\n        }\n\n        let db_progress_fn = self.progress.media_db_fn(ImportProgress::MediaCheck)?;\n        let existing_sha1s = self\n            .media_manager\n            .all_checksums_after_checking(db_progress_fn)?;\n\n        prepare_media(\n            media_entries,\n            &mut self.archive,\n            &existing_sha1s,\n            &mut self.progress,\n        )\n    }\n\n    pub(super) fn copy_media(&mut self, media_map: &mut MediaUseMap) -> Result<()> {\n        let mut incrementor = self.progress.incrementor(ImportProgress::Media);\n        let mut copier = MediaCopier::new(false);\n        self.media_manager.transact(|_db| {\n            for entry in media_map.used_entries() {\n                incrementor.increment()?;\n                entry.copy_and_ensure_sha1_set(\n                    &mut self.archive,\n                    &self.target_col.media_folder,\n                    &mut copier,\n                    self.meta.zstd_compressed(),\n                )?;\n                self.media_manager\n                    .add_entry(&entry.name, entry.sha1.unwrap())?;\n            }\n            Ok(())\n        })\n    }\n}\n\nfn prepare_media(\n    media_entries: Vec<SafeMediaEntry>,\n    archive: &mut ZipArchive<File>,\n    existing_sha1s: &Checksums,\n    progress: &mut ThrottlingProgressHandler<ImportProgress>,\n) -> Result<MediaUseMap> {\n    let mut media_map = MediaUseMap::default();\n    let mut incrementor = progress.incrementor(ImportProgress::MediaCheck);\n\n    for mut entry in media_entries {\n        incrementor.increment()?;\n\n        if entry.is_static() {\n            if !existing_sha1s.contains_key(&entry.name) {\n                media_map.unchecked.push(entry);\n            }\n        } else if let Some(other_sha1) = existing_sha1s.get(&entry.name) {\n            entry.ensure_sha1_set(archive)?;\n            if entry.sha1.unwrap() != *other_sha1 {\n                let original_name = entry.uniquify_name();\n                media_map.add_checked(original_name, entry);\n            }\n        } else {\n            media_map.add_checked(entry.name.clone(), entry);\n        }\n    }\n    Ok(media_map)\n}\n\nimpl MediaUseMap {\n    pub(super) fn add_checked(&mut self, filename: impl Into<String>, entry: SafeMediaEntry) {\n        self.checked.insert(filename.into(), (false, entry));\n    }\n\n    pub(super) fn use_entry(&mut self, filename: &str) -> Option<&SafeMediaEntry> {\n        self.checked.get_mut(filename).map(|(used, entry)| {\n            *used = true;\n            &*entry\n        })\n    }\n\n    pub(super) fn used_entries(&mut self) -> impl Iterator<Item = &mut SafeMediaEntry> {\n        self.checked\n            .values_mut()\n            .filter_map(|(used, entry)| used.then(|| entry))\n            .chain(self.unchecked.iter_mut())\n    }\n}\n\nimpl SafeMediaEntry {\n    fn ensure_sha1_set(&mut self, archive: &mut ZipArchive<File>) -> Result<()> {\n        if self.sha1.is_none() {\n            let mut reader = self.fetch_file(archive)?;\n            self.sha1 = Some(sha1_of_reader(&mut reader).context(FileIoSnafu {\n                path: &self.name,\n                op: FileOp::Read,\n            })?);\n        }\n        Ok(())\n    }\n\n    /// Requires sha1 to be set. Returns old file name.\n    fn uniquify_name(&mut self) -> String {\n        let new_name = add_hash_suffix_to_file_stem(&self.name, &self.sha1.expect(\"sha1 not set\"));\n        mem::replace(&mut self.name, new_name)\n    }\n\n    fn is_static(&self) -> bool {\n        self.name.starts_with('_') || self.name.starts_with(\"latex-\")\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/import/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod cards;\nmod decks;\nmod media;\nmod notes;\n\nuse std::fs::File;\nuse std::path::Path;\n\nuse anki_io::new_tempfile;\nuse anki_io::open_file;\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\npub(crate) use notes::NoteMeta;\nuse rusqlite::OptionalExtension;\nuse tempfile::NamedTempFile;\nuse zip::ZipArchive;\n\nuse super::super::meta::MetaExt;\nuse crate::collection::CollectionBuilder;\nuse crate::config::ConfigKey;\nuse crate::import_export::gather::ExchangeData;\nuse crate::import_export::package::ImportAnkiPackageOptions;\nuse crate::import_export::package::Meta;\nuse crate::import_export::package::UpdateCondition;\nuse crate::import_export::ImportProgress;\nuse crate::import_export::NoteLog;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::search::SearchNode;\n\n/// A map of old to new template indices for a given notetype.\ntype TemplateMap = std::collections::HashMap<u16, u16>;\n\nstruct Context<'a> {\n    target_col: &'a mut Collection,\n    merge_notetypes: bool,\n    update_notes: UpdateCondition,\n    update_notetypes: UpdateCondition,\n    media_manager: MediaManager,\n    archive: ZipArchive<File>,\n    meta: Meta,\n    data: ExchangeData,\n    usn: Usn,\n    progress: ThrottlingProgressHandler<ImportProgress>,\n}\n\nimpl Collection {\n    pub fn import_apkg(\n        &mut self,\n        path: impl AsRef<Path>,\n        options: ImportAnkiPackageOptions,\n    ) -> Result<OpOutput<NoteLog>> {\n        let file = open_file(path)?;\n        let archive = ZipArchive::new(file)?;\n        let progress = self.new_progress_handler();\n\n        self.transact(Op::Import, |col| {\n            col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?;\n            col.set_config(BoolKey::WithScheduling, &options.with_scheduling)?;\n            col.set_config(BoolKey::WithDeckConfigs, &options.with_deck_configs)?;\n            col.set_config(ConfigKey::UpdateNotes, &options.update_notes())?;\n            col.set_config(ConfigKey::UpdateNotetypes, &options.update_notetypes())?;\n            let mut ctx = Context::new(archive, col, options, progress)?;\n            ctx.import()\n        })\n    }\n}\n\nimpl<'a> Context<'a> {\n    fn new(\n        mut archive: ZipArchive<File>,\n        target_col: &'a mut Collection,\n        options: ImportAnkiPackageOptions,\n        mut progress: ThrottlingProgressHandler<ImportProgress>,\n    ) -> Result<Self> {\n        let media_manager = target_col.media()?;\n        let meta = Meta::from_archive(&mut archive)?;\n        let data = ExchangeData::gather_from_archive(\n            &mut archive,\n            &meta,\n            SearchNode::WholeCollection,\n            &mut progress,\n            options.with_scheduling,\n            options.with_deck_configs,\n        )?;\n        let usn = target_col.usn()?;\n        Ok(Self {\n            target_col,\n            merge_notetypes: options.merge_notetypes,\n            update_notes: options.update_notes(),\n            update_notetypes: options.update_notetypes(),\n            media_manager,\n            archive,\n            meta,\n            data,\n            usn,\n            progress,\n        })\n    }\n\n    fn import(&mut self) -> Result<NoteLog> {\n        let notetypes = self\n            .data\n            .notes\n            .iter()\n            .map(|n| (n.id, n.notetype_id))\n            .collect();\n        let mut media_map = self.prepare_media()?;\n        let note_imports = self.import_notes_and_notetypes(&mut media_map)?;\n        let imported_decks = self.import_decks_and_configs()?;\n        self.import_cards_and_revlog(\n            &note_imports.id_map,\n            &notetypes,\n            &note_imports.remapped_templates,\n            &imported_decks,\n        )?;\n        self.copy_media(&mut media_map)?;\n        Ok(note_imports.log)\n    }\n}\n\nimpl ExchangeData {\n    fn gather_from_archive(\n        archive: &mut ZipArchive<File>,\n        meta: &Meta,\n        search: impl TryIntoSearch,\n        progress: &mut ThrottlingProgressHandler<ImportProgress>,\n        with_scheduling: bool,\n        with_deck_configs: bool,\n    ) -> Result<Self> {\n        let tempfile = collection_to_tempfile(meta, archive)?;\n        let mut col = CollectionBuilder::new(tempfile.path()).build()?;\n        col.maybe_fix_invalid_ids()?;\n        col.maybe_upgrade_scheduler()?;\n\n        progress.set(ImportProgress::Gathering)?;\n        let mut data = ExchangeData::default();\n        data.gather_data(&mut col, search, with_scheduling, with_deck_configs)?;\n\n        Ok(data)\n    }\n}\n\nfn collection_to_tempfile(meta: &Meta, archive: &mut ZipArchive<File>) -> Result<NamedTempFile> {\n    let mut zip_file = archive.by_name(meta.collection_filename())?;\n    let mut tempfile = new_tempfile()?;\n    meta.copy(&mut zip_file, &mut tempfile)\n        .with_context(|_| FileIoSnafu {\n            path: tempfile.path(),\n            op: FileOp::copy(zip_file.name()),\n        })?;\n\n    Ok(tempfile)\n}\n\nimpl Collection {\n    fn maybe_upgrade_scheduler(&mut self) -> Result<()> {\n        if self.scheduling_included()? {\n            self.upgrade_to_v2_scheduler()?;\n        }\n        Ok(())\n    }\n\n    fn scheduling_included(&mut self) -> Result<bool> {\n        const SQL: &str = \"SELECT 1 FROM cards WHERE queue != 0\";\n        Ok(self\n            .storage\n            .db\n            .query_row(SQL, [], |_| Ok(()))\n            .optional()?\n            .is_some())\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/import/notes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::mem;\nuse std::sync::Arc;\n\nuse super::media::MediaUseMap;\nuse super::Context;\nuse super::TemplateMap;\nuse crate::import_export::package::media::safe_normalized_file_name;\nuse crate::import_export::package::UpdateCondition;\nuse crate::import_export::ImportError;\nuse crate::import_export::ImportProgress;\nuse crate::import_export::NoteLog;\nuse crate::notes::UpdateNoteInnerWithoutCardsArgs;\nuse crate::notetype::ChangeNotetypeInput;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::text::replace_media_refs;\n\n#[derive(Debug)]\nstruct NoteContext<'a> {\n    target_col: &'a mut Collection,\n    usn: Usn,\n    normalize_notes: bool,\n    remapped_notetypes: HashMap<NotetypeId, NotetypeId>,\n    remapped_fields: HashMap<NotetypeId, Vec<Option<u32>>>,\n    target_ids: HashSet<NoteId>,\n    target_notetypes: Vec<Arc<Notetype>>,\n    media_map: &'a mut MediaUseMap,\n    merge_notetypes: bool,\n    update_notes: UpdateCondition,\n    update_notetypes: UpdateCondition,\n    imports: NoteImports,\n    // notetypes that have been merged into others and may now possibly be deleted\n    merged_notetypes: HashSet<NotetypeId>,\n}\n\n#[derive(Debug, Default)]\npub(super) struct NoteImports {\n    pub(super) id_map: HashMap<NoteId, NoteId>,\n    pub(super) remapped_templates: HashMap<NotetypeId, TemplateMap>,\n    /// All notes from the source collection as [Vec]s of their fields, and\n    /// grouped by import result kind.\n    pub(super) log: NoteLog,\n}\n\nimpl NoteImports {\n    fn log_new(&mut self, note: Note, source_id: NoteId) {\n        self.id_map.insert(source_id, note.id);\n        self.log.new.push(note.into_log_note());\n    }\n\n    fn log_updated(&mut self, note: Note, source_id: NoteId) {\n        self.id_map.insert(source_id, note.id);\n        self.log.updated.push(note.into_log_note());\n    }\n\n    fn log_duplicate(&mut self, mut note: Note, target_id: NoteId) {\n        self.id_map.insert(note.id, target_id);\n        // id is for looking up note in *target* collection\n        note.id = target_id;\n        self.log.duplicate.push(note.into_log_note());\n    }\n\n    fn log_conflicting(&mut self, note: Note) {\n        self.log.conflicting.push(note.into_log_note());\n    }\n}\n\n#[derive(Debug, Clone, Copy)]\npub(crate) struct NoteMeta {\n    id: NoteId,\n    mtime: TimestampSecs,\n    notetype_id: NotetypeId,\n}\n\nimpl NoteMeta {\n    pub(crate) fn new(id: NoteId, mtime: TimestampSecs, notetype_id: NotetypeId) -> Self {\n        Self {\n            id,\n            mtime,\n            notetype_id,\n        }\n    }\n}\n\nimpl Context<'_> {\n    pub(super) fn import_notes_and_notetypes(\n        &mut self,\n        media_map: &mut MediaUseMap,\n    ) -> Result<NoteImports> {\n        let mut ctx = NoteContext::new(\n            self.usn,\n            self.target_col,\n            media_map,\n            self.merge_notetypes,\n            self.update_notes,\n            self.update_notetypes,\n        )?;\n        ctx.import_notetypes(mem::take(&mut self.data.notetypes))?;\n        ctx.import_notes(mem::take(&mut self.data.notes), &mut self.progress)?;\n        Ok(ctx.imports)\n    }\n}\n\nimpl<'n> NoteContext<'n> {\n    fn new<'a: 'n>(\n        usn: Usn,\n        target_col: &'a mut Collection,\n        media_map: &'a mut MediaUseMap,\n        merge_notetypes: bool,\n        update_notes: UpdateCondition,\n        update_notetypes: UpdateCondition,\n    ) -> Result<Self> {\n        let normalize_notes = target_col.get_config_bool(BoolKey::NormalizeNoteText);\n        let target_ids = target_col.storage.get_all_note_ids()?;\n        let target_notetypes = target_col.get_all_notetypes()?;\n        Ok(Self {\n            target_col,\n            usn,\n            normalize_notes,\n            remapped_notetypes: HashMap::new(),\n            remapped_fields: HashMap::new(),\n            target_ids,\n            target_notetypes,\n            imports: NoteImports::default(),\n            merge_notetypes,\n            update_notes,\n            update_notetypes,\n            media_map,\n            merged_notetypes: HashSet::new(),\n        })\n    }\n\n    fn import_notetypes(&mut self, mut notetypes: Vec<Notetype>) -> Result<()> {\n        for notetype in &mut notetypes {\n            notetype.config.original_id.replace(notetype.id.0);\n            if let Some(nt) = self.get_target_notetype(notetype.id) {\n                let existing = nt.as_ref().clone();\n                if self.merge_notetypes {\n                    self.update_or_merge_notetype(notetype, existing)?;\n                } else {\n                    self.update_or_duplicate_notetype(notetype, existing)?;\n                }\n            } else {\n                self.add_notetype(notetype)?;\n            }\n        }\n        Ok(())\n    }\n\n    fn get_target_notetype(&self, ntid: NotetypeId) -> Option<&Arc<Notetype>> {\n        self.target_notetypes.iter().find(|nt| nt.id == ntid)\n    }\n\n    fn update_or_duplicate_notetype(\n        &mut self,\n        incoming: &mut Notetype,\n        mut existing: Notetype,\n    ) -> Result<()> {\n        if !existing.equal_schema(incoming) {\n            if let Some(nt) = self.get_previously_duplicated_notetype(incoming) {\n                existing = nt;\n                self.remapped_notetypes.insert(incoming.id, existing.id);\n                incoming.id = existing.id;\n            } else {\n                return self.add_notetype_with_remapped_id(incoming);\n            }\n        }\n        if should_update(\n            self.update_notetypes,\n            existing.mtime_secs,\n            incoming.mtime_secs,\n        ) {\n            self.update_notetype(incoming, existing, false)?;\n        }\n        Ok(())\n    }\n\n    /// Try to find a notetype with matching original id and schema.\n    fn get_previously_duplicated_notetype(&self, original: &Notetype) -> Option<Notetype> {\n        self.target_notetypes\n            .iter()\n            .find(|nt| {\n                nt.id != original.id\n                    && nt.config.original_id == Some(original.id.0)\n                    && nt.equal_schema(original)\n            })\n            .map(|nt| nt.as_ref().clone())\n    }\n\n    fn should_update_notetype(&self, existing: &Notetype, incoming: &Notetype) -> bool {\n        match self.update_notetypes {\n            UpdateCondition::IfNewer => existing.mtime_secs < incoming.mtime_secs,\n            UpdateCondition::Always => true,\n            UpdateCondition::Never => false,\n        }\n    }\n\n    fn add_notetype(&mut self, notetype: &mut Notetype) -> Result<()> {\n        notetype.prepare_for_update(None, true)?;\n        self.target_col\n            .ensure_notetype_name_unique(notetype, self.usn)?;\n        notetype.usn = self.usn;\n        self.target_col\n            .add_notetype_with_unique_id_undoable(notetype)\n    }\n\n    fn update_notetype(\n        &mut self,\n        notetype: &mut Notetype,\n        original: Notetype,\n        modified: bool,\n    ) -> Result<()> {\n        if modified {\n            notetype.set_modified(self.usn);\n            notetype.prepare_for_update(Some(&original), true)?;\n        } else {\n            notetype.usn = self.usn;\n        }\n        self.target_col\n            .add_or_update_notetype_with_existing_id_inner(notetype, Some(original), self.usn, true)\n    }\n\n    fn update_or_merge_notetype(\n        &mut self,\n        incoming: &mut Notetype,\n        mut existing: Notetype,\n    ) -> Result<()> {\n        if existing.is_cloze() != incoming.is_cloze() {\n            return Err(ImportError::NotetypeKindMergeConflict.into());\n        }\n\n        let original_existing = existing.clone();\n        // get and merge duplicated notetypes from previous no-merge imports\n        let mut siblings = self.get_sibling_notetypes(existing.id);\n        existing.merge_all(&siblings);\n        incoming.merge(&existing);\n        existing.merge(incoming);\n        self.record_remapped_ords(incoming);\n        let new_incoming = if self.should_update_notetype(&existing, incoming) {\n            // ords must be existing's as they are used to remap note fields and card\n            // template indices\n            incoming.copy_ords(&existing);\n            incoming\n        } else {\n            &mut existing\n        };\n        self.update_notetype(new_incoming, original_existing, true)?;\n        self.merge_sibling_notetypes(new_incoming, &mut siblings)\n    }\n\n    /// Get notetypes with different id, but matching original id.\n    fn get_sibling_notetypes(&mut self, original_id: NotetypeId) -> Vec<Notetype> {\n        self.target_notetypes\n            .iter()\n            .filter(|nt| nt.id != original_id && nt.config.original_id == Some(original_id.0))\n            .map(|nt| nt.as_ref().clone())\n            .collect()\n    }\n\n    /// Removes the sibling notetypes, changing their notes' notetype to\n    /// `original`. This assumes `siblings` have already been merged into\n    /// `original`.\n    fn merge_sibling_notetypes(\n        &mut self,\n        original: &Notetype,\n        siblings: &mut [Notetype],\n    ) -> Result<()> {\n        for nt in siblings {\n            nt.merge(original);\n            let note_ids = self.target_col.search_notes_unordered(nt.id)?;\n            self.target_col\n                .change_notetype_of_notes_inner(ChangeNotetypeInput {\n                    current_schema: self.target_col_schema_change()?,\n                    note_ids,\n                    old_notetype_name: nt.name.clone(),\n                    old_notetype_id: nt.id,\n                    new_notetype_id: original.id,\n                    new_fields: nt.field_ords_vec(),\n                    new_templates: Some(nt.template_ords_vec()),\n                })?;\n            self.merged_notetypes.insert(nt.id);\n        }\n        Ok(())\n    }\n\n    fn target_col_schema_change(&self) -> Result<TimestampMillis> {\n        self.target_col\n            .storage\n            .get_collection_timestamps()\n            .map(|ts| ts.schema_change)\n    }\n\n    /// Maintain map of ord changes in order to remap incoming note fields and\n    /// cards. If called multiple times with the same notetype maps will be\n    /// chained.\n    fn record_remapped_ords(&mut self, incoming: &Notetype) {\n        self.remapped_fields\n            .entry(incoming.id)\n            .and_modify(|old| {\n                *old = combine_field_ords_maps(old, incoming.field_ords());\n            })\n            .or_insert(incoming.field_ords().collect());\n        self.imports\n            .remapped_templates\n            .entry(incoming.id)\n            .and_modify(|old_map| {\n                combine_template_ords_maps(old_map, incoming);\n            })\n            .or_insert(\n                incoming\n                    .template_ords()\n                    .enumerate()\n                    .filter_map(|(new, old)| old.map(|ord| (ord as u16, new as u16)))\n                    .collect(),\n            );\n    }\n\n    fn add_notetype_with_remapped_id(&mut self, notetype: &mut Notetype) -> Result<()> {\n        let old_id = mem::take(&mut notetype.id);\n        notetype.usn = self.usn;\n        self.target_col\n            .add_notetype_inner(notetype, self.usn, true)?;\n        self.remapped_notetypes.insert(old_id, notetype.id);\n        Ok(())\n    }\n\n    fn import_notes(\n        &mut self,\n        notes: Vec<Note>,\n        progress: &mut ThrottlingProgressHandler<ImportProgress>,\n    ) -> Result<()> {\n        let existing_guids = self.target_col.storage.note_guid_map()?;\n        if self.merge_notetypes {\n            self.resolve_notetype_conflicts(&notes, &existing_guids)?;\n        }\n        let mut incrementor = progress.incrementor(ImportProgress::Notes);\n        self.imports.log.found_notes = notes.len() as u32;\n        for mut note in notes {\n            incrementor.increment()?;\n            self.remap_notetype_and_fields(&mut note);\n            if let Some(existing_note) = existing_guids.get(&note.guid) {\n                self.maybe_update_existing_note(*existing_note, note)?;\n            } else {\n                self.add_note(note)?;\n            }\n        }\n\n        self.delete_merged_unused_notetypes()\n    }\n\n    fn resolve_notetype_conflicts(\n        &mut self,\n        incoming_notes: &[Note],\n        existing_guids: &HashMap<String, NoteMeta>,\n    ) -> Result<()> {\n        for ((existing_ntid, incoming_ntid), note_ids) in\n            notetype_conflicts(incoming_notes, existing_guids)\n        {\n            let original_existing = self\n                .target_col\n                .storage\n                .get_notetype(existing_ntid)?\n                .or_not_found(existing_ntid)?;\n            let mut incoming = self\n                .target_col\n                .storage\n                .get_notetype(incoming_ntid)?\n                .or_not_found(incoming_ntid)?;\n\n            if original_existing.is_cloze() != incoming.is_cloze() {\n                return Err(ImportError::NotetypeKindMergeConflict.into());\n            }\n\n            let mut existing = original_existing.clone();\n            existing.merge(&incoming);\n            incoming.merge(&existing);\n            self.record_remapped_ords(&incoming);\n\n            let old_notetype_name = existing.name.clone();\n            let new_fields = existing.field_ords_vec();\n            let new_templates = Some(existing.template_ords_vec());\n            incoming.copy_ords(&existing);\n            self.update_notetype(&mut incoming, original_existing, true)?;\n\n            self.target_col\n                .change_notetype_of_notes_inner(ChangeNotetypeInput {\n                    current_schema: self.target_col_schema_change()?,\n                    note_ids,\n                    old_notetype_name,\n                    old_notetype_id: existing_ntid,\n                    new_notetype_id: incoming_ntid,\n                    new_fields,\n                    new_templates,\n                })?;\n\n            self.merged_notetypes.insert(existing_ntid);\n        }\n        Ok(())\n    }\n\n    fn remap_notetype_and_fields(&mut self, note: &mut Note) {\n        if let Some(new_ords) = self.remapped_fields.get(&note.notetype_id) {\n            note.reorder_fields(new_ords);\n        }\n        if let Some(remapped_ntid) = self.remapped_notetypes.get(&note.notetype_id) {\n            note.notetype_id = *remapped_ntid;\n        }\n    }\n\n    fn maybe_update_existing_note(&mut self, existing: NoteMeta, incoming: Note) -> Result<()> {\n        if !self.merge_notetypes && incoming.notetype_id != existing.notetype_id {\n            // notetype of existing note has changed, or notetype of incoming note has been\n            // remapped due to a schema conflict\n            self.imports.log_conflicting(incoming);\n        } else if should_update(self.update_notes, existing.mtime, incoming.mtime) {\n            self.update_note(incoming, existing.id)?;\n        } else {\n            // TODO: might still want to update merged in fields\n            self.imports.log_duplicate(incoming, existing.id);\n        }\n        Ok(())\n    }\n\n    fn add_note(&mut self, mut note: Note) -> Result<()> {\n        self.munge_media(&mut note)?;\n        self.target_col.canonify_note_tags(&mut note, self.usn)?;\n        let notetype = self.get_expected_notetype(note.notetype_id)?;\n        note.prepare_for_update(&notetype, self.normalize_notes)?;\n        note.usn = self.usn;\n        let old_id = self.uniquify_note_id(&mut note);\n\n        self.target_col.add_note_only_with_id_undoable(&mut note)?;\n        self.target_ids.insert(note.id);\n        self.imports.log_new(note, old_id);\n\n        Ok(())\n    }\n\n    fn uniquify_note_id(&mut self, note: &mut Note) -> NoteId {\n        let original = note.id;\n        while self.target_ids.contains(&note.id) {\n            note.id.0 += 999;\n        }\n        original\n    }\n\n    fn get_expected_notetype(&mut self, ntid: NotetypeId) -> Result<Arc<Notetype>> {\n        self.target_col.get_notetype(ntid)?.or_not_found(ntid)\n    }\n\n    fn get_expected_note(&mut self, nid: NoteId) -> Result<Note> {\n        self.target_col.storage.get_note(nid)?.or_not_found(nid)\n    }\n\n    fn update_note(&mut self, mut note: Note, target_id: NoteId) -> Result<()> {\n        let source_id = note.id;\n        note.id = target_id;\n        self.munge_media(&mut note)?;\n        let original = self.get_expected_note(note.id)?;\n        let notetype = self.get_expected_notetype(note.notetype_id)?;\n        // Preserve the incoming note's mtime to allow imports of successive exports\n        let incoming_mtime = note.mtime;\n        self.target_col\n            .update_note_inner_without_cards_using_mtime(\n                UpdateNoteInnerWithoutCardsArgs {\n                    note: &mut note,\n                    original: &original,\n                    notetype: &notetype,\n                    usn: self.usn,\n                    mark_note_modified: true,\n                    normalize_text: self.normalize_notes,\n                    update_tags: true,\n                },\n                Some(incoming_mtime),\n            )?;\n        self.imports.log_updated(note, source_id);\n        Ok(())\n    }\n\n    fn munge_media(&mut self, note: &mut Note) -> Result<()> {\n        for field in note.fields_mut() {\n            if let Some(new_field) = self.replace_media_refs(field) {\n                *field = new_field;\n            };\n        }\n        Ok(())\n    }\n\n    fn replace_media_refs(&mut self, field: &mut str) -> Option<String> {\n        replace_media_refs(field, |name| {\n            if let Ok(normalized) = safe_normalized_file_name(name) {\n                if let Some(entry) = self.media_map.use_entry(&normalized) {\n                    if entry.name != name {\n                        // name is not normalized, and/or remapped\n                        return Some(entry.name.clone());\n                    }\n                } else if let Cow::Owned(s) = normalized {\n                    // no entry; might be a reference to an existing file, so ensure normalization\n                    return Some(s);\n                }\n            }\n            None\n        })\n    }\n\n    fn delete_merged_unused_notetypes(&mut self) -> Result<()> {\n        for &ntid in self\n            .merged_notetypes\n            .difference(&self.target_col.storage.used_notetypes()?)\n        {\n            self.target_col.remove_notetype_inner(ntid)?;\n        }\n        Ok(())\n    }\n}\n\nfn should_update(\n    cond: UpdateCondition,\n    existing_mtime: TimestampSecs,\n    incoming_mtime: TimestampSecs,\n) -> bool {\n    match cond {\n        UpdateCondition::IfNewer => existing_mtime < incoming_mtime,\n        UpdateCondition::Always => existing_mtime != incoming_mtime,\n        UpdateCondition::Never => false,\n    }\n}\n\nfn combine_field_ords_maps(\n    old: &[Option<u32>],\n    new: impl Iterator<Item = Option<u32>>,\n) -> Vec<Option<u32>> {\n    new.map(|new_field| {\n        new_field.and_then(|old_field| old.get(old_field as usize).copied().flatten())\n    })\n    .collect()\n}\n\nfn combine_template_ords_maps(old_map: &mut HashMap<u16, u16>, new: &Notetype) {\n    for to in old_map.values_mut() {\n        *to = new\n            .template_ords()\n            .enumerate()\n            .find_map(|(new_to, new_from)| (new_from == Some(*to as u32)).then_some(new_to as u16))\n            .unwrap_or(*to);\n    }\n}\n\n/// Target ids of notes with conflicting notetypes, with keys\n/// `(target note's notetype, incoming note's notetype)`.\nfn notetype_conflicts(\n    incoming_notes: &[Note],\n    existing_guids: &HashMap<String, NoteMeta>,\n) -> HashMap<(NotetypeId, NotetypeId), Vec<NoteId>> {\n    let mut conflicts: HashMap<(NotetypeId, NotetypeId), Vec<NoteId>> = HashMap::default();\n    for note in incoming_notes {\n        if let Some(meta) = existing_guids.get(&note.guid) {\n            if meta.notetype_id != note.notetype_id {\n                conflicts\n                    .entry((meta.notetype_id, note.notetype_id))\n                    .or_default()\n                    .push(meta.id);\n            }\n        };\n    }\n    conflicts\n}\n\nimpl Notetype {\n    pub(crate) fn field_ords(&self) -> impl Iterator<Item = Option<u32>> + '_ {\n        self.fields.iter().map(|f| f.ord)\n    }\n\n    pub(crate) fn template_ords(&self) -> impl Iterator<Item = Option<u32>> + '_ {\n        self.templates.iter().map(|t| t.ord)\n    }\n\n    fn field_ords_vec(&self) -> Vec<Option<usize>> {\n        self.field_ords()\n            .map(|opt| opt.map(|u| u as usize))\n            .collect()\n    }\n\n    fn template_ords_vec(&self) -> Vec<Option<usize>> {\n        self.template_ords()\n            .map(|opt| opt.map(|u| u as usize))\n            .collect()\n    }\n\n    fn equal_schema(&self, other: &Self) -> bool {\n        self.fields.len() == other.fields.len()\n            && self.templates.len() == other.templates.len()\n            && self\n                .fields\n                .iter()\n                .zip(other.fields.iter())\n                .all(|(f1, f2)| f1.is_match(f2))\n            && self\n                .templates\n                .iter()\n                .zip(other.templates.iter())\n                .all(|(t1, t2)| t1.is_match(t2))\n    }\n\n    fn copy_ords(&mut self, other: &Self) {\n        for (field, other_ord) in self.fields.iter_mut().zip(other.field_ords()) {\n            field.ord = other_ord;\n        }\n        for (template, other_ord) in self.templates.iter_mut().zip(other.template_ords()) {\n            template.ord = other_ord;\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_proto::import_export::ExportAnkiPackageOptions;\n    use anki_proto::import_export::ImportAnkiPackageOptions;\n    use tempfile::TempDir;\n\n    use super::*;\n    use crate::collection::CollectionBuilder;\n    use crate::import_export::package::media::SafeMediaEntry;\n    use crate::notetype::CardTemplate;\n    use crate::notetype::NoteField;\n\n    #[derive(Default)]\n    struct ImportBuilder {\n        notes: Vec<Note>,\n        notetypes: Vec<Notetype>,\n        remapped_notetypes: HashMap<NotetypeId, NotetypeId>,\n        media_map: MediaUseMap,\n        merge_notetypes: bool,\n    }\n\n    impl ImportBuilder {\n        fn new() -> Self {\n            Self::default()\n        }\n\n        fn note(mut self, note: Note) -> Self {\n            self.notes.push(note);\n            self\n        }\n\n        fn notetype(mut self, notetype: Notetype) -> Self {\n            self.notetypes.push(notetype);\n            self\n        }\n\n        fn remap_notetype(mut self, from: NotetypeId, to: NotetypeId) -> Self {\n            self.remapped_notetypes.insert(from, to);\n            self\n        }\n\n        fn merge_notetypes(mut self, yes: bool) -> Self {\n            self.merge_notetypes = yes;\n            self\n        }\n\n        fn import(self, col: &mut Collection) -> NoteContext<'_> {\n            let mut progress_handler = col.new_progress_handler();\n            let media_map = Box::leak(Box::new(self.media_map));\n            let mut ctx = NoteContext::new(\n                Usn(1),\n                col,\n                media_map,\n                self.merge_notetypes,\n                UpdateCondition::IfNewer,\n                UpdateCondition::IfNewer,\n            )\n            .unwrap();\n            ctx.import_notetypes(self.notetypes).unwrap();\n            ctx.remapped_notetypes.extend(self.remapped_notetypes);\n            ctx.import_notes(self.notes, &mut progress_handler).unwrap();\n            ctx\n        }\n    }\n\n    /// Assert that exactly one [Note] is logged, and that with the given state\n    /// and fields.\n    macro_rules! assert_note_logged {\n        ($log:expr, $state:ident, $fields:expr) => {\n            assert_eq!($log.$state.pop().unwrap().fields, $fields);\n            assert!($log.new.is_empty());\n            assert!($log.updated.is_empty());\n            assert!($log.duplicate.is_empty());\n            assert!($log.conflicting.is_empty());\n        };\n    }\n\n    impl Collection {\n        fn note_id_for_guid(&self, guid: &str) -> NoteId {\n            self.storage\n                .db\n                .query_row(\"SELECT id FROM notes WHERE guid = ?\", [guid], |r| r.get(0))\n                .unwrap()\n        }\n    }\n\n    impl Notetype {\n        pub(crate) fn field_names(&self) -> impl Iterator<Item = &String> {\n            self.fields.iter().map(|f| &f.name)\n        }\n\n        pub(crate) fn template_names(&self) -> impl Iterator<Item = &String> {\n            self.templates.iter().map(|t| &t.name)\n        }\n    }\n\n    #[test]\n    fn should_add_note_with_new_id_if_guid_is_unique_and_id_is_not() {\n        let mut col = Collection::new();\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.guid = \"other\".to_string();\n        let original_id = note.id;\n\n        let mut ctx = ImportBuilder::new().note(note).import(&mut col);\n        assert_note_logged!(ctx.imports.log, new, &[\"\", \"\"]);\n        assert_ne!(col.note_id_for_guid(\"other\"), original_id);\n    }\n\n    #[test]\n    fn should_skip_note_if_guid_already_exists_with_newer_mtime() {\n        let mut col = Collection::new();\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.mtime.0 -= 1;\n        note.fields_mut()[0] = \"outdated\".to_string();\n\n        let mut ctx = ImportBuilder::new().note(note).import(&mut col);\n        assert_note_logged!(ctx.imports.log, duplicate, &[\"outdated\", \"\"]);\n        assert_eq!(col.get_all_notes()[0].fields()[0], \"\");\n    }\n\n    #[test]\n    fn should_update_note_if_guid_already_exists_with_different_id() {\n        let mut col = Collection::new();\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.id.0 = 42;\n        note.mtime.0 += 1;\n        note.fields_mut()[0] = \"updated\".to_string();\n\n        let mut ctx = ImportBuilder::new().note(note).import(&mut col);\n        assert_note_logged!(ctx.imports.log, updated, &[\"updated\", \"\"]);\n        assert_eq!(col.get_all_notes()[0].fields()[0], \"updated\");\n    }\n\n    #[test]\n    fn should_ignore_note_if_guid_already_exists_with_different_notetype() {\n        let mut col = Collection::new();\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.notetype_id.0 = 42;\n        note.mtime.0 += 1;\n        note.fields_mut()[0] = \"updated\".to_string();\n\n        let mut ctx = ImportBuilder::new().note(note).import(&mut col);\n        assert_note_logged!(ctx.imports.log, conflicting, &[\"updated\", \"\"]);\n        assert_eq!(col.get_all_notes()[0].fields()[0], \"\");\n    }\n\n    #[test]\n    fn should_add_note_with_remapped_notetype_if_in_notetype_map() {\n        let mut col = Collection::new();\n        let basic_ntid = col.get_notetype_by_name(\"basic\").unwrap().unwrap().id;\n        let mut note = NoteAdder::basic(&mut col).note();\n        note.notetype_id.0 = 123;\n\n        let mut ctx = ImportBuilder::new()\n            .note(note)\n            .remap_notetype(NotetypeId(123), basic_ntid)\n            .import(&mut col);\n        assert_note_logged!(ctx.imports.log, new, &[\"\", \"\"]);\n        assert_eq!(col.get_all_notes()[0].notetype_id, basic_ntid);\n    }\n\n    #[test]\n    fn should_ignore_note_if_guid_already_exists_and_notetype_is_remapped() {\n        let mut col = Collection::new();\n        let basic_ntid = col.get_notetype_by_name(\"basic\").unwrap().unwrap().id;\n        let mut note = NoteAdder::basic(&mut col).add(&mut col);\n        note.mtime.0 += 1;\n        note.fields_mut()[0] = \"updated\".to_string();\n\n        let mut ctx = ImportBuilder::new()\n            .note(note)\n            .remap_notetype(basic_ntid, NotetypeId(123))\n            .import(&mut col);\n        assert_note_logged!(ctx.imports.log, conflicting, &[\"updated\", \"\"]);\n        assert_eq!(col.get_all_notes()[0].fields()[0], \"\");\n    }\n\n    #[test]\n    fn should_add_note_with_remapped_media_reference_in_field_if_in_media_map() {\n        let mut col = Collection::new();\n        let mut note = NoteAdder::basic(&mut col).note();\n        note.fields_mut()[0] = \"<img src='foo.jpg'>\".to_string();\n\n        let mut builder = ImportBuilder::new();\n        let entry = SafeMediaEntry::from_legacy((\"0\", \"bar.jpg\".to_string())).unwrap();\n        builder.media_map.add_checked(\"foo.jpg\", entry);\n\n        let mut ctx = builder.note(note).import(&mut col);\n        assert_note_logged!(ctx.imports.log, new, &[\" bar.jpg \", \"\"]);\n        assert_eq!(col.get_all_notes()[0].fields()[0], \"<img src='bar.jpg'>\");\n    }\n\n    #[test]\n    fn should_import_new_notetype() {\n        let mut col = Collection::new();\n        let mut new_basic = crate::notetype::stock::basic(&col.tr);\n        new_basic.id.0 = 123;\n        ImportBuilder::new().notetype(new_basic).import(&mut col);\n        assert!(col.storage.get_notetype(NotetypeId(123)).unwrap().is_some());\n    }\n\n    #[test]\n    fn should_update_existing_notetype_with_older_mtime_and_matching_schema() {\n        let mut col = Collection::new();\n        let mut basic = col.basic_notetype();\n        basic.mtime_secs.0 += 1;\n        basic.name = String::from(\"new\");\n        ImportBuilder::new().notetype(basic).import(&mut col);\n        assert!(col.get_notetype_by_name(\"new\").unwrap().is_some());\n    }\n\n    #[test]\n    fn should_not_update_existing_notetype_with_newer_mtime_and_matching_schema() {\n        let mut col = Collection::new();\n        let mut basic = col.basic_notetype();\n        basic.mtime_secs.0 -= 1;\n        basic.name = String::from(\"new\");\n        ImportBuilder::new().notetype(basic).import(&mut col);\n        assert!(col.get_notetype_by_name(\"new\").unwrap().is_none());\n    }\n\n    #[test]\n    fn should_rename_field_with_matching_id_without_schema_change() {\n        let mut col = Collection::new();\n        let mut to_import = col.basic_notetype();\n        to_import.fields[0].name = String::from(\"renamed\");\n        to_import.mtime_secs.0 += 1;\n        ImportBuilder::new().notetype(to_import).import(&mut col);\n        assert_eq!(col.basic_notetype().fields[0].name, \"renamed\");\n    }\n\n    #[test]\n    fn should_add_remapped_notetype_if_schema_has_changed_and_reuse_it_subsequently() {\n        let mut col = Collection::new();\n        let mut to_import = col.basic_notetype();\n        to_import.fields[0].name = String::from(\"new field\");\n        // clear id or schemas would still match\n        to_import.fields[0].config.id.take();\n\n        // schema mismatch => notetype should be imported with new id\n        let ctx = ImportBuilder::new()\n            .notetype(to_import.clone())\n            .import(&mut col);\n        let remapped_id = *ctx.remapped_notetypes.values().next().unwrap();\n        assert_eq!(col.basic_notetype().fields[0].name, \"Front\");\n        let remapped = col.storage.get_notetype(remapped_id).unwrap().unwrap();\n        assert_eq!(remapped.fields[0].name, \"new field\");\n\n        // notetype with matching schema and original id exists => should be reused\n        to_import.name = String::from(\"new name\");\n        to_import.mtime_secs.0 = remapped.mtime_secs.0 + 1;\n        let ctx_2 = ImportBuilder::new().notetype(to_import).import(&mut col);\n        let remapped_id_2 = *ctx_2.remapped_notetypes.values().next().unwrap();\n        assert_eq!(remapped_id, remapped_id_2);\n        let updated = col.storage.get_notetype(remapped_id).unwrap().unwrap();\n        assert_eq!(updated.name, \"new name\");\n    }\n\n    #[test]\n    fn should_merge_notetype_fields() {\n        let mut col = Collection::new();\n        let mut to_import = col.basic_notetype();\n        to_import.mtime_secs.0 += 1;\n        to_import.fields.remove(0);\n        to_import.fields[0].name = String::from(\"renamed\");\n        to_import.fields[0].ord.replace(0);\n        to_import.fields.push(NoteField::new(\"new\"));\n        to_import.fields[1].ord.replace(1);\n\n        let fields = ImportBuilder::new()\n            .notetype(to_import.clone())\n            .merge_notetypes(true)\n            .import(&mut col)\n            .remapped_fields;\n        // Front field is preserved and new field added\n        assert!(col\n            .basic_notetype()\n            .field_names()\n            .eq([\"Front\", \"renamed\", \"new\"]));\n        // extra field must be inserted into incoming notes\n        assert_eq!(\n            fields.get(&to_import.id).unwrap(),\n            &[None, Some(0), Some(1)]\n        );\n    }\n\n    #[test]\n    fn should_merge_notetype_templates() {\n        let mut col = Collection::new();\n        let mut to_import = col.basic_rev_notetype();\n        to_import.mtime_secs.0 += 1;\n        to_import.templates.remove(0);\n        to_import.templates[0].name = String::from(\"renamed\");\n        to_import.templates[0].ord.replace(0);\n        to_import.templates.push(CardTemplate::new(\"new\", \"\", \"\"));\n        to_import.templates[1].ord.replace(1);\n\n        let templates = ImportBuilder::new()\n            .notetype(to_import.clone())\n            .merge_notetypes(true)\n            .import(&mut col)\n            .imports\n            .remapped_templates;\n        // Card 1 is preserved and new template added\n        assert!(col\n            .basic_rev_notetype()\n            .template_names()\n            .eq([\"Card 1\", \"renamed\", \"new\"]));\n        // templates must be shifted accordingly\n        let map = templates.get(&to_import.id).unwrap();\n        assert_eq!(map.get(&0), Some(&1));\n        assert_eq!(map.get(&1), Some(&2));\n    }\n\n    #[test]\n    fn should_merge_notetype_duplicates_from_previous_imports() {\n        let mut col = Collection::new();\n        let mut incoming = col.basic_notetype();\n        incoming.fields.push(NoteField::new(\"new incoming\"));\n        // simulate a notetype duplicated during previous import\n        let mut remapped = col.basic_notetype();\n        remapped.config.original_id.replace(incoming.id.0);\n        // ... which was modified and has notes\n        remapped.fields.push(NoteField::new(\"new remapped\"));\n        remapped.id.0 = 0;\n        col.add_notetype_inner(&mut remapped, Usn(0), true).unwrap();\n        let mut note = Note::new(&remapped);\n        *note.fields_mut() = vec![\n            String::from(\"front\"),\n            String::from(\"back\"),\n            String::from(\"new\"),\n        ];\n        col.add_note(&mut note, DeckId(1)).unwrap();\n\n        let ntid = incoming.id;\n        ImportBuilder::new()\n            .notetype(incoming)\n            .merge_notetypes(true)\n            .import(&mut col);\n\n        // both notetypes should have been merged into it\n        assert!(col.get_notetype(ntid).unwrap().unwrap().field_names().eq([\n            \"Front\",\n            \"Back\",\n            \"new remapped\",\n            \"new incoming\",\n        ]));\n        assert!(col.get_all_notes()[0]\n            .fields()\n            .iter()\n            .eq([\"front\", \"back\", \"new\", \"\"]))\n    }\n\n    #[test]\n    fn reimport_with_merge_enabled_should_handle_duplicates() -> Result<()> {\n        // import from src to dst\n        let mut src = Collection::new();\n        NoteAdder::basic(&mut src)\n            .fields(&[\"foo\", \"bar\"])\n            .add(&mut src);\n        let temp_dir = TempDir::new()?;\n        let path = temp_dir.path().join(\"foo.apkg\");\n        src.export_apkg(&path, ExportAnkiPackageOptions::default(), \"\", None)?;\n\n        let mut dst = CollectionBuilder::new(temp_dir.path().join(\"dst.anki2\"))\n            .with_desktop_media_paths()\n            .build()?;\n        dst.import_apkg(&path, ImportAnkiPackageOptions::default())?;\n\n        // add a field to src\n        let mut nt = src.basic_notetype();\n        nt.fields.push(NoteField::new(\"new incoming\"));\n        src.update_notetype(&mut nt, false)?;\n        // add a new note using the updated notetype\n        NoteAdder::basic(&mut src)\n            .fields(&[\"baz\", \"bar\", \"foo\"])\n            .add(&mut src);\n\n        // importing again with merge disabled will fail for the exisitng note,\n        // but the new one will be added with an extra notetype\n        assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7);\n        src.export_apkg(&path, ExportAnkiPackageOptions::default(), \"\", None)?;\n        assert_eq!(\n            dst.import_apkg(&path, ImportAnkiPackageOptions::default())?\n                .output\n                .conflicting\n                .len(),\n            1\n        );\n        assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 8);\n\n        // if enabling merge, it should succeed and remove the empty notetype, remapping\n        // its note\n        src.export_apkg(&path, ExportAnkiPackageOptions::default(), \"\", None)?;\n        assert_eq!(\n            dst.import_apkg(\n                &path,\n                ImportAnkiPackageOptions {\n                    merge_notetypes: true,\n                    ..Default::default()\n                }\n            )?\n            .output\n            .conflicting\n            .len(),\n            0\n        );\n        assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7);\n\n        Ok(())\n    }\n\n    #[test]\n    fn should_merge_conflicting_notetype_even_without_original_id() {\n        let mut col = Collection::new();\n        // incoming notetype with a new field\n        let mut incoming_notetype = col.basic_notetype();\n        incoming_notetype.fields.push(NoteField {\n            ord: Some(2),\n            ..NoteField::new(\"new incoming\")\n        });\n        // existing notetype with a different new field and id\n        let mut existing_notetype = col.basic_notetype();\n        existing_notetype\n            .fields\n            .push(NoteField::new(\"new existing\"));\n        existing_notetype.id.0 = 0;\n        col.add_notetype_inner(&mut existing_notetype, Usn(0), true)\n            .unwrap();\n        // incoming conflicts with existing note, e.g. because it was remapped during a\n        // previous import (which wasn't recording the origninal id of the notetype yet)\n        let mut note = NoteAdder::new(&existing_notetype)\n            .fields(&[\"front\", \"back\", \"new existing\"])\n            .add(&mut col);\n        note.fields_mut()[2] = String::from(\"new incoming\");\n        note.notetype_id = incoming_notetype.id;\n        note.mtime.0 += 1;\n\n        let ntid = incoming_notetype.id;\n        ImportBuilder::new()\n            .note(note)\n            .notetype(incoming_notetype)\n            .merge_notetypes(true)\n            .import(&mut col);\n\n        // notetypes should have been merged\n        assert!(col.get_notetype(ntid).unwrap().unwrap().field_names().eq([\n            \"Front\",\n            \"Back\",\n            \"new incoming\",\n            \"new existing\"\n        ]));\n        // merged, now unused notetype should have been deleted\n        assert!(col.get_notetype(existing_notetype.id).unwrap().is_none());\n        assert!(col.get_all_notes()[0]\n            .fields()\n            .iter()\n            .eq([\"front\", \"back\", \"new incoming\", \"\",]))\n    }\n\n    #[test]\n    fn should_combine_field_ords_maps() {\n        // (A, B) -> (C, B, A)\n        let old = [None, Some(1), Some(0)];\n        // (C, B, A)-> (D, A, B, C)\n        let new = [None, Some(2), Some(1), Some(0)].into_iter();\n        // (A, B) -> (D, A, B, C)\n        let expected = [None, Some(0), Some(1), None];\n        assert!(combine_field_ords_maps(&old, new).eq(&expected));\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod export;\nmod import;\nmod tests;\n\npub(crate) use import::NoteMeta;\n"
  },
  {
    "path": "rslib/src/import_export/package/apkg/tests.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n\nuse std::collections::HashSet;\nuse std::fs::File;\nuse std::io::Write;\n\nuse anki_io::read_file;\nuse anki_proto::import_export::ImportAnkiPackageOptions;\n\nuse crate::import_export::package::ExportAnkiPackageOptions;\nuse crate::media::files::sha1_of_data;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\nuse crate::search::SearchNode;\nuse crate::tests::open_fs_test_collection;\n\nconst SAMPLE_JPG: &str = \"sample.jpg\";\nconst SAMPLE_MP3: &str = \"sample.mp3\";\nconst SAMPLE_JS: &str = \"_sample.js\";\nconst JPG_DATA: &[u8] = b\"1\";\nconst MP3_DATA: &[u8] = b\"2\";\nconst JS_DATA: &[u8] = b\"3\";\nconst EXISTING_MP3_DATA: &[u8] = b\"4\";\n\n#[test]\nfn roundtrip() {\n    roundtrip_inner(true);\n    roundtrip_inner(false);\n}\n\nfn roundtrip_inner(legacy: bool) {\n    let (mut src_col, src_tempdir) = open_fs_test_collection(\"src\");\n    let (mut target_col, _target_tempdir) = open_fs_test_collection(\"target\");\n    let apkg_path = src_tempdir.path().join(\"test.apkg\");\n\n    let (main_deck, sibling_deck) = src_col.add_sample_decks();\n    let notetype = src_col.add_sample_notetype();\n    let note = src_col.add_sample_note(&main_deck, &sibling_deck, &notetype);\n    src_col.add_sample_media();\n    target_col.add_conflicting_media();\n\n    src_col\n        .export_apkg(\n            &apkg_path,\n            ExportAnkiPackageOptions {\n                with_scheduling: true,\n                with_deck_configs: true,\n                with_media: true,\n                legacy,\n            },\n            SearchNode::from_deck_name(\"parent::sample\"),\n            None,\n        )\n        .unwrap();\n    target_col\n        .import_apkg(&apkg_path, ImportAnkiPackageOptions::default())\n        .unwrap();\n\n    target_col.assert_decks();\n    target_col.assert_notetype(&notetype);\n    target_col.assert_note_and_media(&note);\n\n    target_col.undo().unwrap();\n    target_col.assert_empty();\n}\n\nimpl Collection {\n    fn add_sample_decks(&mut self) -> (Deck, Deck) {\n        let sample = self.add_named_deck(\"parent\\x1fsample\");\n        self.add_named_deck(\"parent\\x1fsample\\x1fchild\");\n        let siblings = self.add_named_deck(\"siblings\");\n\n        (sample, siblings)\n    }\n\n    fn add_named_deck(&mut self, name: &str) -> Deck {\n        let mut deck = Deck::new_normal();\n        deck.name = NativeDeckName::from_native_str(name);\n        self.add_deck(&mut deck).unwrap();\n        deck\n    }\n\n    fn add_sample_notetype(&mut self) -> Notetype {\n        let mut nt = Notetype {\n            name: \"sample\".into(),\n            ..Default::default()\n        };\n        nt.add_field(\"sample\");\n        nt.add_template(\"sample1\", \"{{sample}}\", \"<script src=_sample.js></script>\");\n        nt.add_template(\"sample2\", \"{{sample}}2\", \"\");\n        self.add_notetype(&mut nt, true).unwrap();\n        nt\n    }\n\n    fn add_sample_note(\n        &mut self,\n        main_deck: &Deck,\n        sibling_decks: &Deck,\n        notetype: &Notetype,\n    ) -> Note {\n        let mut sample = notetype.new_note();\n        sample.fields_mut()[0] = format!(\"<img src='{SAMPLE_JPG}'> [sound:{SAMPLE_MP3}]\");\n        sample.tags = vec![\"sample\".into()];\n        self.add_note(&mut sample, main_deck.id).unwrap();\n\n        let card = self\n            .storage\n            .get_card_by_ordinal(sample.id, 1)\n            .unwrap()\n            .unwrap();\n        self.set_deck(&[card.id], sibling_decks.id).unwrap();\n\n        sample\n    }\n\n    fn add_sample_media(&self) {\n        self.add_media(&[\n            (SAMPLE_JPG, JPG_DATA),\n            (SAMPLE_MP3, MP3_DATA),\n            (SAMPLE_JS, JS_DATA),\n        ]);\n    }\n\n    fn add_conflicting_media(&mut self) {\n        let mut file = File::create(self.media_folder.join(SAMPLE_MP3)).unwrap();\n        file.write_all(EXISTING_MP3_DATA).unwrap();\n    }\n\n    fn assert_decks(&mut self) {\n        let existing_decks: HashSet<_> = self\n            .get_all_deck_names(true)\n            .unwrap()\n            .into_iter()\n            .map(|(_, name)| name)\n            .collect();\n        for deck in [\"parent\", \"parent::sample\", \"siblings\"] {\n            assert!(existing_decks.contains(deck));\n        }\n        assert!(!existing_decks.contains(\"parent::sample::child\"));\n    }\n\n    fn assert_notetype(&mut self, notetype: &Notetype) {\n        assert!(self.get_notetype(notetype.id).unwrap().is_some());\n    }\n\n    fn assert_note_and_media(&mut self, note: &Note) {\n        let sha1 = sha1_of_data(MP3_DATA);\n        let new_mp3_name = format!(\"sample-{}.mp3\", hex::encode(sha1));\n        let csums = MediaManager::new(&self.media_folder, &self.media_db)\n            .unwrap()\n            .all_checksums_as_is();\n\n        for (fname, orig_data) in [\n            (SAMPLE_JPG, JPG_DATA),\n            (SAMPLE_MP3, EXISTING_MP3_DATA),\n            (new_mp3_name.as_str(), MP3_DATA),\n            (SAMPLE_JS, JS_DATA),\n        ] {\n            // data should have been copied correctly\n            assert_eq!(read_file(self.media_folder.join(fname)).unwrap(), orig_data);\n            // and checksums in media db should be valid\n            assert_eq!(*csums.get(fname).unwrap(), sha1_of_data(orig_data));\n        }\n\n        let imported_note = self.storage.get_note(note.id).unwrap().unwrap();\n        assert!(imported_note.fields()[0].contains(&new_mp3_name));\n    }\n\n    fn assert_empty(&self) {\n        assert!(self.get_all_deck_names(true).unwrap().is_empty());\n        assert!(self.storage.get_all_note_ids().unwrap().is_empty());\n        assert!(self.storage.get_all_card_ids().unwrap().is_empty());\n        assert!(self.storage.all_tags().unwrap().is_empty());\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/colpkg/export.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::fs::File;\nuse std::io;\nuse std::io::Read;\nuse std::io::Write;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::atomic_rename;\nuse anki_io::new_tempfile;\nuse anki_io::new_tempfile_in_parent_of;\nuse anki_io::open_file;\nuse prost::Message;\nuse tempfile::NamedTempFile;\nuse zip::write::FileOptions;\nuse zip::CompressionMethod;\nuse zip::ZipWriter;\nuse zstd::stream::raw::Encoder as RawEncoder;\nuse zstd::stream::zio;\nuse zstd::Encoder;\n\nuse super::super::meta::MetaExt;\nuse super::super::meta::VersionExt;\nuse super::super::MediaEntries;\nuse super::super::MediaEntry;\nuse super::super::Meta;\nuse super::super::Version;\nuse crate::collection::CollectionBuilder;\nuse crate::import_export::package::media::new_media_entry;\nuse crate::import_export::package::media::MediaCopier;\nuse crate::import_export::package::media::MediaIter;\nuse crate::import_export::ExportProgress;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::storage::SchemaVersion;\n\n/// Enable multithreaded compression if over this size. For smaller files,\n/// multithreading makes things slower, and in initial tests, the crossover\n/// point was somewhere between 1MB and 10MB on a many-core system.\nconst MULTITHREAD_MIN_BYTES: usize = 10 * 1024 * 1024;\n\nimpl Collection {\n    pub fn export_colpkg(\n        self,\n        out_path: impl AsRef<Path>,\n        include_media: bool,\n        legacy: bool,\n    ) -> Result<()> {\n        let mut progress = self.new_progress_handler();\n        let colpkg_name = out_path.as_ref();\n        let temp_colpkg = new_tempfile_in_parent_of(colpkg_name)?;\n        let src_path = self.col_path.clone();\n        let src_media_folder = if include_media {\n            Some(self.media_folder.clone())\n        } else {\n            None\n        };\n        let tr = self.tr.clone();\n        self.close(Some(if legacy {\n            SchemaVersion::V11\n        } else {\n            SchemaVersion::V18\n        }))?;\n\n        export_collection_file(\n            temp_colpkg.path(),\n            &src_path,\n            src_media_folder,\n            legacy,\n            &tr,\n            &mut progress,\n        )?;\n        atomic_rename(temp_colpkg, colpkg_name, true)?;\n\n        Ok(())\n    }\n}\n\nfn export_collection_file(\n    out_path: impl AsRef<Path>,\n    col_path: impl AsRef<Path>,\n    media_dir: Option<PathBuf>,\n    legacy: bool,\n    tr: &I18n,\n    progress: &mut ThrottlingProgressHandler<ExportProgress>,\n) -> Result<()> {\n    let meta = if legacy {\n        Meta::new_legacy()\n    } else {\n        Meta::new()\n    };\n    let mut col_file = open_file(col_path)?;\n    let col_size = col_file.metadata()?.len() as usize;\n    let media = if let Some(path) = media_dir {\n        MediaIter::from_folder(&path)?\n    } else {\n        MediaIter::empty()\n    };\n\n    export_collection(meta, out_path, &mut col_file, col_size, media, tr, progress)\n}\n\n/// Write copied collection data without any media.\npub(crate) fn export_colpkg_from_data(\n    out_path: impl AsRef<Path>,\n    mut col_data: &[u8],\n    tr: &I18n,\n) -> Result<()> {\n    let col_size = col_data.len();\n    let mut progress = ThrottlingProgressHandler::new(Default::default());\n    export_collection(\n        Meta::new(),\n        out_path,\n        &mut col_data,\n        col_size,\n        MediaIter::empty(),\n        tr,\n        &mut progress,\n    )\n}\n\npub(crate) fn export_collection(\n    meta: Meta,\n    out_path: impl AsRef<Path>,\n    col: &mut impl Read,\n    col_size: usize,\n    media: MediaIter,\n    tr: &I18n,\n    progress: &mut ThrottlingProgressHandler<ExportProgress>,\n) -> Result<()> {\n    let out_file = File::create(&out_path)?;\n    let mut zip = ZipWriter::new(out_file);\n\n    zip.start_file(\"meta\", file_options_stored())?;\n    let mut meta_bytes = vec![];\n    meta.encode(&mut meta_bytes)?;\n    zip.write_all(&meta_bytes)?;\n    write_collection(&meta, &mut zip, col, col_size)?;\n    write_dummy_collection(&mut zip, tr)?;\n    write_media(&meta, &mut zip, media, progress)?;\n    zip.finish()?;\n\n    Ok(())\n}\n\nfn file_options_stored() -> FileOptions<'static, ()> {\n    FileOptions::<'static, ()>::default().compression_method(CompressionMethod::Stored)\n}\n\nfn file_options_default() -> FileOptions<'static, ()> {\n    FileOptions::<'static, ()>::default()\n}\n\nfn write_collection(\n    meta: &Meta,\n    zip: &mut ZipWriter<File>,\n    col: &mut impl Read,\n    size: usize,\n) -> Result<()> {\n    if meta.zstd_compressed() {\n        zip.start_file(meta.collection_filename(), file_options_stored())?;\n        zstd_copy(col, zip, size)?;\n    } else {\n        zip.start_file(meta.collection_filename(), file_options_default())?;\n        io::copy(col, zip)?;\n    }\n    Ok(())\n}\n\nfn write_dummy_collection(zip: &mut ZipWriter<File>, tr: &I18n) -> Result<()> {\n    let mut tempfile = create_dummy_collection_file(tr)?;\n    zip.start_file(\n        Version::Legacy1.collection_filename(),\n        file_options_stored(),\n    )?;\n    io::copy(&mut tempfile, zip)?;\n\n    Ok(())\n}\n\nfn create_dummy_collection_file(tr: &I18n) -> Result<NamedTempFile> {\n    let tempfile = new_tempfile()?;\n    let mut dummy_col = CollectionBuilder::new(tempfile.path()).build()?;\n    dummy_col.add_dummy_note(tr)?;\n    dummy_col\n        .storage\n        .db\n        .execute_batch(\"pragma page_size=512; pragma journal_mode=delete; vacuum;\")?;\n    dummy_col.close(Some(SchemaVersion::V11))?;\n\n    Ok(tempfile)\n}\n\nimpl Collection {\n    fn add_dummy_note(&mut self, tr: &I18n) -> Result<()> {\n        let notetype = self.get_notetype_by_name(\"basic\")?.unwrap();\n        let mut note = notetype.new_note();\n        note.set_field(0, tr.exporting_colpkg_too_new())?;\n        self.add_note(&mut note, DeckId(1))?;\n        Ok(())\n    }\n}\n\n/// Copy contents of reader into writer, compressing as we copy.\nfn zstd_copy(reader: &mut impl Read, writer: &mut impl Write, size: usize) -> Result<()> {\n    let mut encoder = Encoder::new(writer, 0)?;\n    if size > MULTITHREAD_MIN_BYTES {\n        encoder.multithread(num_cpus::get() as u32)?;\n    }\n    io::copy(reader, &mut encoder)?;\n    encoder.finish()?;\n    Ok(())\n}\n\nfn write_media(\n    meta: &Meta,\n    zip: &mut ZipWriter<File>,\n    media: MediaIter,\n    progress: &mut ThrottlingProgressHandler<ExportProgress>,\n) -> Result<()> {\n    let mut media_entries = vec![];\n    write_media_files(meta, zip, media, &mut media_entries, progress)?;\n    write_media_map(meta, media_entries, zip)?;\n    Ok(())\n}\n\nfn write_media_map(\n    meta: &Meta,\n    media_entries: Vec<MediaEntry>,\n    zip: &mut ZipWriter<File>,\n) -> Result<()> {\n    zip.start_file(\"media\", file_options_stored())?;\n    let encoded_bytes = if meta.media_list_is_hashmap() {\n        let map: HashMap<String, &str> = media_entries\n            .iter()\n            .enumerate()\n            .map(|(k, entry)| (k.to_string(), entry.name.as_str()))\n            .collect();\n        serde_json::to_vec(&map)?\n    } else {\n        let mut buf = vec![];\n        MediaEntries {\n            entries: media_entries,\n        }\n        .encode(&mut buf)?;\n        buf\n    };\n    let size = encoded_bytes.len();\n    let mut cursor = io::Cursor::new(encoded_bytes);\n    if meta.zstd_compressed() {\n        zstd_copy(&mut cursor, zip, size)?;\n    } else {\n        io::copy(&mut cursor, zip)?;\n    }\n    Ok(())\n}\n\nfn write_media_files(\n    meta: &Meta,\n    zip: &mut ZipWriter<File>,\n    media: MediaIter,\n    media_entries: &mut Vec<MediaEntry>,\n    progress: &mut ThrottlingProgressHandler<ExportProgress>,\n) -> Result<()> {\n    let mut copier = MediaCopier::new(meta.zstd_compressed());\n    let mut incrementor = progress.incrementor(ExportProgress::Media);\n    for (index, res) in media.0.enumerate() {\n        incrementor.increment()?;\n        let mut entry = res?;\n\n        zip.start_file(index.to_string(), file_options_stored())?;\n\n        let (size, sha1) = copier.copy(&mut entry.data, zip)?;\n        media_entries.push(new_media_entry(entry.nfc_filename, size, sha1));\n    }\n\n    Ok(())\n}\n\npub(crate) enum MaybeEncodedWriter<'a, W: Write> {\n    Stored(&'a mut W),\n    Encoded(zio::Writer<&'a mut W, RawEncoder<'static>>),\n}\n\nimpl<'a, W: Write> MaybeEncodedWriter<'a, W> {\n    pub fn new(writer: &'a mut W, encoder: Option<RawEncoder<'static>>) -> Self {\n        if let Some(encoder) = encoder {\n            Self::Encoded(zio::Writer::new(writer, encoder))\n        } else {\n            Self::Stored(writer)\n        }\n    }\n\n    pub fn write(&mut self, buf: &[u8]) -> Result<()> {\n        match self {\n            Self::Stored(writer) => writer.write_all(buf)?,\n            Self::Encoded(writer) => writer.write_all(buf)?,\n        };\n        Ok(())\n    }\n\n    pub fn finish(self) -> Result<Option<RawEncoder<'static>>> {\n        Ok(match self {\n            Self::Stored(_) => None,\n            Self::Encoded(mut writer) => {\n                writer.finish()?;\n                Some(writer.into_inner().1)\n            }\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::media::files::sha1_of_data;\n\n    #[test]\n    fn media_file_writing() {\n        let bytes = b\"foo\";\n        let bytes_hash = sha1_of_data(b\"foo\");\n\n        for meta in [Meta::new_legacy(), Meta::new()] {\n            let mut writer = MediaCopier::new(meta.zstd_compressed());\n            let mut buf = Vec::new();\n\n            let (size, hash) = writer.copy(&mut bytes.as_slice(), &mut buf).unwrap();\n            if meta.zstd_compressed() {\n                buf = zstd::decode_all(buf.as_slice()).unwrap();\n            }\n\n            assert_eq!(buf, bytes);\n            assert_eq!(size, bytes.len());\n            assert_eq!(hash, bytes_hash);\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/colpkg/import.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs::File;\nuse std::io;\nuse std::io::Write;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::atomic_rename;\nuse anki_io::create_dir_all;\nuse anki_io::new_tempfile_in_parent_of;\nuse anki_io::open_file;\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\nuse zip::read::ZipFile;\nuse zip::ZipArchive;\nuse zstd::stream::copy_decode;\n\nuse super::super::meta::MetaExt;\nuse crate::collection::CollectionBuilder;\nuse crate::import_export::package::media::extract_media_entries;\nuse crate::import_export::package::media::SafeMediaEntry;\nuse crate::import_export::package::Meta;\nuse crate::import_export::ImportError;\nuse crate::import_export::ImportProgress;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\n\npub fn import_colpkg(\n    colpkg_path: &str,\n    target_col_path: &str,\n    target_media_folder: &Path,\n    media_db: &Path,\n    mut progress: ThrottlingProgressHandler<ImportProgress>,\n) -> Result<()> {\n    let col_path = PathBuf::from(target_col_path);\n    let mut tempfile = new_tempfile_in_parent_of(&col_path)?;\n\n    let backup_file = open_file(colpkg_path)?;\n    let mut archive = ZipArchive::new(backup_file)?;\n    let meta = Meta::from_archive(&mut archive)?;\n\n    copy_collection(&mut archive, &mut tempfile, &meta)?;\n    progress.set(ImportProgress::File)?;\n    check_collection_and_mod_schema(tempfile.path())?;\n    progress.set(ImportProgress::File)?;\n\n    restore_media(\n        &meta,\n        &mut progress,\n        &mut archive,\n        target_media_folder,\n        media_db,\n    )?;\n\n    atomic_rename(tempfile, &col_path, true)?;\n\n    Ok(())\n}\n\nfn check_collection_and_mod_schema(col_path: &Path) -> Result<()> {\n    CollectionBuilder::new(col_path)\n        .build()\n        .ok()\n        .and_then(|mut col| {\n            col.set_schema_modified().ok()?;\n            col.set_modified().ok()?;\n            col.storage\n                .db\n                .pragma_query_value(None, \"integrity_check\", |row| row.get::<_, String>(0))\n                .ok()\n        })\n        .and_then(|s| (s == \"ok\").then_some(()))\n        .ok_or(AnkiError::ImportError {\n            source: ImportError::Corrupt,\n        })\n}\n\nfn restore_media(\n    meta: &Meta,\n    progress: &mut ThrottlingProgressHandler<ImportProgress>,\n    archive: &mut ZipArchive<File>,\n    media_folder: &Path,\n    media_db: &Path,\n) -> Result<()> {\n    let media_entries = extract_media_entries(meta, archive)?;\n    if media_entries.is_empty() {\n        return Ok(());\n    }\n\n    create_dir_all(media_folder)?;\n    let media_manager = MediaManager::new(media_folder, media_db)?;\n    let mut media_comparer = MediaComparer::new(meta, progress, &media_manager)?;\n\n    let mut incrementor = progress.incrementor(ImportProgress::Media);\n    for mut entry in media_entries {\n        incrementor.increment()?;\n        maybe_restore_media_file(meta, media_folder, archive, &mut entry, &mut media_comparer)?;\n    }\n\n    Ok(())\n}\n\nfn maybe_restore_media_file(\n    meta: &Meta,\n    media_folder: &Path,\n    archive: &mut ZipArchive<File>,\n    entry: &mut SafeMediaEntry,\n    media_comparer: &mut MediaComparer,\n) -> Result<()> {\n    let file_path = entry.file_path(media_folder);\n    let mut zip_file = entry.fetch_file(archive)?;\n    if meta.media_list_is_hashmap() {\n        entry.size = zip_file.size() as u32;\n    }\n\n    let already_exists = media_comparer.entry_is_equal_to(entry, &file_path)?;\n    if !already_exists {\n        restore_media_file(meta, &mut zip_file, &file_path)?;\n    };\n\n    Ok(())\n}\n\nfn restore_media_file(meta: &Meta, zip_file: &mut ZipFile<File>, path: &Path) -> Result<()> {\n    let mut tempfile = new_tempfile_in_parent_of(path)?;\n    meta.copy(zip_file, &mut tempfile)\n        .with_context(|_| FileIoSnafu {\n            path: tempfile.path(),\n            op: FileOp::copy(zip_file.name()),\n        })?;\n    atomic_rename(tempfile, path, false)?;\n    Ok(())\n}\n\nfn copy_collection(\n    archive: &mut ZipArchive<File>,\n    writer: &mut impl Write,\n    meta: &Meta,\n) -> Result<()> {\n    let mut file =\n        archive\n            .by_name(meta.collection_filename())\n            .map_err(|_| AnkiError::ImportError {\n                source: ImportError::Corrupt,\n            })?;\n    if !meta.zstd_compressed() {\n        io::copy(&mut file, writer)?;\n    } else {\n        copy_decode(file, writer)?;\n    }\n\n    Ok(())\n}\n\ntype GetChecksumFn<'a> = dyn FnMut(&str) -> Result<Option<Sha1Hash>> + 'a;\n\nstruct MediaComparer<'a>(Option<Box<GetChecksumFn<'a>>>);\n\nimpl<'a> MediaComparer<'a> {\n    fn new(\n        meta: &Meta,\n        progress: &mut ThrottlingProgressHandler<ImportProgress>,\n        media_manager: &'a MediaManager,\n    ) -> Result<Self> {\n        Ok(Self(if meta.media_list_is_hashmap() {\n            None\n        } else {\n            let mut db_progress_fn = progress.media_db_fn(ImportProgress::MediaCheck)?;\n            media_manager.register_changes(&mut db_progress_fn)?;\n            Some(Box::new(media_manager.checksum_getter()))\n        }))\n    }\n\n    fn entry_is_equal_to(&mut self, entry: &SafeMediaEntry, other_path: &Path) -> Result<bool> {\n        if let Some(ref mut get_checksum) = self.0 {\n            Ok(entry.has_checksum_equal_to(get_checksum)?)\n        } else {\n            Ok(entry.has_size_equal_to(other_path))\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/colpkg/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(super) mod export;\npub(super) mod import;\nmod tests;\n"
  },
  {
    "path": "rslib/src/import_export/package/colpkg/tests.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n\nuse std::path::Path;\n\nuse anki_io::create_dir_all;\nuse anki_io::read_file;\nuse tempfile::tempdir;\n\nuse crate::collection::CollectionBuilder;\nuse crate::import_export::package::import_colpkg;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\n\nfn collection_with_media(dir: &Path, name: &str) -> Result<Collection> {\n    let name = format!(\"{name}_src\");\n    // add collection with sentinel note\n    let mut col = CollectionBuilder::new(dir.join(format!(\"{name}.anki2\")))\n        .with_desktop_media_paths()\n        .build()?;\n    let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n    let mut note = nt.new_note();\n    col.add_note(&mut note, DeckId(1))?;\n    // add sample media\n    let mgr = col.media()?;\n    mgr.add_file(\"1\", b\"1\")?;\n    mgr.add_file(\"2\", b\"2\")?;\n    mgr.add_file(\"3\", b\"3\")?;\n    Ok(col)\n}\n\n#[test]\nfn roundtrip() -> Result<()> {\n    let _dir = tempdir()?;\n    let dir = _dir.path();\n\n    for (legacy, name) in [(true, \"legacy\"), (false, \"v3\")] {\n        // export to a file\n        let col = collection_with_media(dir, name)?;\n        let colpkg_name = dir.join(format!(\"{name}.colpkg\"));\n        let progress = col.new_progress_handler();\n        col.export_colpkg(&colpkg_name, true, legacy)?;\n\n        // import into a new collection\n        let anki2_name = dir\n            .join(format!(\"{name}.anki2\"))\n            .to_string_lossy()\n            .into_owned();\n        let import_media_dir = dir.join(format!(\"{name}.media\"));\n        create_dir_all(&import_media_dir)?;\n        let import_media_db = dir.join(format!(\"{name}.mdb\"));\n        MediaManager::new(&import_media_dir, &import_media_db)?;\n        import_colpkg(\n            &colpkg_name.to_string_lossy(),\n            &anki2_name,\n            &import_media_dir,\n            &import_media_db,\n            progress,\n        )?;\n\n        // confirm collection imported\n        let col = CollectionBuilder::new(&anki2_name).build()?;\n        assert_eq!(\n            col.storage.db_scalar::<i32>(\"select count() from notes\")?,\n            1\n        );\n        // confirm media imported correctly\n        assert_eq!(read_file(import_media_dir.join(\"1\"))?, b\"1\");\n        assert_eq!(read_file(import_media_dir.join(\"2\"))?, b\"2\");\n        assert_eq!(read_file(import_media_dir.join(\"3\"))?, b\"3\");\n    }\n\n    Ok(())\n}\n\n/// Files with an invalid encoding should prevent export, except\n/// on Apple platforms where the encoding is transparently changed.\n#[test]\n#[cfg(not(target_vendor = \"apple\"))]\nfn normalization_check_on_export() -> Result<()> {\n    use anki_io::write_file;\n\n    let _dir = tempdir()?;\n    let dir = _dir.path();\n\n    let col = collection_with_media(dir, \"normalize\")?;\n    let colpkg_name = dir.join(\"normalize.colpkg\");\n    // manually write a file in the wrong encoding.\n    write_file(col.media_folder.join(\"ぱぱ.jpg\"), \"nfd encoding\")?;\n    assert_eq!(\n        col.export_colpkg(&colpkg_name, true, false,).unwrap_err(),\n        AnkiError::MediaCheckRequired\n    );\n    // file should have been cleaned up\n    assert!(!colpkg_name.exists());\n\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/media.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::ffi::OsString;\nuse std::fs;\nuse std::fs::File;\nuse std::io;\nuse std::io::Read;\nuse std::io::Write;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::atomic_rename;\nuse anki_io::filename_is_safe;\nuse anki_io::new_tempfile_in;\nuse anki_io::read_dir_files;\nuse anki_io::FileIoError;\nuse anki_io::FileOp;\nuse prost::Message;\nuse sha1::Digest;\nuse sha1::Sha1;\nuse zip::read::ZipFile;\nuse zip::result::ZipError;\nuse zip::ZipArchive;\nuse zstd::stream::copy_decode;\nuse zstd::stream::raw::Encoder as RawEncoder;\n\nuse super::meta::MetaExt;\nuse super::MediaEntries;\nuse super::MediaEntry;\nuse super::Meta;\nuse crate::error::InvalidInputError;\nuse crate::import_export::package::colpkg::export::MaybeEncodedWriter;\nuse crate::import_export::ImportError;\nuse crate::media::files::filename_if_normalized;\nuse crate::media::files::normalize_filename;\nuse crate::prelude::*;\n\n/// Like [MediaEntry], but with a safe filename and set zip filename.\n#[derive(Debug)]\npub(super) struct SafeMediaEntry {\n    pub(super) name: String,\n    pub(super) size: u32,\n    pub(super) sha1: Option<Sha1Hash>,\n    pub(super) index: usize,\n}\n\npub(super) fn new_media_entry(\n    name: impl Into<String>,\n    size: impl TryInto<u32>,\n    sha1: impl Into<Vec<u8>>,\n) -> MediaEntry {\n    MediaEntry {\n        name: name.into(),\n        size: size.try_into().unwrap_or_default(),\n        sha1: sha1.into(),\n        legacy_zip_filename: None,\n    }\n}\n\nimpl SafeMediaEntry {\n    pub(super) fn from_entry(enumerated: (usize, MediaEntry)) -> Result<Self> {\n        let (index, entry) = enumerated;\n        if let Ok(sha1) = entry.sha1.try_into() {\n            if !matches!(safe_normalized_file_name(&entry.name)?, Cow::Owned(_)) {\n                return Ok(Self {\n                    name: entry.name,\n                    size: entry.size,\n                    sha1: Some(sha1),\n                    index,\n                });\n            }\n        }\n        Err(AnkiError::ImportError {\n            source: ImportError::Corrupt,\n        })\n    }\n\n    pub(super) fn from_legacy(legacy_entry: (&str, String)) -> Result<Self> {\n        let zip_filename: usize = legacy_entry.0.parse()?;\n        let name = match safe_normalized_file_name(&legacy_entry.1)? {\n            Cow::Owned(new_name) => new_name,\n            Cow::Borrowed(_) => legacy_entry.1,\n        };\n        Ok(Self {\n            name,\n            size: 0,\n            sha1: None,\n            index: zip_filename,\n        })\n    }\n\n    pub(super) fn file_path(&self, media_folder: &Path) -> PathBuf {\n        media_folder.join(&self.name)\n    }\n\n    pub(super) fn fetch_file<'a>(\n        &self,\n        archive: &'a mut ZipArchive<File>,\n    ) -> Result<ZipFile<'a, File>> {\n        match archive.by_name(&self.index.to_string()) {\n            Ok(file) => Ok(file),\n            Err(err) => invalid_input!(err, \"{} missing from archive\", self.index),\n        }\n    }\n\n    pub(super) fn has_checksum_equal_to(\n        &self,\n        get_checksum: &mut impl FnMut(&str) -> Result<Option<Sha1Hash>>,\n    ) -> Result<bool> {\n        get_checksum(&self.name)\n            .map(|opt| opt.is_some_and(|sha1| sha1 == self.sha1.expect(\"sha1 not set\")))\n    }\n\n    pub(super) fn has_size_equal_to(&self, other_path: &Path) -> bool {\n        fs::metadata(other_path).is_ok_and(|metadata| metadata.len() == self.size as u64)\n    }\n\n    /// Copy the archived file to the target folder, setting its hash if\n    /// necessary.\n    pub(super) fn copy_and_ensure_sha1_set(\n        &mut self,\n        archive: &mut ZipArchive<File>,\n        target_folder: &Path,\n        copier: &mut MediaCopier,\n        compressed: bool,\n    ) -> Result<()> {\n        let mut file = self.fetch_file(archive)?;\n        let mut tempfile = new_tempfile_in(target_folder)?;\n        if compressed {\n            copy_decode(&mut file, &mut tempfile)?\n        } else {\n            let (_, sha1) = copier.copy(&mut file, &mut tempfile)?;\n            self.sha1 = Some(sha1);\n        }\n        atomic_rename(tempfile, &self.file_path(target_folder), false)?;\n\n        Ok(())\n    }\n}\n\npub(super) fn extract_media_entries(\n    meta: &Meta,\n    archive: &mut ZipArchive<File>,\n) -> Result<Vec<SafeMediaEntry>> {\n    let media_list_data = get_media_list_data(archive, meta)?;\n    if meta.media_list_is_hashmap() {\n        let map: HashMap<&str, String> = serde_json::from_slice(&media_list_data)?;\n        map.into_iter().map(SafeMediaEntry::from_legacy).collect()\n    } else {\n        decode_safe_entries(&media_list_data)\n    }\n}\n\npub(super) fn safe_normalized_file_name(name: &str) -> Result<Cow<'_, str>> {\n    if !filename_is_safe(name) {\n        Err(AnkiError::ImportError {\n            source: ImportError::Corrupt,\n        })\n    } else {\n        Ok(normalize_filename(name))\n    }\n}\n\nfn get_media_list_data(archive: &mut ZipArchive<File>, meta: &Meta) -> Result<Vec<u8>> {\n    let mut file = match archive.by_name(\"media\") {\n        Ok(file) => file,\n        Err(ZipError::FileNotFound) => {\n            // Older AnkiDroid versions wrote colpkg files without a media map\n            return Ok(b\"{}\".to_vec());\n        }\n        err => err?,\n    };\n    let mut buf = Vec::new();\n    if meta.zstd_compressed() {\n        copy_decode(file, &mut buf)?;\n    } else {\n        io::copy(&mut file, &mut buf)?;\n    }\n    Ok(buf)\n}\n\npub(super) fn decode_safe_entries(buf: &[u8]) -> Result<Vec<SafeMediaEntry>> {\n    let entries: MediaEntries = Message::decode(buf)?;\n    entries\n        .entries\n        .into_iter()\n        .enumerate()\n        .map(SafeMediaEntry::from_entry)\n        .collect()\n}\n\npub struct MediaIterEntry {\n    pub nfc_filename: String,\n    pub data: Box<dyn Read>,\n}\n\n#[derive(Debug)]\npub enum MediaIterError {\n    InvalidFilename {\n        filename: OsString,\n    },\n    IoError {\n        filename: String,\n        source: io::Error,\n    },\n    Other {\n        source: Box<dyn std::error::Error + Send + Sync>,\n    },\n}\n\nimpl TryFrom<&Path> for MediaIterEntry {\n    type Error = MediaIterError;\n\n    fn try_from(value: &Path) -> std::result::Result<Self, Self::Error> {\n        let nfc_filename: String = value\n            .file_name()\n            .and_then(|s| s.to_str())\n            .and_then(filename_if_normalized)\n            .ok_or_else(|| MediaIterError::InvalidFilename {\n                filename: value.as_os_str().to_owned(),\n            })?\n            .into();\n        let file = File::open(value).map_err(|err| MediaIterError::IoError {\n            filename: nfc_filename.clone(),\n            source: err,\n        })?;\n        Ok(MediaIterEntry {\n            nfc_filename,\n            data: Box::new(file) as _,\n        })\n    }\n}\n\nimpl From<MediaIterError> for AnkiError {\n    fn from(err: MediaIterError) -> Self {\n        match err {\n            MediaIterError::InvalidFilename { .. } => AnkiError::MediaCheckRequired,\n            MediaIterError::IoError { filename, source } => FileIoError {\n                path: filename.into(),\n                op: FileOp::Read,\n                source,\n            }\n            .into(),\n            MediaIterError::Other { source } => InvalidInputError {\n                message: \"\".to_string(),\n                source: Some(source),\n                backtrace: None,\n            }\n            .into(),\n        }\n    }\n}\n\npub struct MediaIter(pub Box<dyn Iterator<Item = Result<MediaIterEntry, MediaIterError>>>);\n\nimpl MediaIter {\n    pub fn new<I>(iter: I) -> Self\n    where\n        I: Iterator<Item = Result<MediaIterEntry, MediaIterError>> + 'static,\n    {\n        Self(Box::new(iter))\n    }\n\n    /// Iterator over all files in the given path, without traversing\n    /// subfolders.\n    pub fn from_folder(path: &Path) -> Result<Self> {\n        let path2 = path.to_owned();\n        Ok(Self::new(read_dir_files(path)?.map(move |res| match res {\n            Ok(entry) => MediaIterEntry::try_from(entry.path().as_path()),\n            Err(err) => Err(MediaIterError::IoError {\n                filename: path2.to_string_lossy().into(),\n                source: err,\n            }),\n        })))\n    }\n\n    /// Iterator over all given files in the given folder.\n    /// Missing files are silently ignored.\n    pub fn from_file_list(\n        list: impl IntoIterator<Item = String> + 'static,\n        folder: PathBuf,\n    ) -> Self {\n        Self::new(\n            list.into_iter()\n                .map(move |file| folder.join(file))\n                .filter(|path| path.exists())\n                .map(|path| MediaIterEntry::try_from(path.as_path())),\n        )\n    }\n\n    pub fn empty() -> Self {\n        Self::new([].into_iter())\n    }\n}\n\n/// Copies and hashes while optionally encoding.\n/// If compressing, the encoder is reused to optimize for repeated calls.\npub(crate) struct MediaCopier {\n    encoding: bool,\n    encoder: Option<RawEncoder<'static>>,\n    buf: [u8; 64 * 1024],\n}\n\nimpl MediaCopier {\n    pub(crate) fn new(encoding: bool) -> Self {\n        Self {\n            encoding,\n            encoder: None,\n            buf: [0; 64 * 1024],\n        }\n    }\n\n    fn encoder(&mut self) -> Option<RawEncoder<'static>> {\n        self.encoding.then(|| {\n            self.encoder\n                .take()\n                .unwrap_or_else(|| RawEncoder::with_dictionary(0, &[]).unwrap())\n        })\n    }\n\n    /// Returns size and sha1 hash of the copied data.\n    pub(crate) fn copy(\n        &mut self,\n        reader: &mut impl Read,\n        writer: &mut impl Write,\n    ) -> Result<(usize, Sha1Hash)> {\n        let mut size = 0;\n        let mut hasher = Sha1::new();\n        self.buf = [0; 64 * 1024];\n        let mut wrapped_writer = MaybeEncodedWriter::new(writer, self.encoder());\n\n        loop {\n            let count = match reader.read(&mut self.buf) {\n                Ok(0) => break,\n                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,\n                result => result?,\n            };\n            size += count;\n            hasher.update(&self.buf[..count]);\n            wrapped_writer.write(&self.buf[..count])?;\n        }\n\n        self.encoder = wrapped_writer.finish()?;\n\n        Ok((size, hasher.finalize().into()))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn normalization() {\n        // legacy entries get normalized on deserialisation\n        let entry = SafeMediaEntry::from_legacy((\"1\", \"con\".to_owned())).unwrap();\n        assert_eq!(entry.name, \"con_\");\n\n        // new-style entries should have been normalized on export\n        let mut entries = Vec::new();\n        MediaEntries {\n            entries: vec![new_media_entry(\"con\", 0, Vec::new())],\n        }\n        .encode(&mut entries)\n        .unwrap();\n        assert!(decode_safe_entries(&entries).is_err());\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/meta.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs::File;\nuse std::io;\nuse std::io::Read;\n\npub(super) use anki_proto::import_export::package_metadata::Version;\npub(super) use anki_proto::import_export::PackageMetadata as Meta;\nuse prost::Message;\nuse zip::ZipArchive;\nuse zstd::stream::copy_decode;\n\nuse crate::import_export::ImportError;\nuse crate::prelude::*;\nuse crate::storage::SchemaVersion;\n\npub(super) trait VersionExt {\n    fn collection_filename(&self) -> &'static str;\n    fn schema_version(&self) -> SchemaVersion;\n}\n\nimpl VersionExt for Version {\n    fn collection_filename(&self) -> &'static str {\n        match self {\n            Version::Unknown => unreachable!(),\n            Version::Legacy1 => \"collection.anki2\",\n            Version::Legacy2 => \"collection.anki21\",\n            Version::Latest => \"collection.anki21b\",\n        }\n    }\n\n    /// Latest schema version that is supported by all clients supporting\n    /// this package version.\n    fn schema_version(&self) -> SchemaVersion {\n        match self {\n            Version::Unknown => unreachable!(),\n            Version::Legacy1 | Version::Legacy2 => SchemaVersion::V11,\n            Version::Latest => SchemaVersion::V18,\n        }\n    }\n}\n\npub(in crate::import_export) trait MetaExt: Sized {\n    fn new() -> Self;\n    fn new_legacy() -> Self;\n    fn from_archive(archive: &mut ZipArchive<File>) -> Result<Self>;\n    fn collection_filename(&self) -> &'static str;\n    fn schema_version(&self) -> SchemaVersion;\n    fn zstd_compressed(&self) -> bool;\n    fn media_list_is_hashmap(&self) -> bool;\n    fn is_legacy(&self) -> bool;\n    fn copy(&self, reader: &mut impl Read, writer: &mut impl io::Write) -> io::Result<()>;\n}\n\nimpl MetaExt for Meta {\n    fn new() -> Self {\n        Self {\n            version: Version::Latest as i32,\n        }\n    }\n\n    fn new_legacy() -> Self {\n        Self {\n            version: Version::Legacy2 as i32,\n        }\n    }\n\n    /// Extracts meta data from an archive and checks if its version is\n    /// supported.\n    fn from_archive(archive: &mut ZipArchive<File>) -> Result<Self> {\n        let meta_bytes = archive.by_name(\"meta\").ok().and_then(|mut meta_file| {\n            let mut buf = vec![];\n            meta_file.read_to_end(&mut buf).ok()?;\n            Some(buf)\n        });\n        let meta = if let Some(bytes) = meta_bytes {\n            let meta: Meta = Message::decode(&*bytes)?;\n            if meta.version() == Version::Unknown {\n                return Err(AnkiError::ImportError {\n                    source: ImportError::TooNew,\n                });\n            }\n            meta\n        } else {\n            Meta {\n                version: if archive.by_name(\"collection.anki21\").is_ok() {\n                    Version::Legacy2\n                } else {\n                    Version::Legacy1\n                } as i32,\n            }\n        };\n        Ok(meta)\n    }\n\n    fn collection_filename(&self) -> &'static str {\n        self.version().collection_filename()\n    }\n\n    /// Latest schema version that is supported by all clients supporting\n    /// this package version.\n    fn schema_version(&self) -> SchemaVersion {\n        self.version().schema_version()\n    }\n\n    fn zstd_compressed(&self) -> bool {\n        !self.is_legacy()\n    }\n\n    fn media_list_is_hashmap(&self) -> bool {\n        self.is_legacy()\n    }\n\n    fn is_legacy(&self) -> bool {\n        matches!(self.version(), Version::Legacy1 | Version::Legacy2)\n    }\n\n    fn copy(&self, reader: &mut impl Read, writer: &mut impl io::Write) -> io::Result<()> {\n        if self.zstd_compressed() {\n            copy_decode(reader, writer)\n        } else {\n            io::copy(reader, writer).map(|_| ())\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/package/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod apkg;\nmod colpkg;\nmod media;\nmod meta;\n\nuse anki_proto::import_export::media_entries::MediaEntry;\npub use anki_proto::import_export::ExportAnkiPackageOptions;\npub use anki_proto::import_export::ImportAnkiPackageOptions;\npub use anki_proto::import_export::ImportAnkiPackageUpdateCondition as UpdateCondition;\nuse anki_proto::import_export::MediaEntries;\npub(crate) use apkg::NoteMeta;\npub(crate) use colpkg::export::export_colpkg_from_data;\npub use colpkg::import::import_colpkg;\npub use media::MediaIter;\npub use media::MediaIterEntry;\npub use media::MediaIterError;\nuse meta::Meta;\nuse meta::Version;\n"
  },
  {
    "path": "rslib/src/import_export/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::generic;\nuse anki_proto::import_export::import_response::Log as NoteLog;\nuse anki_proto::import_export::ExportLimit;\n\nuse crate::prelude::*;\nuse crate::search::SearchNode;\n\nimpl crate::services::ImportExportService for Collection {\n    fn import_anki_package(\n        &mut self,\n        input: anki_proto::import_export::ImportAnkiPackageRequest,\n    ) -> Result<anki_proto::import_export::ImportResponse> {\n        self.import_apkg(&input.package_path, input.options.unwrap_or_default())\n            .map(Into::into)\n    }\n\n    fn get_import_anki_package_presets(\n        &mut self,\n    ) -> Result<anki_proto::import_export::ImportAnkiPackageOptions> {\n        Ok(anki_proto::import_export::ImportAnkiPackageOptions {\n            merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes),\n            with_scheduling: self.get_config_bool(BoolKey::WithScheduling),\n            with_deck_configs: self.get_config_bool(BoolKey::WithDeckConfigs),\n            update_notes: self.get_update_notes() as i32,\n            update_notetypes: self.get_update_notetypes() as i32,\n        })\n    }\n\n    fn export_anki_package(\n        &mut self,\n        input: anki_proto::import_export::ExportAnkiPackageRequest,\n    ) -> Result<generic::UInt32> {\n        self.export_apkg(\n            &input.out_path,\n            input.options.unwrap_or_default(),\n            input.limit.unwrap_or_default(),\n            None,\n        )\n        .map(Into::into)\n    }\n\n    fn get_csv_metadata(\n        &mut self,\n        input: anki_proto::import_export::CsvMetadataRequest,\n    ) -> Result<anki_proto::import_export::CsvMetadata> {\n        let delimiter = input.delimiter.is_some().then(|| input.delimiter());\n\n        self.get_csv_metadata(\n            &input.path,\n            delimiter,\n            input.notetype_id.map(Into::into),\n            input.deck_id.map(Into::into),\n            input.is_html,\n        )\n    }\n\n    fn import_csv(\n        &mut self,\n        input: anki_proto::import_export::ImportCsvRequest,\n    ) -> Result<anki_proto::import_export::ImportResponse> {\n        self.import_csv(&input.path, input.metadata.unwrap_or_default())\n            .map(Into::into)\n    }\n\n    fn export_note_csv(\n        &mut self,\n        input: anki_proto::import_export::ExportNoteCsvRequest,\n    ) -> Result<generic::UInt32> {\n        self.export_note_csv(input).map(Into::into)\n    }\n\n    fn export_card_csv(\n        &mut self,\n        input: anki_proto::import_export::ExportCardCsvRequest,\n    ) -> Result<generic::UInt32> {\n        self.export_card_csv(\n            &input.out_path,\n            SearchNode::from(input.limit.unwrap_or_default()),\n            input.with_html,\n        )\n        .map(Into::into)\n    }\n\n    fn import_json_file(\n        &mut self,\n        input: generic::String,\n    ) -> Result<anki_proto::import_export::ImportResponse> {\n        self.import_json_file(&input.val).map(Into::into)\n    }\n\n    fn import_json_string(\n        &mut self,\n        input: generic::String,\n    ) -> Result<anki_proto::import_export::ImportResponse> {\n        self.import_json_string(&input.val).map(Into::into)\n    }\n}\n\nimpl From<OpOutput<NoteLog>> for anki_proto::import_export::ImportResponse {\n    fn from(output: OpOutput<NoteLog>) -> Self {\n        Self {\n            changes: Some(output.changes.into()),\n            log: Some(output.output),\n        }\n    }\n}\n\nimpl From<ExportLimit> for SearchNode {\n    fn from(export_limit: ExportLimit) -> Self {\n        use anki_proto::import_export::export_limit::Limit;\n        let limit = export_limit\n            .limit\n            .unwrap_or(Limit::WholeCollection(generic::Empty {}));\n        match limit {\n            Limit::WholeCollection(_) => Self::WholeCollection,\n            Limit::DeckId(did) => Self::from_deck_id(did, true),\n            Limit::NoteIds(nids) => Self::from_note_ids(nids.note_ids),\n            Limit::CardIds(cids) => Self::from_card_ids(cids.cids),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/csv/export.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::fs::File;\nuse std::io::Write;\nuse std::sync::Arc;\nuse std::sync::LazyLock;\n\nuse anki_proto::import_export::ExportNoteCsvRequest;\nuse itertools::Itertools;\nuse regex::Regex;\n\nuse super::metadata::Delimiter;\nuse crate::import_export::text::csv::metadata::DelimeterExt;\nuse crate::import_export::ExportProgress;\nuse crate::notetype::RenderCardOutput;\nuse crate::prelude::*;\nuse crate::search::SearchNode;\nuse crate::search::SortMode;\nuse crate::template::RenderedNode;\nuse crate::text::html_to_text_line;\nuse crate::text::CowMapping;\n\nconst DELIMITER: Delimiter = Delimiter::Tab;\n\nimpl Collection {\n    pub fn export_card_csv(\n        &mut self,\n        path: &str,\n        search: impl TryIntoSearch,\n        with_html: bool,\n    ) -> Result<usize> {\n        let mut progress = self.new_progress_handler::<ExportProgress>();\n        let mut incrementor = progress.incrementor(ExportProgress::Cards);\n\n        let mut writer = file_writer_with_header(path, with_html)?;\n        let mut cards = self.search_cards(search, SortMode::NoOrder)?;\n        cards.sort_unstable();\n        for &card in &cards {\n            incrementor.increment()?;\n            writer\n                .write_record(self.card_record(card, with_html)?)\n                .or_invalid(\"invalid csv\")?;\n        }\n        writer.flush()?;\n\n        Ok(cards.len())\n    }\n\n    pub fn export_note_csv(&mut self, mut request: ExportNoteCsvRequest) -> Result<usize> {\n        let mut progress = self.new_progress_handler::<ExportProgress>();\n        let mut incrementor = progress.incrementor(ExportProgress::Notes);\n\n        let guard = self.search_notes_into_table(Into::<SearchNode>::into(&mut request))?;\n        let ctx = NoteContext::new(&request, guard.col)?;\n        let mut writer = note_file_writer_with_header(&request.out_path, &ctx)?;\n        guard.col.storage.for_each_note_in_search(|note| {\n            incrementor.increment()?;\n            writer\n                .write_record(ctx.record(&note))\n                .or_invalid(\"invalid csv\")?;\n            Ok(())\n        })?;\n        writer.flush()?;\n\n        Ok(incrementor.count())\n    }\n\n    fn card_record(&mut self, card: CardId, with_html: bool) -> Result<[String; 2]> {\n        let RenderCardOutput { qnodes, anodes, .. } =\n            self.render_existing_card(card, false, false)?;\n        Ok([\n            rendered_nodes_to_record_field(&qnodes, with_html, false),\n            rendered_nodes_to_record_field(&anodes, with_html, true),\n        ])\n    }\n}\n\nfn file_writer_with_header(path: &str, with_html: bool) -> Result<csv::Writer<File>> {\n    let mut file = File::create(path)?;\n    write_file_header(&mut file, with_html)?;\n    Ok(csv::WriterBuilder::new()\n        .delimiter(DELIMITER.byte())\n        .comment(Some(b'#'))\n        .from_writer(file))\n}\n\nfn write_file_header(writer: &mut impl Write, with_html: bool) -> Result<()> {\n    writeln!(writer, \"#separator:{}\", DELIMITER.name())?;\n    writeln!(writer, \"#html:{with_html}\")?;\n    Ok(())\n}\n\nfn note_file_writer_with_header(path: &str, ctx: &NoteContext) -> Result<csv::Writer<File>> {\n    let mut file = File::create(path)?;\n    write_note_file_header(&mut file, ctx)?;\n    Ok(csv::WriterBuilder::new()\n        .delimiter(DELIMITER.byte())\n        .comment(Some(b'#'))\n        .from_writer(file))\n}\n\nfn write_note_file_header(writer: &mut impl Write, ctx: &NoteContext) -> Result<()> {\n    write_file_header(writer, ctx.with_html)?;\n    write_column_header(ctx, writer)\n}\n\nfn write_column_header(ctx: &NoteContext, writer: &mut impl Write) -> Result<()> {\n    for (name, column) in [\n        (\"guid\", ctx.guid_column()),\n        (\"notetype\", ctx.notetype_column()),\n        (\"deck\", ctx.deck_column()),\n        (\"tags\", ctx.tags_column()),\n    ] {\n        if let Some(index) = column {\n            writeln!(writer, \"#{name} column:{index}\")?;\n        }\n    }\n    Ok(())\n}\n\nfn rendered_nodes_to_record_field(\n    nodes: &[RenderedNode],\n    with_html: bool,\n    answer_side: bool,\n) -> String {\n    let text = rendered_nodes_to_str(nodes);\n    let mut text = strip_redundant_sections(&text);\n    if answer_side {\n        text = text.map_cow(strip_answer_side_question);\n    }\n    if !with_html {\n        text = text.map_cow(|t| html_to_text_line(t, false));\n    }\n    text.into()\n}\n\nfn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String {\n    nodes\n        .iter()\n        .map(|node| match node {\n            RenderedNode::Text { text } => text,\n            RenderedNode::Replacement { current_text, .. } => current_text,\n        })\n        .join(\"\")\n}\n\nfn field_to_record_field(field: &str, with_html: bool) -> Cow<'_, str> {\n    let mut text = strip_redundant_sections(field);\n    if !with_html {\n        text = text.map_cow(|t| html_to_text_line(t, false));\n    }\n    text\n}\n\nfn strip_redundant_sections(text: &str) -> Cow<'_, str> {\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r\"(?isx)\n            <style>.*?</style>          # style elements\n            |\n            \\[\\[type:[^]]+\\]\\]          # type replacements\n            \",\n        )\n        .unwrap()\n    });\n    RE.replace_all(text.as_ref(), \"\")\n}\n\nfn strip_answer_side_question(text: &str) -> Cow<'_, str> {\n    static RE: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(r\"(?is)^.*<hr id=answer>\\n*\").unwrap());\n    RE.replace_all(text.as_ref(), \"\")\n}\n\nstruct NoteContext {\n    with_html: bool,\n    with_tags: bool,\n    with_deck: bool,\n    with_notetype: bool,\n    with_guid: bool,\n    notetypes: HashMap<NotetypeId, Arc<Notetype>>,\n    deck_ids: HashMap<NoteId, DeckId>,\n    deck_names: HashMap<DeckId, String>,\n    field_columns: usize,\n}\n\nimpl NoteContext {\n    /// Caller must have searched notes into table.\n    fn new(request: &ExportNoteCsvRequest, col: &mut Collection) -> Result<Self> {\n        let notetypes = col.get_all_notetypes_of_search_notes()?;\n        let field_columns = notetypes\n            .values()\n            .map(|nt| nt.fields.len())\n            .max()\n            .unwrap_or_default();\n        let deck_ids = col.storage.all_decks_of_search_notes()?;\n        let deck_names = HashMap::from_iter(col.storage.get_all_deck_names()?);\n\n        Ok(Self {\n            with_html: request.with_html,\n            with_tags: request.with_tags,\n            with_deck: request.with_deck,\n            with_notetype: request.with_notetype,\n            with_guid: request.with_guid,\n            notetypes,\n            field_columns,\n            deck_ids,\n            deck_names,\n        })\n    }\n\n    fn guid_column(&self) -> Option<usize> {\n        self.with_guid.then_some(1)\n    }\n\n    fn notetype_column(&self) -> Option<usize> {\n        self.with_notetype\n            .then(|| 1 + self.guid_column().unwrap_or_default())\n    }\n\n    fn deck_column(&self) -> Option<usize> {\n        self.with_deck.then(|| {\n            1 + self\n                .notetype_column()\n                .or_else(|| self.guid_column())\n                .unwrap_or_default()\n        })\n    }\n\n    fn tags_column(&self) -> Option<usize> {\n        self.with_tags.then(|| {\n            1 + self\n                .deck_column()\n                .or_else(|| self.notetype_column())\n                .or_else(|| self.guid_column())\n                .unwrap_or_default()\n                + self.field_columns\n        })\n    }\n\n    fn record<'c, 's: 'c, 'n: 'c>(&'s self, note: &'n Note) -> impl Iterator<Item = Cow<'c, [u8]>> {\n        self.with_guid\n            .then(|| Cow::from(note.guid.as_bytes()))\n            .into_iter()\n            .chain(self.notetype_name(note))\n            .chain(self.deck_name(note))\n            .chain(self.note_fields(note))\n            .chain(self.tags(note))\n    }\n\n    fn notetype_name(&self, note: &Note) -> Option<Cow<'_, [u8]>> {\n        self.with_notetype.then(|| {\n            self.notetypes\n                .get(&note.notetype_id)\n                .map_or(Cow::from(vec![]), |nt| Cow::from(nt.name.as_bytes()))\n        })\n    }\n\n    fn deck_name(&self, note: &Note) -> Option<Cow<'_, [u8]>> {\n        self.with_deck.then(|| {\n            self.deck_ids\n                .get(&note.id)\n                .and_then(|did| self.deck_names.get(did))\n                .map_or(Cow::from(vec![]), |name| Cow::from(name.as_bytes()))\n        })\n    }\n\n    fn tags(&self, note: &Note) -> Option<Cow<'_, [u8]>> {\n        self.with_tags\n            .then(|| Cow::from(note.tags.join(\" \").into_bytes()))\n    }\n\n    fn note_fields<'n>(&self, note: &'n Note) -> impl Iterator<Item = Cow<'n, [u8]>> {\n        let with_html = self.with_html;\n        note.fields()\n            .iter()\n            .map(move |f| field_to_record_field(f, with_html))\n            .pad_using(self.field_columns, |_| Cow::from(\"\"))\n            .map(|cow| match cow {\n                Cow::Borrowed(s) => Cow::from(s.as_bytes()),\n                Cow::Owned(s) => Cow::from(s.into_bytes()),\n            })\n    }\n}\n\nimpl From<&mut ExportNoteCsvRequest> for SearchNode {\n    fn from(req: &mut ExportNoteCsvRequest) -> Self {\n        SearchNode::from(req.limit.take().unwrap_or_default())\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/csv/import.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::io::BufRead;\nuse std::io::BufReader;\nuse std::io::Read;\nuse std::io::Seek;\nuse std::io::SeekFrom;\n\nuse anki_io::open_file;\n\nuse crate::import_export::text::csv::metadata::CsvDeck;\nuse crate::import_export::text::csv::metadata::CsvMetadata;\nuse crate::import_export::text::csv::metadata::CsvMetadataHelpers;\nuse crate::import_export::text::csv::metadata::CsvNotetype;\nuse crate::import_export::text::csv::metadata::DelimeterExt;\nuse crate::import_export::text::csv::metadata::Delimiter;\nuse crate::import_export::text::ForeignData;\nuse crate::import_export::text::ForeignNote;\nuse crate::import_export::text::NameOrId;\nuse crate::import_export::NoteLog;\nuse crate::prelude::*;\nuse crate::text::strip_utf8_bom;\n\nimpl Collection {\n    pub fn import_csv(&mut self, path: &str, metadata: CsvMetadata) -> Result<OpOutput<NoteLog>> {\n        let progress = self.new_progress_handler();\n        let file = open_file(path)?;\n        let mut ctx = ColumnContext::new(&metadata)?;\n        let notes = ctx.deserialize_csv(file, metadata.delimiter())?;\n        let mut data = ForeignData::from(metadata);\n        data.notes = notes;\n        data.import(self, progress)\n    }\n}\n\nimpl From<CsvMetadata> for ForeignData {\n    fn from(metadata: CsvMetadata) -> Self {\n        ForeignData {\n            dupe_resolution: metadata.dupe_resolution(),\n            match_scope: metadata.match_scope(),\n            default_deck: metadata.deck().map(|d| d.name_or_id()).unwrap_or_default(),\n            default_notetype: metadata\n                .notetype()\n                .map(|nt| nt.name_or_id())\n                .unwrap_or_default(),\n            global_tags: metadata.global_tags,\n            updated_tags: metadata.updated_tags,\n            ..Default::default()\n        }\n    }\n}\n\ntrait CsvDeckExt {\n    fn name_or_id(&self) -> NameOrId;\n    fn column(&self) -> Option<usize>;\n}\n\nimpl CsvDeckExt for CsvDeck {\n    fn name_or_id(&self) -> NameOrId {\n        match self {\n            Self::DeckId(did) => NameOrId::Id(*did),\n            Self::DeckColumn(_) => NameOrId::default(),\n            Self::DeckName(name) => NameOrId::Name(name.into()),\n        }\n    }\n\n    fn column(&self) -> Option<usize> {\n        match self {\n            Self::DeckId(_) => None,\n            Self::DeckColumn(column) => Some(*column as usize),\n            Self::DeckName(_) => None,\n        }\n    }\n}\n\ntrait CsvNotetypeExt {\n    fn name_or_id(&self) -> NameOrId;\n    fn column(&self) -> Option<usize>;\n}\n\nimpl CsvNotetypeExt for CsvNotetype {\n    fn name_or_id(&self) -> NameOrId {\n        match self {\n            Self::GlobalNotetype(nt) => NameOrId::Id(nt.id),\n            Self::NotetypeColumn(_) => NameOrId::default(),\n        }\n    }\n\n    fn column(&self) -> Option<usize> {\n        match self {\n            Self::GlobalNotetype(_) => None,\n            Self::NotetypeColumn(column) => Some(*column as usize),\n        }\n    }\n}\n\n/// Column indices for the fields of a notetype.\npub(super) type FieldSourceColumns = Vec<Option<usize>>;\n\n// Column indices are 1-based.\nstruct ColumnContext {\n    tags_column: Option<usize>,\n    guid_column: Option<usize>,\n    deck_column: Option<usize>,\n    notetype_column: Option<usize>,\n    /// Source column indices for the fields of a notetype\n    field_source_columns: FieldSourceColumns,\n    /// Metadata column indices (1-based)\n    meta_columns: HashSet<usize>,\n    /// How fields are converted to strings. Used for escaping HTML if\n    /// appropriate.\n    stringify: fn(&str) -> String,\n}\n\nimpl ColumnContext {\n    fn new(metadata: &CsvMetadata) -> Result<Self> {\n        Ok(Self {\n            tags_column: (metadata.tags_column > 0).then_some(metadata.tags_column as usize),\n            guid_column: (metadata.guid_column > 0).then_some(metadata.guid_column as usize),\n            deck_column: metadata.deck()?.column(),\n            notetype_column: metadata.notetype()?.column(),\n            field_source_columns: metadata.field_source_columns()?,\n            meta_columns: metadata.meta_columns(),\n            stringify: stringify_fn(metadata.is_html),\n        })\n    }\n\n    fn deserialize_csv(\n        &mut self,\n        reader: impl Read + Seek,\n        delimiter: Delimiter,\n    ) -> Result<Vec<ForeignNote>> {\n        let mut csv_reader = build_csv_reader(reader, delimiter)?;\n        self.deserialize_csv_reader(&mut csv_reader)\n    }\n\n    fn deserialize_csv_reader(\n        &mut self,\n        reader: &mut csv::Reader<impl Read>,\n    ) -> Result<Vec<ForeignNote>> {\n        reader\n            .records()\n            .map(|res| {\n                res.or_invalid(\"invalid csv\")\n                    .map(|record| self.foreign_note_from_record(&record))\n            })\n            .collect()\n    }\n\n    fn foreign_note_from_record(&self, record: &csv::StringRecord) -> ForeignNote {\n        ForeignNote {\n            notetype: name_or_id_from_record_column(self.notetype_column, record),\n            fields: self.gather_note_fields(record),\n            tags: self.gather_tags(record),\n            deck: name_or_id_from_record_column(self.deck_column, record),\n            guid: str_from_record_column(self.guid_column, record),\n            ..Default::default()\n        }\n    }\n\n    fn gather_tags(&self, record: &csv::StringRecord) -> Option<Vec<String>> {\n        self.tags_column.and_then(|i| record.get(i - 1)).map(|s| {\n            s.split_whitespace()\n                .filter(|s| !s.is_empty())\n                .map(ToString::to_string)\n                .collect()\n        })\n    }\n\n    fn gather_note_fields(&self, record: &csv::StringRecord) -> Vec<Option<String>> {\n        let op = |i| record.get(i - 1).map(self.stringify);\n        if !self.field_source_columns.is_empty() {\n            self.field_source_columns\n                .iter()\n                .map(|opt| opt.and_then(op))\n                .collect()\n        } else {\n            // notetype column provided, assume all non-metadata columns are notetype fields\n            (1..=record.len())\n                .filter(|i| !self.meta_columns.contains(i))\n                .map(op)\n                .collect()\n        }\n    }\n}\n\nfn str_from_record_column(column: Option<usize>, record: &csv::StringRecord) -> String {\n    column\n        .and_then(|i| record.get(i - 1))\n        .unwrap_or_default()\n        .to_string()\n}\n\nfn name_or_id_from_record_column(column: Option<usize>, record: &csv::StringRecord) -> NameOrId {\n    NameOrId::parse(column.and_then(|i| record.get(i - 1)).unwrap_or_default())\n}\n\npub(super) fn build_csv_reader(\n    mut reader: impl Read + Seek,\n    delimiter: Delimiter,\n) -> Result<csv::Reader<impl Read + Seek>> {\n    remove_tags_line_from_reader(&mut reader)?;\n    Ok(csv::ReaderBuilder::new()\n        .has_headers(false)\n        .flexible(true)\n        .comment(Some(b'#'))\n        .delimiter(delimiter.byte())\n        .from_reader(reader))\n}\n\nfn stringify_fn(is_html: bool) -> fn(&str) -> String {\n    if is_html {\n        ToString::to_string\n    } else {\n        |s| htmlescape::encode_minimal(s).replace('\\n', \"<br>\")\n    }\n}\n\n/// If the reader's first line starts with \"tags:\", which is allowed for\n/// historic reasons, seek to the second line.\nfn remove_tags_line_from_reader(reader: &mut (impl Read + Seek)) -> Result<()> {\n    let mut buf_reader = BufReader::new(reader);\n    let mut first_line = String::new();\n    buf_reader.read_line(&mut first_line)?;\n    let offset = if strip_utf8_bom(&first_line).starts_with(\"tags:\") {\n        first_line.len()\n    } else {\n        0\n    };\n    buf_reader\n        .into_inner()\n        .seek(SeekFrom::Start(offset as u64))?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod test {\n    use std::io::Cursor;\n\n    use anki_proto::import_export::csv_metadata::MappedNotetype;\n\n    use super::super::metadata::test::CsvMetadataTestExt;\n    use super::*;\n\n    macro_rules! import {\n        ($metadata:expr, $csv:expr) => {{\n            let reader = Cursor::new($csv);\n            let delimiter = $metadata.delimiter();\n            let mut ctx = ColumnContext::new(&$metadata).unwrap();\n            ctx.deserialize_csv(reader, delimiter).unwrap()\n        }};\n    }\n\n    macro_rules! assert_imported_fields {\n        ($metadata:expr, $csv:expr, $expected:expr) => {\n            let notes = import!(&$metadata, $csv);\n            let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect();\n            assert_eq!(fields.len(), $expected.len());\n            for (note_fields, note_expected) in fields.iter().zip($expected.iter()) {\n                assert_field_eq!(note_fields, note_expected);\n            }\n        };\n    }\n\n    macro_rules! assert_field_eq {\n        ($fields:expr, $expected:expr) => {\n            assert_eq!($fields.len(), $expected.len());\n            for (field, expected) in $fields.iter().zip($expected.iter()) {\n                assert_eq!(&field.as_ref().map(String::as_str), expected);\n            }\n        };\n    }\n\n    #[test]\n    fn should_allow_missing_columns() {\n        let metadata = CsvMetadata::defaults_for_testing();\n        assert_imported_fields!(metadata, \"foo\\n\", [[Some(\"foo\"), None]]);\n    }\n\n    #[test]\n    fn should_respect_custom_delimiter() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata.set_delimiter(Delimiter::Pipe);\n        assert_imported_fields!(\n            metadata,\n            \"fr,ont|ba,ck\\n\",\n            [[Some(\"fr,ont\"), Some(\"ba,ck\")]]\n        );\n    }\n\n    #[test]\n    fn should_ignore_first_line_starting_with_tags() {\n        let metadata = CsvMetadata::defaults_for_testing();\n        assert_imported_fields!(\n            metadata,\n            \"tags:foo\\nfront,back\\n\",\n            [[Some(\"front\"), Some(\"back\")]]\n        );\n    }\n\n    #[test]\n    fn should_respect_column_remapping() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata\n            .notetype\n            .replace(CsvNotetype::GlobalNotetype(MappedNotetype {\n                id: 1,\n                field_columns: vec![3, 1],\n            }));\n        assert_imported_fields!(\n            metadata,\n            \"front,foo,back\\n\",\n            [[Some(\"back\"), Some(\"front\")]]\n        );\n    }\n\n    #[test]\n    fn should_ignore_lines_starting_with_number_sign() {\n        let metadata = CsvMetadata::defaults_for_testing();\n        assert_imported_fields!(\n            metadata,\n            \"#foo\\nfront,back\\n#bar\\n\",\n            [[Some(\"front\"), Some(\"back\")]]\n        );\n    }\n\n    #[test]\n    fn should_escape_html_entities_if_csv_is_html() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        assert_imported_fields!(metadata, \"<hr>\\n\", [[Some(\"&lt;hr&gt;\"), None]]);\n        metadata.is_html = true;\n        assert_imported_fields!(metadata, \"<hr>\\n\", [[Some(\"<hr>\"), None]]);\n    }\n\n    #[test]\n    fn should_parse_tag_column() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata.tags_column = 3;\n        let notes = import!(metadata, \"front,back,foo bar\\n\");\n        assert_eq!(notes[0].tags.as_ref().unwrap(), &[\"foo\", \"bar\"]);\n    }\n\n    #[test]\n    fn should_parse_deck_column() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata.deck.replace(CsvDeck::DeckColumn(1));\n        let notes = import!(metadata, \"front,back\\n\");\n        assert_eq!(notes[0].deck, NameOrId::Name(String::from(\"front\")));\n    }\n\n    #[test]\n    fn should_parse_notetype_column() {\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata.notetype.replace(CsvNotetype::NotetypeColumn(1));\n        metadata.column_labels.push(\"\".to_string());\n        let notes = import!(metadata, \"Basic,front,back\\nCloze,foo,bar\\n\");\n        assert_field_eq!(notes[0].fields, [Some(\"front\"), Some(\"back\")]);\n        assert_eq!(notes[0].notetype, NameOrId::Name(String::from(\"Basic\")));\n        assert_field_eq!(notes[1].fields, [Some(\"foo\"), Some(\"bar\")]);\n        assert_eq!(notes[1].notetype, NameOrId::Name(String::from(\"Cloze\")));\n    }\n\n    #[test]\n    fn should_ignore_bom() {\n        let metadata = CsvMetadata::defaults_for_testing();\n        assert_imported_fields!(metadata, \"\\u{feff}foo,bar\\n\", [[Some(\"foo\"), Some(\"bar\")]]);\n        assert!(import!(metadata, \"\\u{feff}#foo\\n\").is_empty());\n        assert!(import!(metadata, \"\\u{feff}#html:true\\n\").is_empty());\n        assert!(import!(metadata, \"\\u{feff}tags:foo\\n\").is_empty());\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/csv/metadata.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::io::BufRead;\nuse std::io::BufReader;\nuse std::io::Cursor;\nuse std::io::Read;\nuse std::io::Seek;\nuse std::io::SeekFrom;\n\nuse anki_io::read_to_string;\npub use anki_proto::import_export::csv_metadata::Deck as CsvDeck;\npub use anki_proto::import_export::csv_metadata::Delimiter;\npub use anki_proto::import_export::csv_metadata::DupeResolution;\npub use anki_proto::import_export::csv_metadata::MappedNotetype;\npub use anki_proto::import_export::csv_metadata::MatchScope;\npub use anki_proto::import_export::csv_metadata::Notetype as CsvNotetype;\npub use anki_proto::import_export::CsvMetadata;\nuse itertools::Itertools;\nuse strum::IntoEnumIterator;\n\nuse super::import::build_csv_reader;\nuse crate::config::I32ConfigKey;\nuse crate::import_export::text::csv::import::FieldSourceColumns;\nuse crate::import_export::text::NameOrId;\nuse crate::import_export::ImportError;\nuse crate::notetype::NoteField;\nuse crate::prelude::*;\nuse crate::text::html_to_text_line;\nuse crate::text::is_html;\nuse crate::text::strip_utf8_bom;\n\n/// The maximum number of preview rows.\nconst PREVIEW_LENGTH: usize = 5;\n/// The maximum number of characters per preview field.\nconst PREVIEW_FIELD_LENGTH: usize = 80;\n\nimpl Collection {\n    pub fn get_csv_metadata(\n        &mut self,\n        path: &str,\n        delimiter: Option<Delimiter>,\n        notetype_id: Option<NotetypeId>,\n        deck_id: Option<DeckId>,\n        is_html: Option<bool>,\n    ) -> Result<CsvMetadata> {\n        let text = read_to_string(path)?;\n        let mut reader = Cursor::new(text);\n        let meta =\n            self.get_reader_metadata(&mut reader, delimiter, notetype_id, deck_id, is_html)?;\n        if meta.preview.is_empty() {\n            return Err(ImportError::EmptyFile.into());\n        }\n        Ok(meta)\n    }\n\n    fn get_reader_metadata(\n        &mut self,\n        mut reader: impl Read + Seek,\n        delimiter: Option<Delimiter>,\n        notetype_id: Option<NotetypeId>,\n        deck_id: Option<DeckId>,\n        is_html: Option<bool>,\n    ) -> Result<CsvMetadata> {\n        let mut metadata = CsvMetadata::from_config(self);\n        let meta_len = self.parse_meta_lines(&mut reader, &mut metadata)? as u64;\n        maybe_set_fallback_delimiter(delimiter, &mut metadata, &mut reader, meta_len)?;\n        let records = collect_preview_records(&mut metadata, reader)?;\n        maybe_set_fallback_is_html(&mut metadata, &records, is_html)?;\n        set_preview(&mut metadata, &records)?;\n        maybe_set_fallback_columns(&mut metadata)?;\n        self.maybe_set_notetype_and_deck(&mut metadata, notetype_id, deck_id)?;\n        self.maybe_init_notetype_map(&mut metadata)?;\n\n        Ok(metadata)\n    }\n\n    /// Parses the meta head of the file and returns the total of meta bytes.\n    fn parse_meta_lines(&mut self, reader: impl Read, metadata: &mut CsvMetadata) -> Result<usize> {\n        let mut meta_len = 0;\n        let mut reader = BufReader::new(reader);\n        let mut line = String::new();\n        let mut line_len = reader.read_line(&mut line)?;\n        if self.parse_first_line(&line, metadata) {\n            meta_len += line_len;\n            line.clear();\n            line_len = reader.read_line(&mut line)?;\n            while self.parse_line(&line, metadata) {\n                meta_len += line_len;\n                line.clear();\n                line_len = reader.read_line(&mut line)?;\n            }\n        }\n        Ok(meta_len)\n    }\n\n    /// True if the line is a meta line, i.e. a comment, or starting with\n    /// 'tags:'.\n    fn parse_first_line(&mut self, line: &str, metadata: &mut CsvMetadata) -> bool {\n        let line = strip_utf8_bom(line);\n        if let Some(tags) = line.strip_prefix(\"tags:\") {\n            metadata.global_tags = collect_tags(tags);\n            true\n        } else {\n            self.parse_line(line, metadata)\n        }\n    }\n\n    /// True if the line is a comment.\n    fn parse_line(&mut self, line: &str, metadata: &mut CsvMetadata) -> bool {\n        if let Some(l) = line.strip_prefix('#') {\n            if let Some((key, value)) = l.split_once(':') {\n                self.parse_meta_value(key, strip_line_ending(value), metadata);\n            }\n            true\n        } else {\n            false\n        }\n    }\n\n    fn parse_meta_value(&mut self, key: &str, value: &str, metadata: &mut CsvMetadata) {\n        // trim potential delimiters past the first char* if\n        // metadata line was mistakenly exported as a record\n        // *to allow cases like #separator:,\n        // ASSUMPTION: delimiters are not ascii-alphanumeric\n        let trimmed_value = value\n            .char_indices()\n            .nth(1)\n            .and_then(|(i, _)| {\n                value[i..] // SAFETY: char_indices are on char boundaries\n                    .find(|c| !char::is_ascii_alphanumeric(&c))\n                    .map(|j| value.split_at(i + j).0)\n            })\n            .unwrap_or(value);\n\n        match key.trim().to_ascii_lowercase().as_str() {\n            \"separator\" => {\n                if let Some(delimiter) = delimiter_from_value(trimmed_value) {\n                    metadata.delimiter = delimiter as i32;\n                    metadata.force_delimiter = true;\n                }\n            }\n            \"html\" => {\n                if let Ok(is_html) = trimmed_value.to_lowercase().parse() {\n                    metadata.is_html = is_html;\n                    metadata.force_is_html = true;\n                }\n            }\n            // freeform values cannot be trimmed thus without knowing the exact delimiter\n            \"tags\" => metadata.global_tags = collect_tags(value),\n            \"columns\" => {\n                if let Ok(columns) = parse_columns(value, metadata.delimiter()) {\n                    metadata.column_labels = columns;\n                }\n            }\n            \"notetype\" => {\n                if let Ok(Some(nt)) = self.notetype_by_name_or_id(&NameOrId::parse(value)) {\n                    metadata.notetype = Some(new_global_csv_notetype(nt.id));\n                }\n            }\n            \"deck\" => {\n                if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) {\n                    metadata.deck = Some(CsvDeck::DeckId(did.0));\n                } else if !value.is_empty() {\n                    metadata.deck = Some(CsvDeck::DeckName(value.to_string()));\n                }\n            }\n            \"notetype column\" => {\n                if let Ok(n) = trimmed_value.trim().parse() {\n                    metadata.notetype = Some(CsvNotetype::NotetypeColumn(n));\n                }\n            }\n            \"deck column\" => {\n                if let Ok(n) = trimmed_value.trim().parse() {\n                    metadata.deck = Some(CsvDeck::DeckColumn(n));\n                }\n            }\n            \"tags column\" => {\n                if let Ok(n) = trimmed_value.trim().parse() {\n                    metadata.tags_column = n;\n                }\n            }\n            \"guid column\" => {\n                if let Ok(n) = trimmed_value.trim().parse() {\n                    metadata.guid_column = n;\n                }\n            }\n            \"match scope\" => {\n                if let Some(scope) = MatchScope::from_text(value) {\n                    metadata.match_scope = scope as i32;\n                }\n            }\n            \"if matches\" => {\n                if let Some(resolution) = DupeResolution::from_text(value) {\n                    metadata.dupe_resolution = resolution as i32;\n                }\n            }\n            _ => (),\n        }\n    }\n\n    /// Ensure notetype and deck are set.\n    ///\n    /// - When the UI is first loaded, both notetype and deck arguments will be\n    ///   None.\n    /// - When the UI refreshes due to user changes, the currently-selected deck\n    ///   and notetype will be provided.\n    /// - Metadata may already have deck and notetype set, if those directives\n    ///   were present in the file to import. In the UI refresh case, we\n    ///   override them with the current UI values, so that the user can adjust\n    ///   the deck/notetype if they wish.\n    /// - In the initial load case, if notetype/deck were not specified in file,\n    ///   we apply the defaults from defaults_for_adding().\n    pub(crate) fn maybe_set_notetype_and_deck(\n        &mut self,\n        metadata: &mut anki_proto::import_export::CsvMetadata,\n        notetype_id: Option<NotetypeId>,\n        deck_id: Option<DeckId>,\n    ) -> Result<()> {\n        let defaults = self.defaults_for_adding(DeckId(0))?;\n        if metadata.notetype.is_none() || notetype_id.is_some() {\n            metadata.notetype = Some(new_global_csv_notetype(\n                notetype_id.unwrap_or(defaults.notetype_id),\n            ));\n        }\n        if metadata.deck.is_none() || deck_id.is_some() {\n            metadata.deck = Some(CsvDeck::DeckId(deck_id.unwrap_or(defaults.deck_id).0));\n        }\n        Ok(())\n    }\n\n    fn maybe_init_notetype_map(&mut self, metadata: &mut CsvMetadata) -> Result<()> {\n        let meta_columns = metadata.meta_columns();\n        if let Some(CsvNotetype::GlobalNotetype(ref mut global)) = metadata.notetype {\n            let notetype = self\n                .get_notetype(NotetypeId(global.id))?\n                .or_not_found(NotetypeId(global.id))?;\n            global.field_columns = vec![0; notetype.fields.len()];\n            global.field_columns[0] = 1;\n            let column_len = metadata.column_labels.len();\n            if metadata.column_labels.iter().all(String::is_empty) {\n                map_field_columns_by_index(&mut global.field_columns, column_len, &meta_columns);\n            } else {\n                map_field_columns_by_name(\n                    &mut global.field_columns,\n                    &metadata.column_labels,\n                    &meta_columns,\n                    &notetype.fields,\n                );\n            }\n            ensure_first_field_is_mapped(&mut global.field_columns, column_len, &meta_columns)?;\n            maybe_set_tags_column(metadata, &meta_columns);\n        }\n        Ok(())\n    }\n}\n\npub(super) trait CsvMetadataHelpers {\n    fn from_config(col: &Collection) -> Self;\n    fn deck(&self) -> Result<&CsvDeck>;\n    fn notetype(&self) -> Result<&CsvNotetype>;\n    fn field_source_columns(&self) -> Result<FieldSourceColumns>;\n    fn meta_columns(&self) -> HashSet<usize>;\n}\n\nimpl CsvMetadataHelpers for CsvMetadata {\n    /// Defaults with config values filled in.\n    fn from_config(col: &Collection) -> Self {\n        Self {\n            dupe_resolution: DupeResolution::from_config(col) as i32,\n            match_scope: MatchScope::from_config(col) as i32,\n            ..Default::default()\n        }\n    }\n\n    fn deck(&self) -> Result<&CsvDeck> {\n        self.deck.as_ref().or_invalid(\"deck oneof not set\")\n    }\n\n    fn notetype(&self) -> Result<&CsvNotetype> {\n        self.notetype.as_ref().or_invalid(\"notetype oneof not set\")\n    }\n\n    fn field_source_columns(&self) -> Result<FieldSourceColumns> {\n        Ok(match self.notetype()? {\n            CsvNotetype::GlobalNotetype(global) => global\n                .field_columns\n                .iter()\n                .map(|&i| (i > 0).then_some(i as usize))\n                .collect(),\n            CsvNotetype::NotetypeColumn(_) => {\n                // each row's notetype could have varying number of fields\n                vec![]\n            }\n        })\n    }\n\n    fn meta_columns(&self) -> HashSet<usize> {\n        let mut columns = HashSet::new();\n        if let Some(CsvDeck::DeckColumn(deck_column)) = self.deck {\n            columns.insert(deck_column as usize);\n        }\n        if let Some(CsvNotetype::NotetypeColumn(notetype_column)) = self.notetype {\n            columns.insert(notetype_column as usize);\n        }\n        if self.tags_column > 0 {\n            columns.insert(self.tags_column as usize);\n        }\n        if self.guid_column > 0 {\n            columns.insert(self.guid_column as usize);\n        }\n        columns\n    }\n}\n\npub(super) trait DupeResolutionExt: Sized {\n    fn from_config(col: &Collection) -> Self;\n    fn from_text(text: &str) -> Option<Self>;\n}\n\nimpl DupeResolutionExt for DupeResolution {\n    fn from_config(col: &Collection) -> Self {\n        Self::try_from(col.get_config_i32(I32ConfigKey::CsvDuplicateResolution)).unwrap_or_default()\n    }\n\n    fn from_text(text: &str) -> Option<Self> {\n        match text {\n            \"update current\" => Some(Self::Update),\n            \"keep current\" => Some(Self::Preserve),\n            \"keep both\" => Some(Self::Duplicate),\n            _ => None,\n        }\n    }\n}\n\npub(super) trait MatchScopeExt: Sized {\n    fn from_config(col: &Collection) -> Self;\n    fn from_text(text: &str) -> Option<Self>;\n}\n\nimpl MatchScopeExt for MatchScope {\n    fn from_config(col: &Collection) -> Self {\n        Self::try_from(col.get_config_i32(I32ConfigKey::MatchScope)).unwrap_or_default()\n    }\n\n    fn from_text(text: &str) -> Option<Self> {\n        match text {\n            \"notetype\" => Some(Self::Notetype),\n            \"notetype + deck\" => Some(Self::NotetypeAndDeck),\n            _ => None,\n        }\n    }\n}\n\nfn parse_columns(line: &str, delimiter: Delimiter) -> Result<Vec<String>> {\n    map_single_record(line, delimiter, |record| {\n        record.iter().map(ToString::to_string).collect()\n    })\n}\n\nfn collect_preview_records(\n    metadata: &mut CsvMetadata,\n    mut reader: impl Read + Seek,\n) -> Result<Vec<csv::StringRecord>> {\n    reader.rewind()?;\n    let mut csv_reader = build_csv_reader(reader, metadata.delimiter())?;\n    csv_reader\n        .records()\n        .take(PREVIEW_LENGTH)\n        .collect::<csv::Result<_>>()\n        .or_invalid(\"invalid csv\")\n}\n\nfn set_preview(metadata: &mut CsvMetadata, records: &[csv::StringRecord]) -> Result<()> {\n    let mut min_len = 1;\n    metadata.preview = records\n        .iter()\n        .enumerate()\n        .map(|(idx, record)| {\n            let row = build_preview_row(min_len, record, metadata.is_html);\n            if idx == 0 {\n                min_len = row.vals.len();\n            }\n            row\n        })\n        .collect();\n    Ok(())\n}\n\nfn build_preview_row(\n    min_len: usize,\n    record: &csv::StringRecord,\n    strip_html: bool,\n) -> anki_proto::generic::StringList {\n    anki_proto::generic::StringList {\n        vals: record\n            .iter()\n            .pad_using(min_len, |_| \"\")\n            .map(|field| {\n                if strip_html {\n                    html_to_text_line(field, true)\n                        .chars()\n                        .take(PREVIEW_FIELD_LENGTH)\n                        .collect()\n                } else {\n                    field.chars().take(PREVIEW_FIELD_LENGTH).collect()\n                }\n            })\n            .collect(),\n    }\n}\n\npub(super) fn collect_tags(txt: &str) -> Vec<String> {\n    txt.split_whitespace()\n        .filter(|s| !s.is_empty())\n        .map(ToString::to_string)\n        .collect()\n}\n\nfn map_field_columns_by_index(\n    field_columns: &mut [u32],\n    column_len: usize,\n    meta_columns: &HashSet<usize>,\n) {\n    let mut field_columns = field_columns.iter_mut();\n    for index in 1..column_len + 1 {\n        if !meta_columns.contains(&index) {\n            if let Some(field_column) = field_columns.next() {\n                *field_column = index as u32;\n            } else {\n                break;\n            }\n        }\n    }\n}\n\nfn map_field_columns_by_name(\n    field_columns: &mut [u32],\n    column_labels: &[String],\n    meta_columns: &HashSet<usize>,\n    note_fields: &[NoteField],\n) {\n    let columns: HashMap<&str, usize> = HashMap::from_iter(\n        column_labels\n            .iter()\n            .enumerate()\n            .map(|(idx, s)| (s.as_str(), idx + 1))\n            .filter(|(_, idx)| !meta_columns.contains(idx)),\n    );\n    for (column, field) in field_columns.iter_mut().zip(note_fields) {\n        if let Some(index) = columns.get(field.name.as_str()) {\n            *column = *index as u32;\n        }\n    }\n}\n\nfn ensure_first_field_is_mapped(\n    field_columns: &mut [u32],\n    column_len: usize,\n    meta_columns: &HashSet<usize>,\n) -> Result<()> {\n    if field_columns[0] == 0 {\n        field_columns[0] = (1..column_len + 1)\n            .find(|i| !meta_columns.contains(i))\n            .ok_or(AnkiError::ImportError {\n                source: ImportError::NoFieldColumn,\n            })? as u32;\n    }\n    Ok(())\n}\n\nfn maybe_set_fallback_columns(metadata: &mut CsvMetadata) -> Result<()> {\n    if metadata.column_labels.is_empty() {\n        metadata.column_labels =\n            vec![String::new(); metadata.preview.first().map_or(0, |row| row.vals.len())];\n    }\n    Ok(())\n}\n\nfn maybe_set_fallback_is_html(\n    metadata: &mut CsvMetadata,\n    records: &[csv::StringRecord],\n    is_html_option: Option<bool>,\n) -> Result<()> {\n    if let Some(is_html) = is_html_option {\n        metadata.is_html = is_html;\n    } else if !metadata.force_is_html {\n        metadata.is_html = records.iter().flat_map(|record| record.iter()).any(is_html);\n    }\n    Ok(())\n}\n\nfn maybe_set_fallback_delimiter(\n    delimiter: Option<Delimiter>,\n    metadata: &mut CsvMetadata,\n    mut reader: impl Read + Seek,\n    meta_len: u64,\n) -> Result<()> {\n    if let Some(delim) = delimiter {\n        metadata.set_delimiter(delim);\n    } else if !metadata.force_delimiter {\n        reader.seek(SeekFrom::Start(meta_len))?;\n        metadata.set_delimiter(delimiter_from_reader(reader)?);\n    }\n    Ok(())\n}\n\nfn maybe_set_tags_column(metadata: &mut CsvMetadata, meta_columns: &HashSet<usize>) {\n    if metadata.tags_column == 0 {\n        if let Some(CsvNotetype::GlobalNotetype(ref global)) = metadata.notetype {\n            let max_field = global.field_columns.iter().max().copied().unwrap_or(0);\n            for idx in (max_field + 1) as usize..=metadata.column_labels.len() {\n                if !meta_columns.contains(&idx) {\n                    metadata.tags_column = idx as u32;\n                    break;\n                }\n            }\n        }\n    }\n}\n\nfn delimiter_from_value(value: &str) -> Option<Delimiter> {\n    let normed = value.to_ascii_lowercase();\n    Delimiter::iter().find(|&delimiter| {\n        normed.trim() == delimiter.name() || normed.as_bytes() == [delimiter.byte()]\n    })\n}\n\nfn delimiter_from_reader(mut reader: impl Read) -> Result<Delimiter> {\n    let mut buf = [0; 8 * 1024];\n    let _ = reader.read(&mut buf)?;\n    // TODO: use smarter heuristic\n    for delimiter in Delimiter::iter() {\n        if buf.contains(&delimiter.byte()) {\n            return Ok(delimiter);\n        }\n    }\n    Ok(Delimiter::Space)\n}\n\nfn map_single_record<T>(\n    line: &str,\n    delimiter: Delimiter,\n    op: impl FnOnce(&csv::StringRecord) -> T,\n) -> Result<T> {\n    csv::ReaderBuilder::new()\n        .delimiter(delimiter.byte())\n        .from_reader(line.as_bytes())\n        .headers()\n        .map_err(|_| AnkiError::ImportError {\n            source: ImportError::Corrupt,\n        })\n        .map(op)\n}\n\nfn strip_line_ending(line: &str) -> &str {\n    line.strip_suffix(\"\\r\\n\")\n        .unwrap_or_else(|| line.strip_suffix('\\n').unwrap_or(line))\n}\n\npub(super) trait DelimeterExt {\n    fn byte(self) -> u8;\n    fn name(self) -> &'static str;\n}\n\nimpl DelimeterExt for Delimiter {\n    fn byte(self) -> u8 {\n        match self {\n            Delimiter::Comma => b',',\n            Delimiter::Semicolon => b';',\n            Delimiter::Tab => b'\\t',\n            Delimiter::Space => b' ',\n            Delimiter::Pipe => b'|',\n            Delimiter::Colon => b':',\n        }\n    }\n\n    fn name(self) -> &'static str {\n        match self {\n            Delimiter::Comma => \"comma\",\n            Delimiter::Semicolon => \"semicolon\",\n            Delimiter::Tab => \"tab\",\n            Delimiter::Space => \"space\",\n            Delimiter::Pipe => \"pipe\",\n            Delimiter::Colon => \"colon\",\n        }\n    }\n}\n\nfn new_global_csv_notetype(id: NotetypeId) -> CsvNotetype {\n    CsvNotetype::GlobalNotetype(MappedNotetype {\n        id: id.0,\n        field_columns: Vec::new(),\n    })\n}\n\nimpl NameOrId {\n    pub fn parse(s: &str) -> Self {\n        if let Ok(id) = s.parse() {\n            Self::Id(id)\n        } else {\n            Self::Name(s.to_string())\n        }\n    }\n}\n\n#[cfg(test)]\npub(in crate::import_export) mod test {\n    use std::io::Cursor;\n\n    use super::*;\n\n    macro_rules! metadata {\n        ($col:expr,$csv:expr) => {\n            metadata!($col, $csv, None)\n        };\n        ($col:expr,$csv:expr, $delim:expr) => {\n            $col.get_reader_metadata(Cursor::new($csv.as_bytes()), $delim, None, None, None)\n                .unwrap()\n        };\n    }\n\n    pub trait CsvMetadataTestExt {\n        fn defaults_for_testing() -> Self;\n        fn unwrap_deck_id(&self) -> i64;\n        fn unwrap_deck_name(&self) -> &str;\n        fn unwrap_notetype_id(&self) -> i64;\n        fn unwrap_notetype_map(&self) -> &[u32];\n    }\n\n    impl CsvMetadataTestExt for CsvMetadata {\n        fn defaults_for_testing() -> Self {\n            Self {\n                delimiter: Delimiter::Comma as i32,\n                force_delimiter: false,\n                is_html: false,\n                force_is_html: false,\n                tags_column: 0,\n                guid_column: 0,\n                global_tags: Vec::new(),\n                updated_tags: Vec::new(),\n                column_labels: vec![\"\".to_string(); 2],\n                deck: Some(CsvDeck::DeckId(1)),\n                notetype: Some(CsvNotetype::GlobalNotetype(MappedNotetype {\n                    id: 1,\n                    field_columns: vec![1, 2],\n                })),\n                preview: Vec::new(),\n                dupe_resolution: 0,\n                match_scope: 0,\n            }\n        }\n\n        fn unwrap_deck_id(&self) -> i64 {\n            match self.deck {\n                Some(CsvDeck::DeckId(did)) => did,\n                _ => panic!(\"no deck id\"),\n            }\n        }\n\n        fn unwrap_deck_name(&self) -> &str {\n            match &self.deck {\n                Some(CsvDeck::DeckName(name)) => name,\n                _ => panic!(\"no deck name\"),\n            }\n        }\n\n        fn unwrap_notetype_id(&self) -> i64 {\n            match self.notetype {\n                Some(CsvNotetype::GlobalNotetype(ref nt)) => nt.id,\n                _ => panic!(\"no notetype id\"),\n            }\n        }\n        fn unwrap_notetype_map(&self) -> &[u32] {\n            match &self.notetype {\n                Some(CsvNotetype::GlobalNotetype(nt)) => &nt.field_columns,\n                _ => panic!(\"no notetype map\"),\n            }\n        }\n    }\n\n    #[test]\n    fn should_detect_deck_by_name_or_id() {\n        let mut col = Collection::new();\n        let deck_id = col.get_or_create_normal_deck(\"my deck\").unwrap().id.0;\n        assert_eq!(metadata!(col, \"#deck:my deck\\n\").unwrap_deck_id(), deck_id);\n        assert_eq!(\n            metadata!(col, format!(\"#deck:{deck_id}\\n\")).unwrap_deck_id(),\n            deck_id\n        );\n        // unknown deck\n        assert_eq!(metadata!(col, \"#deck:foo\\n\").unwrap_deck_name(), \"foo\");\n        assert_eq!(metadata!(col, \"#deck:1234\\n\").unwrap_deck_name(), \"1234\");\n        // fallback\n        assert_eq!(metadata!(col, \"#deck:\\n\").unwrap_deck_id(), 1);\n        assert_eq!(metadata!(col, \"\\n\").unwrap_deck_id(), 1);\n    }\n\n    #[test]\n    fn should_detect_notetype_by_name_or_id() {\n        let mut col = Collection::new();\n        let basic_id = col.get_notetype_by_name(\"Basic\").unwrap().unwrap().id.0;\n        assert_eq!(\n            metadata!(col, \"#notetype:Basic\\n\").unwrap_notetype_id(),\n            basic_id\n        );\n        assert_eq!(\n            metadata!(col, &format!(\"#notetype:{basic_id}\\n\")).unwrap_notetype_id(),\n            basic_id\n        );\n    }\n\n    #[test]\n    fn should_fallback_to_parsing_deck_ids_as_deck_names() {\n        let mut col = Collection::new();\n        let numeric_deck_id = col.get_or_create_normal_deck(\"123456789\").unwrap().id.0;\n        let numeric_deck_2_id = col\n            .get_or_create_normal_deck(&numeric_deck_id.to_string())\n            .unwrap()\n            .id\n            .0;\n\n        assert_eq!(\n            metadata!(col, \"#deck:123456789\\n\").unwrap_deck_id(),\n            numeric_deck_id\n        );\n        // parsed as id first, fallback to name after\n        assert_eq!(\n            metadata!(col, format!(\"#deck:{numeric_deck_id}\\n\")).unwrap_deck_id(),\n            numeric_deck_id\n        );\n        assert_eq!(\n            metadata!(col, format!(\"#deck:{numeric_deck_2_id}\\n\")).unwrap_deck_id(),\n            numeric_deck_2_id\n        );\n        assert_eq!(\n            metadata!(col, format!(\"#deck:1234\\n\")).unwrap_deck_name(),\n            \"1234\"\n        );\n    }\n\n    #[test]\n    fn should_detect_valid_delimiters() {\n        let mut col = Collection::new();\n        assert_eq!(\n            metadata!(col, \"#separator:comma\\n\").delimiter(),\n            Delimiter::Comma\n        );\n        assert_eq!(\n            metadata!(col, \"#separator:\\t\\n\").delimiter(),\n            Delimiter::Tab\n        );\n        // fallback\n        assert_eq!(\n            metadata!(col, \"#separator:foo\\n\").delimiter(),\n            Delimiter::Space\n        );\n        assert_eq!(\n            metadata!(col, \"#separator:♥\\n\").delimiter(),\n            Delimiter::Space\n        );\n        // pick up from first line\n        assert_eq!(metadata!(col, \"foo\\tbar\\n\").delimiter(), Delimiter::Tab);\n        // override with provided\n        assert_eq!(\n            metadata!(col, \"#separator: \\nfoo\\tbar\\n\", Some(Delimiter::Pipe)).delimiter(),\n            Delimiter::Pipe\n        );\n    }\n\n    #[test]\n    fn should_enforce_valid_html_flag() {\n        let mut col = Collection::new();\n\n        let meta = metadata!(col, \"#html:true\\n\");\n        assert!(meta.is_html);\n        assert!(meta.force_is_html);\n\n        let meta = metadata!(col, \"#html:FALSE\\n\");\n        assert!(!meta.is_html);\n        assert!(meta.force_is_html);\n\n        assert!(!metadata!(col, \"#html:maybe\\n\").force_is_html);\n    }\n\n    #[test]\n    fn should_set_missing_html_flag_by_first_line() {\n        let mut col = Collection::new();\n\n        let meta = metadata!(col, \"<br/>\\n\");\n        assert!(meta.is_html);\n        assert!(!meta.force_is_html);\n\n        // HTML check is field-, not row-based\n        assert!(!metadata!(col, \"<br,/>\\n\").is_html);\n\n        assert!(!metadata!(col, \"#html:false\\n<br>\\n\").is_html);\n    }\n\n    #[test]\n    fn should_detect_old_and_new_style_tags() {\n        let mut col = Collection::new();\n        assert_eq!(metadata!(col, \"tags:foo bar\\n\").global_tags, [\"foo\", \"bar\"]);\n        assert_eq!(\n            metadata!(col, \"#tags:foo bar\\n\").global_tags,\n            [\"foo\", \"bar\"]\n        );\n        // only in head\n        assert_eq!(\n            metadata!(col, \"#\\n#tags:foo bar\\n\").global_tags,\n            [\"foo\", \"bar\"]\n        );\n        assert_eq!(metadata!(col, \"\\n#tags:foo bar\\n\").global_tags, [\"\"; 0]);\n        // only on very first line\n        assert_eq!(metadata!(col, \"#\\ntags:foo bar\\n\").global_tags, [\"\"; 0]);\n    }\n\n    #[test]\n    fn should_detect_column_number_and_names() {\n        let mut col = Collection::new();\n        // detect from line\n        assert_eq!(metadata!(col, \"foo;bar\\n\").column_labels.len(), 2);\n        // detect encoded\n        assert_eq!(\n            metadata!(col, \"#separator:,\\nfoo;bar\\n\")\n                .column_labels\n                .len(),\n            1\n        );\n        assert_eq!(\n            metadata!(col, \"#separator:|\\nfoo|bar\\n\")\n                .column_labels\n                .len(),\n            2\n        );\n        // override\n        assert_eq!(\n            metadata!(col, \"#separator:;\\nfoo;bar\\n\", Some(Delimiter::Pipe))\n                .column_labels\n                .len(),\n            1\n        );\n\n        // custom names\n        assert_eq!(\n            metadata!(col, \"#columns:one\\ttwo\\n\").column_labels,\n            [\"one\", \"two\"]\n        );\n        assert_eq!(\n            metadata!(col, \"#separator:|\\n#columns:one|two\\n\").column_labels,\n            [\"one\", \"two\"]\n        );\n    }\n\n    #[test]\n    fn should_detect_column_number_despite_escaped_line_breaks() {\n        let mut col = Collection::new();\n        assert_eq!(\n            metadata!(col, \"\\\"foo|\\nbar\\\"\\tfoo\\tbar\\n\")\n                .column_labels\n                .len(),\n            3\n        );\n    }\n\n    #[test]\n    fn should_map_default_notetype_fields_by_index_if_no_column_names() {\n        let mut col = Collection::new();\n        let meta = metadata!(col, \"#deck column:1\\nfoo,bar,baz\\n\");\n        assert_eq!(meta.unwrap_notetype_map(), &[2, 3]);\n    }\n\n    #[test]\n    fn should_map_default_notetype_fields_by_given_column_names() {\n        let mut col = Collection::new();\n        let meta = metadata!(col, \"#columns:Back\\tFront\\nfoo,bar,baz\\n\");\n        assert_eq!(meta.unwrap_notetype_map(), &[2, 1]);\n    }\n\n    #[test]\n    fn should_gather_first_lines_into_preview() {\n        let mut col = Collection::new();\n        let meta = metadata!(col, \"#separator: \\nfoo bar\\nbaz<br>\\n\");\n        assert_eq!(meta.preview[0].vals, [\"foo\", \"bar\"]);\n        // html is stripped\n        assert_eq!(meta.preview[1].vals, [\"baz\", \"\"]);\n    }\n\n    #[test]\n    fn should_parse_first_first_line_despite_bom() {\n        let mut col = Collection::new();\n        assert_eq!(\n            metadata!(col, \"\\u{feff}#separator:tab\\n\").delimiter(),\n            Delimiter::Tab\n        );\n        assert_eq!(metadata!(col, \"\\u{feff}tags:foo\\n\").global_tags, [\"foo\"]);\n    }\n\n    #[test]\n    fn should_not_set_tags_column_if_all_are_field_columns() {\n        let meta_columns = Default::default();\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        maybe_set_tags_column(&mut metadata, &meta_columns);\n        assert_eq!(metadata.tags_column, 0);\n    }\n\n    #[test]\n    fn should_set_tags_column_to_next_unused_column() {\n        let mut meta_columns = HashSet::default();\n        meta_columns.insert(3);\n        let mut metadata = CsvMetadata::defaults_for_testing();\n        metadata.column_labels.push(String::new());\n        metadata.column_labels.push(String::new());\n        maybe_set_tags_column(&mut metadata, &meta_columns);\n        assert_eq!(metadata.tags_column, 4);\n    }\n\n    #[test]\n    fn should_allow_non_freeform_metadata_lines_to_be_suffixed_by_delimiters() {\n        let mut col = Collection::new();\n        let metadata = metadata!(\n            col,\n            r#\"\n#separator:Pipe,,,,,,,\n#html:true|||||\n#tags:foo bar::世界,,,\n#guid column:8   \n#tags column:123abc \n        \"#\n            .trim()\n        );\n        assert_eq!(metadata.delimiter(), Delimiter::Pipe);\n        assert!(metadata.is_html);\n        assert_eq!(metadata.guid_column, 8);\n        // tags is freeform, potential delimiters aren't trimmed\n        assert_eq!(metadata.global_tags, [\"foo\", \"bar::世界,,,\"]);\n        // ascii alphanumerics aren't trimmed away\n        assert_eq!(metadata.tags_column, 0);\n\n        assert_eq!(\n            metadata!(col, \"#separator:\\t|,:\\n\").delimiter(),\n            Delimiter::Tab\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/csv/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod export;\nmod import;\npub mod metadata;\n"
  },
  {
    "path": "rslib/src/import_export/text/import.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::sync::Arc;\n\nuse unicase::UniCase;\n\nuse super::NameOrId;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::config::I32ConfigKey;\nuse crate::import_export::text::DupeResolution;\nuse crate::import_export::text::ForeignCard;\nuse crate::import_export::text::ForeignData;\nuse crate::import_export::text::ForeignNote;\nuse crate::import_export::text::ForeignNotetype;\nuse crate::import_export::text::ForeignTemplate;\nuse crate::import_export::text::MatchScope;\nuse crate::import_export::ImportProgress;\nuse crate::import_export::NoteLog;\nuse crate::notes::field_checksum;\nuse crate::notes::normalize_field;\nuse crate::notetype::CardGenContext;\nuse crate::notetype::CardTemplate;\nuse crate::notetype::NoteField;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::text::strip_html_preserving_media_filenames;\n\nimpl ForeignData {\n    pub fn import(\n        self,\n        col: &mut Collection,\n        mut progress: ThrottlingProgressHandler<ImportProgress>,\n    ) -> Result<OpOutput<NoteLog>> {\n        progress.set(ImportProgress::File)?;\n        col.transact(Op::Import, |col| {\n            self.update_config(col)?;\n            let mut ctx = Context::new(&self, col)?;\n            ctx.import_foreign_notetypes(self.notetypes)?;\n            ctx.import_foreign_notes(\n                self.notes,\n                &self.global_tags,\n                &self.updated_tags,\n                &mut progress,\n            )\n        })\n    }\n\n    fn update_config(&self, col: &mut Collection) -> Result<()> {\n        col.set_config_i32_inner(\n            I32ConfigKey::CsvDuplicateResolution,\n            self.dupe_resolution as i32,\n        )?;\n        col.set_config_i32_inner(I32ConfigKey::MatchScope, self.match_scope as i32)?;\n        Ok(())\n    }\n}\n\nfn new_note_log(dupe_resolution: DupeResolution, found_notes: u32) -> NoteLog {\n    NoteLog {\n        dupe_resolution: dupe_resolution as i32,\n        found_notes,\n        ..Default::default()\n    }\n}\n\nstruct Context<'a> {\n    col: &'a mut Collection,\n    /// Contains the optional default notetype with the default key.\n    notetypes: HashMap<NameOrId, Option<Arc<Notetype>>>,\n    deck_ids: DeckIdsByNameOrId,\n    usn: Usn,\n    normalize_notes: bool,\n    timing: SchedTimingToday,\n    dupe_resolution: DupeResolution,\n    card_gen_ctxs: HashMap<(NotetypeId, DeckId), CardGenContext<Arc<Notetype>>>,\n    existing_checksums: ExistingChecksums,\n    existing_guids: HashMap<String, NoteId>,\n}\n\nstruct DeckIdsByNameOrId {\n    ids: HashSet<DeckId>,\n    names: HashMap<UniCase<String>, DeckId>,\n    default: Option<DeckId>,\n}\n\n/// Notes in the collection indexed by notetype, checksum and optionally deck.\n/// With deck, a note will be included in as many entries as its cards\n/// have different original decks.\n#[derive(Debug)]\nenum ExistingChecksums {\n    ByNotetype(HashMap<(NotetypeId, u32), Vec<NoteId>>),\n    ByNotetypeAndDeck(HashMap<(NotetypeId, u32, DeckId), Vec<NoteId>>),\n}\n\nimpl ExistingChecksums {\n    fn new(col: &mut Collection, match_scope: MatchScope) -> Result<Self> {\n        match match_scope {\n            MatchScope::Notetype => col\n                .storage\n                .all_notes_by_type_and_checksum()\n                .map(Self::ByNotetype),\n            MatchScope::NotetypeAndDeck => col\n                .storage\n                .all_notes_by_type_checksum_and_deck()\n                .map(Self::ByNotetypeAndDeck),\n        }\n    }\n\n    fn get(&self, notetype: NotetypeId, checksum: u32, deck: DeckId) -> Option<&Vec<NoteId>> {\n        match self {\n            Self::ByNotetype(map) => map.get(&(notetype, checksum)),\n            Self::ByNotetypeAndDeck(map) => map.get(&(notetype, checksum, deck)),\n        }\n    }\n}\n\nstruct NoteContext<'a> {\n    note: ForeignNote,\n    dupes: Vec<Duplicate>,\n    notetype: Arc<Notetype>,\n    deck_id: DeckId,\n    global_tags: &'a [String],\n    updated_tags: &'a [String],\n}\n\nstruct Duplicate {\n    note: Note,\n    identical: bool,\n    first_field_match: bool,\n}\n\nimpl Duplicate {\n    fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self {\n        let identical = original.equal_fields_and_tags(&dupe);\n        Self {\n            note: dupe,\n            identical,\n            first_field_match,\n        }\n    }\n}\n\nimpl DeckIdsByNameOrId {\n    fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result<Self> {\n        let names: HashMap<UniCase<String>, DeckId> = col\n            .get_all_normal_deck_names(false)?\n            .into_iter()\n            .map(|(id, name)| (UniCase::new(name), id))\n            .collect();\n        let ids = names.values().copied().collect();\n        let mut new = Self {\n            ids,\n            names,\n            default: None,\n        };\n        new.default = new.get(default);\n        if new.default.is_none() && *default != NameOrId::default() {\n            let mut deck = Deck::new_normal();\n            deck.name = NativeDeckName::from_human_name(default.to_string());\n            col.add_deck_inner(&mut deck, usn)?;\n            new.insert(deck.id, deck.human_name());\n            new.default = Some(deck.id);\n        }\n\n        Ok(new)\n    }\n\n    fn get(&self, name_or_id: &NameOrId) -> Option<DeckId> {\n        match name_or_id {\n            _ if *name_or_id == NameOrId::default() => self.default,\n            NameOrId::Id(id) => self\n                .ids\n                .get(&DeckId(*id))\n                // try treating it as a numeric deck name\n                .or_else(|| self.names.get(&UniCase::new(id.to_string())))\n                .copied(),\n            NameOrId::Name(name) => self.names.get(&UniCase::new(name.to_string())).copied(),\n        }\n    }\n\n    fn insert(&mut self, deck_id: DeckId, name: String) {\n        self.ids.insert(deck_id);\n        self.names.insert(UniCase::new(name), deck_id);\n    }\n}\n\nimpl<'a> Context<'a> {\n    fn new(data: &ForeignData, col: &'a mut Collection) -> Result<Self> {\n        let usn = col.usn()?;\n        let normalize_notes = col.get_config_bool(BoolKey::NormalizeNoteText);\n        let timing = col.timing_today()?;\n        let mut notetypes = HashMap::new();\n        notetypes.insert(\n            NameOrId::default(),\n            col.notetype_by_name_or_id(&data.default_notetype)?,\n        );\n        let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck, usn)?;\n        let existing_checksums = ExistingChecksums::new(col, data.match_scope)?;\n        let existing_guids = col.storage.all_notes_by_guid()?;\n\n        Ok(Self {\n            col,\n            usn,\n            normalize_notes,\n            timing,\n            dupe_resolution: data.dupe_resolution,\n            notetypes,\n            deck_ids,\n            card_gen_ctxs: HashMap::new(),\n            existing_checksums,\n            existing_guids,\n        })\n    }\n\n    fn import_foreign_notetypes(&mut self, notetypes: Vec<ForeignNotetype>) -> Result<()> {\n        for foreign in notetypes {\n            let mut notetype = foreign.into_native();\n            notetype.usn = self.usn;\n            self.col\n                .add_notetype_inner(&mut notetype, self.usn, false)?;\n        }\n        Ok(())\n    }\n\n    fn notetype_for_note(&mut self, note: &ForeignNote) -> Result<Option<Arc<Notetype>>> {\n        Ok(if let Some(nt) = self.notetypes.get(&note.notetype) {\n            nt.clone()\n        } else {\n            let nt = self.col.notetype_by_name_or_id(&note.notetype)?;\n            self.notetypes.insert(note.notetype.clone(), nt.clone());\n            nt\n        })\n    }\n\n    fn import_foreign_notes(\n        &mut self,\n        notes: Vec<ForeignNote>,\n        global_tags: &[String],\n        updated_tags: &[String],\n        progress: &mut ThrottlingProgressHandler<ImportProgress>,\n    ) -> Result<NoteLog> {\n        let mut incrementor = progress.incrementor(ImportProgress::Notes);\n        let mut log = new_note_log(self.dupe_resolution, notes.len() as u32);\n        for foreign in notes {\n            incrementor.increment()?;\n            if foreign.first_field_is_the_empty_string() {\n                log.empty_first_field.push(foreign.into_log_note());\n                continue;\n            }\n            if let Some(notetype) = self.notetype_for_note(&foreign)? {\n                if let Some(deck_id) = self.get_or_create_deck_id(&foreign.deck)? {\n                    let ctx = self.build_note_context(\n                        foreign,\n                        notetype,\n                        deck_id,\n                        global_tags,\n                        updated_tags,\n                    )?;\n                    self.import_note(ctx, &mut log)?;\n                } else {\n                    log.missing_deck.push(foreign.into_log_note());\n                }\n            } else {\n                log.missing_notetype.push(foreign.into_log_note());\n            }\n        }\n        Ok(log)\n    }\n\n    fn get_or_create_deck_id(&mut self, deck: &NameOrId) -> Result<Option<DeckId>> {\n        Ok(if let Some(did) = self.deck_ids.get(deck) {\n            Some(did)\n        } else if let NameOrId::Name(name) = deck {\n            let mut deck = Deck::new_normal();\n            deck.name = NativeDeckName::from_human_name(name);\n            self.col.add_deck_inner(&mut deck, self.usn)?;\n            self.deck_ids.insert(deck.id, deck.human_name());\n            if name.is_empty() {\n                self.deck_ids.default = Some(deck.id);\n            }\n            Some(deck.id)\n        } else {\n            None\n        })\n    }\n\n    fn build_note_context<'tags>(\n        &mut self,\n        mut note: ForeignNote,\n        notetype: Arc<Notetype>,\n        deck_id: DeckId,\n        global_tags: &'tags [String],\n        updated_tags: &'tags [String],\n    ) -> Result<NoteContext<'tags>> {\n        self.prepare_foreign_note(&mut note)?;\n        let dupes = self.find_duplicates(&notetype, &note, deck_id)?;\n        Ok(NoteContext {\n            note,\n            dupes,\n            notetype,\n            deck_id,\n            global_tags,\n            updated_tags,\n        })\n    }\n\n    fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> {\n        note.normalize_fields(self.normalize_notes);\n        self.col.canonify_foreign_tags(note, self.usn)\n    }\n\n    fn find_duplicates(\n        &self,\n        notetype: &Notetype,\n        note: &ForeignNote,\n        deck_id: DeckId,\n    ) -> Result<Vec<Duplicate>> {\n        if note.guid.is_empty() {\n            if let Some(nids) = note\n                .checksum()\n                .and_then(|csum| self.existing_checksums.get(notetype.id, csum, deck_id))\n            {\n                return self.get_first_field_dupes(note, nids);\n            }\n        } else if let Some(nid) = self.existing_guids.get(&note.guid) {\n            return self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe]);\n        }\n        Ok(Vec::new())\n    }\n\n    fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result<Duplicate> {\n        self.col\n            .storage\n            .get_note(nid)?\n            .or_not_found(nid)\n            .map(|dupe| Duplicate::new(dupe, original, false))\n    }\n\n    fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result<Vec<Duplicate>> {\n        Ok(self\n            .col\n            .get_full_duplicates(note, nids)?\n            .into_iter()\n            .map(|dupe| Duplicate::new(dupe, note, true))\n            .collect())\n    }\n\n    fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {\n        match self.dupe_resolution {\n            _ if ctx.dupes.is_empty() => self.add_note(ctx, log)?,\n            DupeResolution::Duplicate if ctx.is_guid_dupe() => log\n                .duplicate\n                .push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()),\n            DupeResolution::Duplicate if !ctx.has_first_field() => {\n                log.empty_first_field.push(ctx.note.into_log_note())\n            }\n            DupeResolution::Duplicate => self.add_note(ctx, log)?,\n            DupeResolution::Update => self.update_with_note(ctx, log)?,\n            DupeResolution::Preserve => log\n                .first_field_match\n                .push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()),\n        }\n        Ok(())\n    }\n\n    fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {\n        let mut note = Note::new(&ctx.notetype);\n        let mut cards = ctx\n            .note\n            .into_native(&mut note, ctx.deck_id, &self.timing, ctx.global_tags);\n        self.prepare_note(&mut note, &ctx.notetype)?;\n        self.col.add_note_only_undoable(&mut note)?;\n        self.add_cards(&mut cards, &note, ctx.deck_id, ctx.notetype)?;\n\n        if ctx.dupes.is_empty() {\n            log.new.push(note.into_log_note());\n        } else {\n            log.first_field_match.push(note.into_log_note());\n        }\n\n        Ok(())\n    }\n\n    fn add_cards(\n        &mut self,\n        cards: &mut [Card],\n        note: &Note,\n        deck_id: DeckId,\n        notetype: Arc<Notetype>,\n    ) -> Result<()> {\n        self.import_cards(cards, note.id)?;\n        self.generate_missing_cards(notetype, deck_id, note)\n    }\n\n    fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {\n        let mut update_result = DuplicateUpdateResult::None;\n        for dupe in ctx.dupes {\n            if dupe.note.notetype_id != ctx.notetype.id {\n                update_result.update(DuplicateUpdateResult::Conflicting(dupe));\n                continue;\n            }\n\n            let mut note = dupe.note.clone();\n            let mut cards = ctx.note.clone().into_native(\n                &mut note,\n                ctx.deck_id,\n                &self.timing,\n                ctx.global_tags.iter().chain(ctx.updated_tags.iter()),\n            );\n\n            if dupe.identical {\n                update_result.update(DuplicateUpdateResult::Identical(dupe));\n            } else {\n                self.prepare_note(&mut note, &ctx.notetype)?;\n                self.col.update_note_undoable(&note, &dupe.note)?;\n                update_result.update(DuplicateUpdateResult::Update(dupe));\n            }\n            self.add_cards(&mut cards, &note, ctx.deck_id, ctx.notetype.clone())?;\n        }\n        update_result.log(log);\n\n        Ok(())\n    }\n\n    fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> {\n        note.prepare_for_update(notetype, self.normalize_notes)?;\n        self.col.canonify_note_tags(note, self.usn)?;\n        note.set_modified(self.usn);\n        Ok(())\n    }\n\n    fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {\n        for card in cards {\n            card.note_id = note_id;\n            self.col.add_card(card)?;\n        }\n        Ok(())\n    }\n\n    fn generate_missing_cards(\n        &mut self,\n        notetype: Arc<Notetype>,\n        deck_id: DeckId,\n        note: &Note,\n    ) -> Result<()> {\n        let card_gen_context = self\n            .card_gen_ctxs\n            .entry((notetype.id, deck_id))\n            .or_insert_with(|| CardGenContext::new(notetype, Some(deck_id), self.usn));\n        self.col\n            .generate_cards_for_existing_note(card_gen_context, note)\n    }\n}\n\n/// Helper enum to decide which result to log if multiple duplicates were found\n/// for a single incoming note.\nenum DuplicateUpdateResult {\n    None,\n    Conflicting(Duplicate),\n    Identical(Duplicate),\n    Update(Duplicate),\n}\n\nimpl DuplicateUpdateResult {\n    fn priority(&self) -> u8 {\n        match self {\n            DuplicateUpdateResult::None => 0,\n            DuplicateUpdateResult::Conflicting(_) => 1,\n            DuplicateUpdateResult::Identical(_) => 2,\n            DuplicateUpdateResult::Update(_) => 3,\n        }\n    }\n\n    fn update(&mut self, new: Self) {\n        if self.priority() < new.priority() {\n            *self = new;\n        }\n    }\n\n    fn log(self, log: &mut NoteLog) {\n        match self {\n            DuplicateUpdateResult::None => (),\n            DuplicateUpdateResult::Conflicting(dupe) => {\n                log.conflicting.push(dupe.note.into_log_note())\n            }\n            DuplicateUpdateResult::Identical(dupe) => log.duplicate.push(dupe.note.into_log_note()),\n            DuplicateUpdateResult::Update(dupe) if dupe.first_field_match => {\n                log.first_field_match.push(dupe.note.into_log_note())\n            }\n            DuplicateUpdateResult::Update(dupe) => log.updated.push(dupe.note.into_log_note()),\n        }\n    }\n}\n\nimpl NoteContext<'_> {\n    fn is_guid_dupe(&self) -> bool {\n        self.dupes\n            .first()\n            .is_some_and(|d| d.note.guid == self.note.guid)\n    }\n\n    fn has_first_field(&self) -> bool {\n        self.note.first_field_is_unempty()\n    }\n}\n\nimpl Note {\n    fn first_field_stripped(&self) -> Cow<'_, str> {\n        strip_html_preserving_media_filenames(&self.fields()[0])\n    }\n}\n\nimpl Collection {\n    pub(super) fn deck_id_by_name_or_id(&mut self, deck: &NameOrId) -> Result<Option<DeckId>> {\n        match deck {\n            NameOrId::Id(id) => Ok({\n                match self.get_deck(DeckId(*id))?.map(|d| d.id) {\n                    did @ Some(_) => did,\n                    // try treating it as a numeric deck name\n                    _ => self.get_deck_id(&id.to_string())?,\n                }\n            }),\n            NameOrId::Name(name) => self.get_deck_id(name),\n        }\n    }\n\n    pub(super) fn notetype_by_name_or_id(\n        &mut self,\n        notetype: &NameOrId,\n    ) -> Result<Option<Arc<Notetype>>> {\n        match notetype {\n            NameOrId::Id(id) => Ok({\n                match self.get_notetype(NotetypeId(*id))? {\n                    nt @ Some(_) => nt,\n                    // try treating it as a numeric notetype name\n                    _ => self.get_notetype_by_name(&id.to_string())?,\n                }\n            }),\n            NameOrId::Name(name) => self.get_notetype_by_name(name),\n        }\n    }\n\n    fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> {\n        if let Some(tags) = note.tags.take() {\n            note.tags\n                .replace(self.canonify_tags_without_registering(tags, usn)?);\n        }\n        Ok(())\n    }\n\n    fn get_full_duplicates(&self, note: &ForeignNote, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {\n        let first_field = note.first_field_stripped().or_invalid(\"no first field\")?;\n        dupe_ids\n            .iter()\n            .filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())\n            .filter(|res| match res {\n                Ok(dupe) => dupe.first_field_stripped() == first_field,\n                Err(_) => true,\n            })\n            .collect()\n    }\n}\n\nimpl ForeignNote {\n    /// Updates a native note with the foreign data and returns its new cards.\n    fn into_native<'tags>(\n        self,\n        note: &mut Note,\n        deck_id: DeckId,\n        timing: &SchedTimingToday,\n        extra_tags: impl IntoIterator<Item = &'tags String>,\n    ) -> Vec<Card> {\n        // TODO: Handle new and learning cards\n        if !self.guid.is_empty() {\n            note.guid = self.guid;\n        }\n        if let Some(tags) = self.tags {\n            note.tags = tags;\n        }\n        note.tags.extend(extra_tags.into_iter().cloned());\n        note.fields_mut()\n            .iter_mut()\n            .zip(self.fields)\n            .for_each(|(field, new)| {\n                if let Some(s) = new {\n                    *field = s;\n                }\n            });\n        self.cards\n            .into_iter()\n            .enumerate()\n            .map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, timing))\n            .collect()\n    }\n\n    fn first_field_is_the_empty_string(&self) -> bool {\n        matches!(self.fields.first(), Some(Some(s)) if s.is_empty())\n    }\n\n    fn first_field_is_unempty(&self) -> bool {\n        matches!(self.fields.first(), Some(Some(s)) if !s.is_empty())\n    }\n\n    fn normalize_fields(&mut self, normalize_text: bool) {\n        for field in self.fields.iter_mut().flatten() {\n            normalize_field(field, normalize_text);\n        }\n    }\n\n    /// Expects normalized form.\n    fn equal_fields_and_tags(&self, other: &Note) -> bool {\n        self.tags.as_ref().map_or(true, |tags| *tags == other.tags)\n            && self\n                .fields\n                .iter()\n                .zip(other.fields())\n                .all(|(opt, field)| opt.as_ref().map(|s| s == field).unwrap_or(true))\n    }\n\n    fn first_field_stripped(&self) -> Option<Cow<'_, str>> {\n        self.fields\n            .first()\n            .and_then(|s| s.as_ref())\n            .map(|field| strip_html_preserving_media_filenames(field.as_str()))\n    }\n\n    /// If the first field is set, returns its checksum. Field is expected to be\n    /// normalized.\n    fn checksum(&self) -> Option<u32> {\n        self.first_field_stripped()\n            .map(|field| field_checksum(&field))\n    }\n}\n\nimpl ForeignCard {\n    fn into_native(\n        self,\n        note_id: NoteId,\n        template_idx: u16,\n        deck_id: DeckId,\n        timing: &SchedTimingToday,\n    ) -> Card {\n        Card {\n            note_id,\n            template_idx,\n            deck_id,\n            due: self.native_due(timing),\n            interval: self.interval,\n            ease_factor: (self.ease_factor * 1000.).round() as u16,\n            reps: self.reps,\n            lapses: self.lapses,\n            ctype: CardType::Review,\n            queue: CardQueue::Review,\n            ..Default::default()\n        }\n    }\n\n    fn native_due(self, timing: &SchedTimingToday) -> i32 {\n        let day_start = timing.next_day_at.0 - 86_400;\n        let due_delta = (self.due - day_start) / 86_400;\n        due_delta as i32 + timing.days_elapsed as i32\n    }\n}\n\nimpl ForeignNotetype {\n    fn into_native(self) -> Notetype {\n        Notetype {\n            name: self.name,\n            fields: self.fields.into_iter().map(NoteField::new).collect(),\n            templates: self\n                .templates\n                .into_iter()\n                .map(ForeignTemplate::into_native)\n                .collect(),\n            config: if self.is_cloze {\n                Notetype::new_cloze_config()\n            } else {\n                Notetype::new_config()\n            },\n            ..Notetype::default()\n        }\n    }\n}\n\nimpl ForeignTemplate {\n    fn into_native(self) -> CardTemplate {\n        CardTemplate::new(self.name, self.qfmt, self.afmt)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::tests::DeckAdder;\n    use crate::tests::NoteAdder;\n\n    impl ForeignData {\n        fn with_defaults() -> Self {\n            Self {\n                default_notetype: NameOrId::Name(\"Basic\".to_string()),\n                default_deck: NameOrId::Id(1),\n                ..Default::default()\n            }\n        }\n\n        fn add_note(&mut self, fields: &[&str]) {\n            self.notes.push(ForeignNote {\n                fields: fields.iter().map(ToString::to_string).map(Some).collect(),\n                ..Default::default()\n            });\n        }\n    }\n\n    #[test]\n    fn should_always_add_note_if_dupe_mode_is_add() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"same\", \"old\"]);\n        data.dupe_resolution = DupeResolution::Duplicate;\n\n        let progress = col.new_progress_handler();\n        data.clone().import(&mut col, progress).unwrap();\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.notes_table_len(), 2);\n    }\n\n    #[test]\n    fn should_add_or_ignore_note_if_dupe_mode_is_ignore() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"same\", \"old\"]);\n        data.dupe_resolution = DupeResolution::Preserve;\n        let progress = col.new_progress_handler();\n        data.clone().import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.notes_table_len(), 1);\n\n        data.notes[0].fields[1].replace(\"new\".to_string());\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        let notes = col.storage.get_all_notes();\n        assert_eq!(notes.len(), 1);\n        assert_eq!(notes[0].fields()[1], \"old\");\n    }\n\n    #[test]\n    fn should_update_or_add_note_if_dupe_mode_is_update() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"same\", \"old\"]);\n        data.dupe_resolution = DupeResolution::Update;\n        let progress = col.new_progress_handler();\n        data.clone().import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.notes_table_len(), 1);\n\n        data.notes[0].fields[1].replace(\"new\".to_string());\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.get_all_notes()[0].fields()[1], \"new\");\n    }\n\n    #[test]\n    fn should_keep_old_field_content_if_no_new_one_is_supplied() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"same\", \"unchanged\"]);\n        data.add_note(&[\"same\", \"unchanged\"]);\n        data.dupe_resolution = DupeResolution::Update;\n        let progress = col.new_progress_handler();\n        data.clone().import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.notes_table_len(), 2);\n\n        data.notes[0].fields[1] = None;\n        data.notes[1].fields.pop();\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        let notes = col.storage.get_all_notes();\n        assert_eq!(notes[0].fields(), &[\"same\", \"unchanged\"]);\n        assert_eq!(notes[0].fields(), &[\"same\", \"unchanged\"]);\n    }\n\n    #[test]\n    fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {\n        let mut col = Collection::new();\n        NoteAdder::basic(&mut col)\n            .fields(&[\"神\", \"old\"])\n            .add(&mut col);\n        let mut data = ForeignData::with_defaults();\n        data.dupe_resolution = DupeResolution::Update;\n        data.add_note(&[\"神\", \"new\"]);\n        let progress = col.new_progress_handler();\n\n        data.clone().import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.get_all_notes()[0].fields(), &[\"神\", \"new\"]);\n\n        col.set_config_bool(BoolKey::NormalizeNoteText, false, false)\n            .unwrap();\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        let notes = col.storage.get_all_notes();\n        assert_eq!(notes[0].fields(), &[\"神\", \"new\"]);\n        assert_eq!(notes[1].fields(), &[\"神\", \"new\"]);\n    }\n\n    #[test]\n    fn should_add_global_tags() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"foo\"]);\n        data.notes[0].tags.replace(vec![String::from(\"bar\")]);\n        data.global_tags = vec![String::from(\"baz\")];\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.get_all_notes()[0].tags, [\"bar\", \"baz\"]);\n    }\n\n    #[test]\n    fn should_match_note_with_same_guid() {\n        let mut col = Collection::new();\n        let mut data = ForeignData::with_defaults();\n        data.add_note(&[\"foo\"]);\n        data.notes[0].tags.replace(vec![String::from(\"bar\")]);\n        data.global_tags = vec![String::from(\"baz\")];\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        assert_eq!(col.storage.get_all_notes()[0].tags, [\"bar\", \"baz\"]);\n    }\n\n    #[test]\n    fn should_only_update_duplicates_in_same_deck_if_limit_is_enabled() {\n        let mut col = Collection::new();\n        let other_deck_id = DeckAdder::new(\"other\").add(&mut col).id;\n        NoteAdder::basic(&mut col)\n            .fields(&[\"foo\", \"old\"])\n            .add(&mut col);\n        NoteAdder::basic(&mut col)\n            .fields(&[\"foo\", \"old\"])\n            .deck(other_deck_id)\n            .add(&mut col);\n        let mut data = ForeignData::with_defaults();\n        data.match_scope = MatchScope::NotetypeAndDeck;\n        data.add_note(&[\"foo\", \"new\"]);\n        let progress = col.new_progress_handler();\n        data.import(&mut col, progress).unwrap();\n        let notes = col.storage.get_all_notes();\n        // same deck, should be updated\n        assert_eq!(notes[0].fields()[1], \"new\");\n        // other deck, should be unchanged\n        assert_eq!(notes[1].fields()[1], \"old\");\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/json.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_io::read_file;\n\nuse crate::import_export::text::ForeignData;\nuse crate::import_export::NoteLog;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn import_json_file(&mut self, path: &str) -> Result<OpOutput<NoteLog>> {\n        let progress = self.new_progress_handler();\n        let slice = read_file(path)?;\n        let data: ForeignData = serde_json::from_slice(&slice)?;\n        data.import(self, progress)\n    }\n\n    pub fn import_json_string(&mut self, json: &str) -> Result<OpOutput<NoteLog>> {\n        let progress = self.new_progress_handler();\n        let data: ForeignData = serde_json::from_str(json)?;\n        data.import(self, progress)\n    }\n}\n"
  },
  {
    "path": "rslib/src/import_export/text/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod csv;\nmod import;\nmod json;\n\nuse anki_proto::import_export::csv_metadata::DupeResolution;\nuse anki_proto::import_export::csv_metadata::MatchScope;\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse super::LogNote;\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize)]\n#[serde(default)]\npub struct ForeignData {\n    dupe_resolution: DupeResolution,\n    match_scope: MatchScope,\n    default_deck: NameOrId,\n    default_notetype: NameOrId,\n    notes: Vec<ForeignNote>,\n    notetypes: Vec<ForeignNotetype>,\n    global_tags: Vec<String>,\n    updated_tags: Vec<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]\n#[serde(default)]\npub struct ForeignNote {\n    guid: String,\n    fields: Vec<Option<String>>,\n    tags: Option<Vec<String>>,\n    notetype: NameOrId,\n    deck: NameOrId,\n    cards: Vec<ForeignCard>,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]\n#[serde(default)]\npub struct ForeignCard {\n    /// Seconds-based timestamp\n    pub due: i64,\n    /// In days\n    pub interval: u32,\n    pub ease_factor: f32,\n    pub reps: u32,\n    pub lapses: u32,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct ForeignNotetype {\n    name: String,\n    fields: Vec<String>,\n    templates: Vec<ForeignTemplate>,\n    #[serde(default)]\n    is_cloze: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct ForeignTemplate {\n    name: String,\n    qfmt: String,\n    afmt: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum NameOrId {\n    Id(i64),\n    Name(String),\n}\n\nimpl Default for NameOrId {\n    fn default() -> Self {\n        NameOrId::Name(String::new())\n    }\n}\n\nimpl From<String> for NameOrId {\n    fn from(s: String) -> Self {\n        Self::Name(s)\n    }\n}\n\nimpl std::fmt::Display for NameOrId {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            NameOrId::Id(did) => write!(f, \"{did}\"),\n            NameOrId::Name(name) => write!(f, \"{name}\"),\n        }\n    }\n}\n\nimpl ForeignNote {\n    pub(crate) fn into_log_note(self) -> LogNote {\n        LogNote {\n            id: None,\n            fields: self\n                .fields\n                .into_iter()\n                .map(Option::unwrap_or_default)\n                .collect(),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/latex.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\n\nuse regex::Captures;\nuse regex::Regex;\n\nuse crate::cloze::expand_clozes_to_reveal_latex;\nuse crate::media::files::sha1_of_data;\nuse crate::text::strip_html;\n\npub(crate) static LATEX: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?xsi)\n            \\[latex\\](.+?)\\[/latex\\]     # 1 - standard latex\n            |\n            \\[\\$\\](.+?)\\[/\\$\\]           # 2 - inline math\n            |\n            \\[\\$\\$\\](.+?)\\[/\\$\\$\\]       # 3 - math environment\n            \",\n    )\n    .unwrap()\n});\nstatic LATEX_NEWLINES: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?xi)\n            <br( /)?>\n            |\n            <div>\n        \"#,\n    )\n    .unwrap()\n});\n\npub(crate) fn contains_latex(text: &str) -> bool {\n    LATEX.is_match(text)\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct ExtractedLatex {\n    pub fname: String,\n    pub latex: String,\n}\n\n/// Expand any cloze deletions, then extract LaTeX.\npub(crate) fn extract_latex_expanding_clozes(\n    text: &str,\n    svg: bool,\n) -> (Cow<'_, str>, Vec<ExtractedLatex>) {\n    if text.contains(\"{{c\") {\n        let expanded = expand_clozes_to_reveal_latex(text);\n        let (text, extracts) = extract_latex(&expanded, svg);\n        (text.into_owned().into(), extracts)\n    } else {\n        extract_latex(text, svg)\n    }\n}\n\n/// Extract LaTeX from the provided text.\n/// Expects cloze deletions to already be expanded.\npub fn extract_latex(text: &str, svg: bool) -> (Cow<'_, str>, Vec<ExtractedLatex>) {\n    let mut extracted = vec![];\n\n    let new_text = LATEX.replace_all(text, |caps: &Captures| {\n        let latex = match (caps.get(1), caps.get(2), caps.get(3)) {\n            (Some(m), _, _) => m.as_str().into(),\n            (_, Some(m), _) => format!(\"${}$\", m.as_str()),\n            (_, _, Some(m)) => format!(r\"\\begin{{displaymath}}{}\\end{{displaymath}}\", m.as_str()),\n            _ => unreachable!(),\n        };\n        let latex_text = strip_html_for_latex(&latex);\n        let fname = fname_for_latex(&latex_text, svg);\n        let img_link = image_link_for_fname(&latex_text, &fname);\n        extracted.push(ExtractedLatex {\n            fname,\n            latex: latex_text.into(),\n        });\n\n        img_link\n    });\n\n    (new_text, extracted)\n}\n\nfn strip_html_for_latex(html: &str) -> Cow<'_, str> {\n    let mut out: Cow<str> = html.into();\n    if let Cow::Owned(o) = LATEX_NEWLINES.replace_all(html, \"\\n\") {\n        out = o.into();\n    }\n    if let Cow::Owned(o) = strip_html(out.as_ref()) {\n        out = o.into();\n    }\n\n    out\n}\n\nfn fname_for_latex(latex: &str, svg: bool) -> String {\n    let ext = if svg { \"svg\" } else { \"png\" };\n    let csum = hex::encode(sha1_of_data(latex.as_bytes()));\n\n    format!(\"latex-{csum}.{ext}\")\n}\n\nfn image_link_for_fname(src: &str, fname: &str) -> String {\n    format!(\n        \"<img class=latex alt=\\\"{}\\\" src=\\\"{}\\\">\",\n        htmlescape::encode_attribute(src),\n        fname\n    )\n}\n\n#[cfg(test)]\nmod test {\n    use crate::latex::extract_latex;\n    use crate::latex::ExtractedLatex;\n\n    #[test]\n    fn latex() {\n        let fname = \"latex-ef30b3f4141c33a5bf7044b0d1961d3399c05d50.png\";\n        assert_eq!(\n            extract_latex(\"a[latex]one<br>and<div>two[/latex]b\", false),\n            (\n                format!(\"a<img class=latex alt=\\\"one&#x0A;and&#x0A;two\\\" src=\\\"{fname}\\\">b\").into(),\n                vec![ExtractedLatex {\n                    fname: fname.into(),\n                    latex: \"one\\nand\\ntwo\".into()\n                }]\n            )\n        );\n\n        assert_eq!(\n            extract_latex(\"[$]<b>hello</b>&nbsp; world[/$]\", true).1,\n            vec![ExtractedLatex {\n                fname: \"latex-060219fbf3ddb74306abddaf4504276ad793b029.svg\".to_string(),\n                latex: \"$hello  world$\".to_string()\n            }]\n        );\n\n        assert_eq!(\n            extract_latex(\"[$$]math &amp; stuff[/$$]\", false).1,\n            vec![ExtractedLatex {\n                fname: \"latex-8899f3f849ffdef6e4e9f2f34a923a1f608ebc07.png\".to_string(),\n                latex: r\"\\begin{displaymath}math & stuff\\end{displaymath}\".to_string()\n            }]\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/lib.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![deny(unused_must_use)]\n\npub mod adding;\npub(crate) mod ankidroid;\npub mod ankihub;\npub mod backend;\npub mod browser_table;\npub mod card;\npub mod card_rendering;\npub mod cloze;\npub mod collection;\npub mod config;\npub mod dbcheck;\npub mod deckconfig;\npub mod decks;\npub mod error;\npub mod findreplace;\npub mod i18n;\npub mod image_occlusion;\npub mod import_export;\npub mod latex;\npub mod links;\npub mod log;\nmod markdown;\npub mod media;\npub mod notes;\npub mod notetype;\npub mod ops;\nmod preferences;\npub mod prelude;\nmod progress;\npub mod revlog;\npub mod scheduler;\npub mod search;\npub mod serde;\npub mod services;\nmod stats;\npub mod storage;\npub mod sync;\npub mod tags;\npub mod template;\npub mod template_filters;\npub(crate) mod tests;\npub mod text;\npub mod timestamp;\nmod typeanswer;\npub mod types;\npub mod undo;\npub mod version;\n\nuse std::env;\nuse std::sync::LazyLock;\n\npub(crate) static PYTHON_UNIT_TESTS: LazyLock<bool> =\n    LazyLock::new(|| env::var(\"ANKI_TEST_MODE\").is_ok());\n"
  },
  {
    "path": "rslib/src/links.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub use anki_proto::links::help_page_link_request::HelpPage;\n\nuse crate::collection::Collection;\nuse crate::error;\n\nstatic HELP_SITE: &str = \"https://docs.ankiweb.net/\";\n\npub fn help_page_to_link(page: HelpPage) -> String {\n    format!(\"{}{}\", HELP_SITE, help_page_link_suffix(page))\n}\n\npub fn help_page_link_suffix(page: HelpPage) -> &'static str {\n    match page {\n        HelpPage::NoteType => \"getting-started.html#note-types\",\n        HelpPage::Browsing => \"browsing.html\",\n        HelpPage::BrowsingFindAndReplace => \"browsing.html#find-and-replace\",\n        HelpPage::BrowsingNotesMenu => \"browsing.html#notes\",\n        HelpPage::KeyboardShortcuts => \"studying.html#keyboard-shortcuts\",\n        HelpPage::Editing => \"editing.html\",\n        HelpPage::AddingCardAndNote => \"editing.html#adding-cards-and-notes\",\n        HelpPage::AddingANoteType => \"editing.html#adding-a-note-type\",\n        HelpPage::Latex => \"math.html#latex\",\n        HelpPage::Preferences => \"preferences.html\",\n        HelpPage::Index => \"\",\n        HelpPage::Templates => \"templates/intro.html\",\n        HelpPage::FilteredDeck => \"filtered-decks.html\",\n        HelpPage::Importing => \"importing/intro.html\",\n        HelpPage::CustomizingFields => \"editing.html#customizing-fields\",\n        HelpPage::DeckOptions => \"deck-options.html\",\n        HelpPage::EditingFeatures => \"editing.html#editing-features\",\n        HelpPage::FullScreenIssue => \"platform/windows/display-issues.html#full-screen\",\n        HelpPage::CardTypeTemplateError => \"templates/errors.html#template-syntax-error\",\n        HelpPage::CardTypeDuplicate => \"templates/errors.html#identical-front-sides\",\n        HelpPage::CardTypeNoFrontField => {\n            \"templates/errors.html#no-field-replacement-on-front-side\"\n        }\n        HelpPage::CardTypeMissingCloze => \"templates/errors.html#no-cloze-filter-on-cloze-notetype\",\n        HelpPage::Troubleshooting => \"troubleshooting.html\",\n    }\n}\n\nimpl crate::services::LinksService for Collection {\n    fn help_page_link(\n        &mut self,\n        input: anki_proto::links::HelpPageLinkRequest,\n    ) -> error::Result<anki_proto::generic::String> {\n        Ok(help_page_to_link(HelpPage::try_from(input.page).unwrap_or(HelpPage::Index)).into())\n    }\n}\n"
  },
  {
    "path": "rslib/src/log.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::fs::OpenOptions;\nuse std::io;\n\nuse once_cell::sync::OnceCell;\nuse tracing::subscriber::set_global_default;\nuse tracing_appender::non_blocking::NonBlocking;\nuse tracing_appender::non_blocking::WorkerGuard;\nuse tracing_subscriber::fmt;\nuse tracing_subscriber::fmt::Layer;\nuse tracing_subscriber::layer::SubscriberExt;\nuse tracing_subscriber::EnvFilter;\n\nuse crate::prelude::*;\n\nconst LOG_ROTATE_BYTES: u64 = 50 * 1024 * 1024;\n\n/// Enable logging to the console, and optionally also to a file.\npub fn set_global_logger(path: Option<&str>) -> Result<()> {\n    if std::env::var(\"BURN_LOG\").is_ok() {\n        return Ok(());\n    }\n    static ONCE: OnceCell<()> = OnceCell::new();\n    ONCE.get_or_try_init(|| -> Result<()> {\n        let file_writer = if let Some(path) = path {\n            Some(Layer::new().with_writer(get_appender(path)?))\n        } else {\n            None\n        };\n        let subscriber = tracing_subscriber::registry()\n            .with(fmt::layer().with_target(false))\n            .with(file_writer)\n            .with(EnvFilter::from_default_env());\n        set_global_default(subscriber).or_invalid(\"global subscriber already set\")?;\n        Ok(())\n    })?;\n    Ok(())\n}\n\n/// Holding on to this guard does not actually ensure the log file will be fully\n/// written, as statics do not implement Drop.\nstatic APPENDER_GUARD: OnceCell<WorkerGuard> = OnceCell::new();\n\nfn get_appender(path: &str) -> Result<NonBlocking> {\n    maybe_rotate_log(path)?;\n    let file = OpenOptions::new().create(true).append(true).open(path)?;\n    let (appender, guard) = tracing_appender::non_blocking(file);\n    if APPENDER_GUARD.set(guard).is_err() {\n        invalid_input!(\"log file should be set only once\");\n    }\n    Ok(appender)\n}\n\nfn maybe_rotate_log(path: &str) -> io::Result<()> {\n    let current_bytes = match fs::metadata(path) {\n        Ok(meta) => meta.len(),\n        Err(e) => {\n            if e.kind() == io::ErrorKind::NotFound {\n                0\n            } else {\n                return Err(e);\n            }\n        }\n    };\n    if current_bytes < LOG_ROTATE_BYTES {\n        return Ok(());\n    }\n\n    let path2 = format!(\"{path}.1\");\n    let path3 = format!(\"{path}.2\");\n\n    // if a rotated file already exists, rename it\n    if let Err(e) = fs::rename(&path2, path3) {\n        if e.kind() != io::ErrorKind::NotFound {\n            return Err(e);\n        }\n    }\n\n    // and rotate the primary log\n    fs::rename(path, path2)\n}\n"
  },
  {
    "path": "rslib/src/markdown.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse pulldown_cmark::html;\nuse pulldown_cmark::Parser;\n\npub(crate) fn render_markdown(markdown: &str) -> String {\n    let mut buf = String::with_capacity(markdown.len());\n    let parser = Parser::new(markdown);\n    html::push_html(&mut buf, parser);\n    buf\n}\n"
  },
  {
    "path": "rslib/src/media/check.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::fs;\nuse std::io;\nuse std::sync::LazyLock;\n\nuse anki_i18n::without_unicode_isolation;\nuse anki_io::write_file;\nuse data_encoding::BASE64;\nuse regex::Regex;\nuse tracing::debug;\nuse tracing::info;\n\nuse crate::error::DbErrorKind;\nuse crate::latex::extract_latex_expanding_clozes;\nuse crate::media::files::data_for_file;\nuse crate::media::files::filename_if_normalized;\nuse crate::media::files::normalize_nfc_filename;\nuse crate::media::files::sha1_of_data;\nuse crate::media::files::trash_folder;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::media::progress::MediaCheckProgress;\nuse crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE;\nuse crate::text::extract_media_refs;\nuse crate::text::normalize_to_nfc;\nuse crate::text::CowMapping;\nuse crate::text::MediaRef;\nuse crate::text::REMOTE_FILENAME;\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub struct MediaCheckOutput {\n    pub unused: Vec<String>,\n    pub missing: Vec<String>,\n    pub missing_media_notes: Vec<NoteId>,\n    pub renamed: HashMap<String, String>,\n    pub dirs: Vec<String>,\n    pub oversize: Vec<String>,\n    pub trash_count: u64,\n    pub trash_bytes: u64,\n    pub inlined_image_count: u64,\n}\n\n#[derive(Debug, PartialEq, Eq, Default)]\nstruct MediaFolderCheck {\n    files: Vec<String>,\n    renamed: HashMap<String, String>,\n    dirs: Vec<String>,\n    oversize: Vec<String>,\n}\n\nimpl Collection {\n    pub fn media_checker(&mut self) -> Result<MediaChecker<'_>> {\n        MediaChecker::new(self)\n    }\n}\n\npub struct MediaChecker<'a> {\n    col: &'a mut Collection,\n    media: MediaManager,\n    progress: ThrottlingProgressHandler<MediaCheckProgress>,\n    inlined_image_count: u64,\n}\n\nimpl MediaChecker<'_> {\n    pub(crate) fn new(col: &mut Collection) -> Result<MediaChecker<'_>> {\n        Ok(MediaChecker {\n            media: col.media()?,\n            progress: col.new_progress_handler(),\n            col,\n            inlined_image_count: 0,\n        })\n    }\n\n    pub fn check(&mut self) -> Result<MediaCheckOutput> {\n        let folder_check = self.check_media_folder()?;\n        let references = self.check_media_references(&folder_check.renamed)?;\n        let unused_and_missing = UnusedAndMissingFiles::new(folder_check.files, references);\n        let (trash_count, trash_bytes) = self.files_in_trash()?;\n        Ok(MediaCheckOutput {\n            unused: unused_and_missing.unused,\n            missing: unused_and_missing.missing,\n            missing_media_notes: unused_and_missing.missing_media_notes,\n            renamed: folder_check.renamed,\n            dirs: folder_check.dirs,\n            oversize: folder_check.oversize,\n            trash_count,\n            trash_bytes,\n            inlined_image_count: self.inlined_image_count,\n        })\n    }\n\n    pub fn summarize_output(&self, output: &mut MediaCheckOutput) -> String {\n        let mut buf = String::new();\n        let tr = &self.col.tr;\n\n        // top summary area\n        if output.trash_count > 0 {\n            let megs = (output.trash_bytes as f32) / 1024.0 / 1024.0;\n            buf += &tr.media_check_trash_count(output.trash_count, megs);\n            buf.push('\\n');\n        }\n\n        buf += &tr.media_check_missing_count(output.missing.len());\n        buf.push('\\n');\n\n        buf += &tr.media_check_unused_count(output.unused.len());\n        buf.push('\\n');\n\n        if output.inlined_image_count > 0 {\n            buf += &tr.media_check_extracted_count(output.inlined_image_count);\n            buf.push('\\n');\n        }\n\n        if !output.renamed.is_empty() {\n            buf += &tr.media_check_renamed_count(output.renamed.len());\n            buf.push('\\n');\n        }\n        if !output.oversize.is_empty() {\n            buf += &tr.media_check_oversize_count(output.oversize.len());\n            buf.push('\\n');\n        }\n        if !output.dirs.is_empty() {\n            buf += &tr.media_check_subfolder_count(output.dirs.len());\n            buf.push('\\n');\n        }\n\n        buf.push('\\n');\n\n        if !output.renamed.is_empty() {\n            buf += &tr.media_check_renamed_header();\n            buf.push('\\n');\n            for (old, new) in &output.renamed {\n                buf += &without_unicode_isolation(\n                    &tr.media_check_renamed_file(old.as_str(), new.as_str()),\n                );\n                buf.push('\\n');\n            }\n            buf.push('\\n')\n        }\n\n        if !output.oversize.is_empty() {\n            output.oversize.sort();\n            buf += &tr.media_check_oversize_header();\n            buf.push('\\n');\n            for fname in &output.oversize {\n                buf += &without_unicode_isolation(&tr.media_check_oversize_file(fname.as_str()));\n                buf.push('\\n');\n            }\n            buf.push('\\n')\n        }\n\n        if !output.dirs.is_empty() {\n            output.dirs.sort();\n            buf += &tr.media_check_subfolder_header();\n            buf.push('\\n');\n            for fname in &output.dirs {\n                buf += &without_unicode_isolation(&tr.media_check_subfolder_file(fname.as_str()));\n                buf.push('\\n');\n            }\n            buf.push('\\n')\n        }\n\n        if !output.missing.is_empty() {\n            output.missing.sort();\n            buf += &tr.media_check_missing_header();\n            buf.push('\\n');\n            for fname in &output.missing {\n                buf += &without_unicode_isolation(&tr.media_check_missing_file(fname.as_str()));\n                buf.push('\\n');\n            }\n            buf.push('\\n')\n        }\n\n        if !output.unused.is_empty() {\n            output.unused.sort();\n            buf += &tr.media_check_unused_header();\n            buf.push('\\n');\n            for fname in &output.unused {\n                buf += &without_unicode_isolation(&tr.media_check_unused_file(fname.as_str()));\n                buf.push('\\n');\n            }\n        }\n\n        buf\n    }\n\n    fn increment_progress(&mut self) -> Result<()> {\n        self.progress.increment(|p| &mut p.checked)\n    }\n\n    /// Check all the files in the media folder.\n    ///\n    /// - Renames files with invalid names\n    /// - Notes folders/oversized files\n    /// - Gathers a list of all files\n    fn check_media_folder(&mut self) -> Result<MediaFolderCheck> {\n        let mut out = MediaFolderCheck::default();\n\n        for dentry in self.media.media_folder.read_dir()? {\n            let dentry = dentry?;\n\n            self.increment_progress()?;\n\n            // if the filename is not valid unicode, skip it\n            let fname_os = dentry.file_name();\n            let disk_fname = match fname_os.to_str() {\n                Some(s) => s,\n                None => continue,\n            };\n\n            if fname_os == \".DS_Store\" {\n                continue;\n            }\n\n            // skip folders\n            if dentry.file_type()?.is_dir() {\n                out.dirs.push(disk_fname.to_string());\n                continue;\n            }\n\n            // ignore large files and zero byte files\n            let metadata = dentry.metadata()?;\n            if metadata.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 {\n                out.oversize.push(disk_fname.to_string());\n                continue;\n            }\n            if metadata.len() == 0 {\n                continue;\n            }\n\n            if let Some(norm_name) = filename_if_normalized(disk_fname) {\n                out.files.push(norm_name.into_owned());\n            } else {\n                match data_for_file(&self.media.media_folder, disk_fname)? {\n                    Some(data) => {\n                        let norm_name = self.normalize_file(disk_fname, data)?;\n                        out.renamed\n                            .insert(disk_fname.to_string(), norm_name.to_string());\n                        out.files.push(norm_name.into_owned());\n                    }\n                    None => {\n                        // file not found, caused by the file being removed at this exact instant,\n                        // or the path being larger than MAXPATH on Windows\n                        continue;\n                    }\n                };\n            }\n        }\n\n        Ok(out)\n    }\n\n    /// Write file data to normalized location, moving old file to trash.\n    fn normalize_file<'a>(&mut self, disk_fname: &'a str, data: Vec<u8>) -> Result<Cow<'a, str>> {\n        // add a copy of the file using the correct name\n        let fname = self.media.add_file(disk_fname, &data)?;\n        debug!(from = disk_fname, to = &fname.as_ref(), \"renamed\");\n        assert_ne!(fname.as_ref(), disk_fname);\n\n        // remove the original file\n        let path = &self.media.media_folder.join(disk_fname);\n        fs::remove_file(path)?;\n\n        Ok(fname)\n    }\n\n    /// Returns the count and total size of the files in the trash folder\n    fn files_in_trash(&mut self) -> Result<(u64, u64)> {\n        let trash = trash_folder(&self.media.media_folder)?;\n        let mut total_files = 0;\n        let mut total_bytes = 0;\n\n        for dentry in trash.read_dir()? {\n            let dentry = dentry?;\n\n            self.increment_progress()?;\n\n            if dentry.file_name() == \".DS_Store\" {\n                continue;\n            }\n\n            let meta = dentry.metadata()?;\n\n            total_files += 1;\n            total_bytes += meta.len();\n        }\n\n        Ok((total_files, total_bytes))\n    }\n\n    pub fn empty_trash(&mut self) -> Result<()> {\n        let trash = trash_folder(&self.media.media_folder)?;\n\n        for dentry in trash.read_dir()? {\n            let dentry = dentry?;\n\n            self.increment_progress()?;\n\n            fs::remove_file(dentry.path())?;\n        }\n\n        Ok(())\n    }\n\n    pub fn restore_trash(&mut self) -> Result<()> {\n        let trash = trash_folder(&self.media.media_folder)?;\n\n        for dentry in trash.read_dir()? {\n            let dentry = dentry?;\n\n            self.increment_progress()?;\n\n            let orig_path = self.media.media_folder.join(dentry.file_name());\n            // if the original filename doesn't exist, we can just rename\n            if let Err(e) = fs::metadata(&orig_path) {\n                if e.kind() == io::ErrorKind::NotFound {\n                    fs::rename(dentry.path(), &orig_path)?;\n                } else {\n                    return Err(e.into());\n                }\n            } else {\n                // ensure we don't overwrite different data\n                let fname_os = dentry.file_name();\n                let fname = fname_os.to_string_lossy();\n                if let Some(data) = data_for_file(&trash, fname.as_ref())? {\n                    let _new_fname = self.media.add_file(fname.as_ref(), &data)?;\n                } else {\n                    debug!(?fname, \"file disappeared while restoring trash\");\n                }\n                fs::remove_file(dentry.path())?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Find all media references in notes, fixing as necessary.\n    fn check_media_references(\n        &mut self,\n        renamed: &HashMap<String, String>,\n    ) -> Result<HashMap<String, Vec<NoteId>>> {\n        let mut referenced_files = HashMap::new();\n        let notetypes = self.col.get_all_notetypes()?;\n        let mut collection_modified = false;\n\n        let nids = self.col.search_notes_unordered(\"\")?;\n        let usn = self.col.usn()?;\n        for nid in nids {\n            self.increment_progress()?;\n            let mut note = self.col.storage.get_note(nid)?.unwrap();\n            let nt = notetypes\n                .iter()\n                .find(|nt| nt.id == note.notetype_id)\n                .ok_or_else(|| {\n                    AnkiError::db_error(\"missing note type\", DbErrorKind::MissingEntity)\n                })?;\n            let mut tracker = |fname| {\n                referenced_files\n                    .entry(fname)\n                    .or_insert_with(Vec::new)\n                    .push(nid)\n            };\n            if self.fix_and_extract_media_refs(&mut note, &mut tracker, renamed)? {\n                // note was modified, needs saving\n                note.prepare_for_update(nt, false)?;\n                note.set_modified(usn);\n                self.col.storage.update_note(&note)?;\n                collection_modified = true;\n            }\n\n            // extract latex\n            extract_latex_refs(&note, &mut tracker, nt.config.latex_svg);\n        }\n\n        if collection_modified {\n            // fixme: need to refactor to use new transaction handling?\n            // self.ctx.storage.commit_trx()?;\n        }\n\n        Ok(referenced_files)\n    }\n\n    /// Returns true if note was modified.\n    fn fix_and_extract_media_refs(\n        &mut self,\n        note: &mut Note,\n        mut tracker: impl FnMut(String),\n        renamed: &HashMap<String, String>,\n    ) -> Result<bool> {\n        let mut updated = false;\n\n        for idx in 0..note.fields().len() {\n            let field =\n                self.normalize_and_maybe_rename_files(&note.fields()[idx], renamed, &mut tracker)?;\n            if let Cow::Owned(field) = field {\n                // field was modified, need to save\n                note.set_field(idx, field)?;\n                updated = true;\n            }\n        }\n\n        Ok(updated)\n    }\n\n    /// Convert any filenames that are not in NFC form into NFC,\n    /// and update any files that were renamed on disk.\n    fn normalize_and_maybe_rename_files<'a>(\n        &mut self,\n        field: &'a str,\n        renamed: &HashMap<String, String>,\n        mut tracker: impl FnMut(String),\n    ) -> Result<Cow<'a, str>> {\n        let refs = extract_media_refs(field);\n        let mut field: Cow<str> = field.into();\n\n        for media_ref in refs {\n            if REMOTE_FILENAME.is_match(media_ref.fname) {\n                // skip remote references\n                continue;\n            }\n\n            let mut fname = self.maybe_extract_inline_image(&media_ref.fname_decoded)?;\n\n            // normalize fname into NFC\n            fname = fname.map_cow(normalize_to_nfc);\n            // and look it up to see if it's been renamed\n            if let Some(new_name) = renamed.get(fname.as_ref()) {\n                fname = new_name.to_owned().into();\n            }\n            // if the filename was in NFC and was not renamed as part of the\n            // media check, it may have already been renamed during a previous\n            // sync. If that's the case and the renamed version exists on disk,\n            // we'll need to update the field to match it. It may be possible\n            // to remove this check in the future once we can be sure all media\n            // files stored on AnkiWeb are in normalized form.\n            if matches!(fname, Cow::Borrowed(_)) {\n                if let Cow::Owned(normname) = normalize_nfc_filename(fname.as_ref().into()) {\n                    let path = self.media.media_folder.join(&normname);\n                    if path.exists() {\n                        fname = normname.into();\n                    }\n                }\n            }\n            // update the field if the filename was modified\n            if let Cow::Owned(ref new_name) = fname {\n                field = rename_media_ref_in_field(field.as_ref(), &media_ref, new_name).into();\n            }\n            // and mark this filename as having been referenced\n            tracker(fname.into_owned());\n        }\n\n        Ok(field)\n    }\n\n    fn maybe_extract_inline_image<'a>(&mut self, fname_decoded: &'a str) -> Result<Cow<'a, str>> {\n        static BASE64_IMG: LazyLock<Regex> = LazyLock::new(|| {\n            Regex::new(\"(?i)^data:image/(jpg|jpeg|png|gif|webp|avif);base64,(.+)$\").unwrap()\n        });\n\n        let Some(caps) = BASE64_IMG.captures(fname_decoded) else {\n            return Ok(fname_decoded.into());\n        };\n        let (_all, [ext, data]) = caps.extract();\n        let data = data.trim();\n        let data = match BASE64.decode(data.as_bytes()) {\n            Ok(data) => data,\n            Err(err) => {\n                info!(\"invalid base64: {}\", err);\n                return Ok(fname_decoded.into());\n            }\n        };\n        let checksum = hex::encode(sha1_of_data(&data));\n        let external_fname = format!(\"paste-{checksum}.{ext}\");\n        write_file(self.media.media_folder.join(&external_fname), data)?;\n        self.inlined_image_count += 1;\n        Ok(external_fname.into())\n    }\n}\n\nfn rename_media_ref_in_field(field: &str, media_ref: &MediaRef, new_name: &str) -> String {\n    let new_name = if matches!(media_ref.fname_decoded, Cow::Owned(_)) {\n        // filename had quoted characters like &amp; - need to re-encode\n        htmlescape::encode_minimal(new_name)\n    } else {\n        new_name.into()\n    };\n    let updated_tag = media_ref.full_ref.replace(media_ref.fname, &new_name);\n    field.replace(media_ref.full_ref, &updated_tag)\n}\n\nstruct UnusedAndMissingFiles {\n    unused: Vec<String>,\n    missing: Vec<String>,\n    missing_media_notes: Vec<NoteId>,\n}\n\nimpl UnusedAndMissingFiles {\n    fn new(files: Vec<String>, mut references: HashMap<String, Vec<NoteId>>) -> Self {\n        let mut unused = vec![];\n        for file in files {\n            if !file.starts_with('_') && !references.contains_key(&file) {\n                unused.push(file);\n            } else {\n                references.remove(&file);\n            }\n        }\n\n        let mut missing = Vec::new();\n        let mut notes = HashSet::new();\n        for (fname, nids) in references {\n            missing.push(fname);\n            notes.extend(nids);\n        }\n\n        Self {\n            unused,\n            missing,\n            missing_media_notes: notes.into_iter().collect(),\n        }\n    }\n}\n\nfn extract_latex_refs(note: &Note, mut tracker: impl FnMut(String), svg: bool) {\n    for field in note.fields() {\n        let (_, extracted) = extract_latex_expanding_clozes(field, svg);\n        for e in extracted {\n            tracker(e.fname);\n        }\n    }\n}\n\n#[cfg(test)]\npub(crate) mod test {\n    pub(crate) const MEDIACHECK_ANKI2: &[u8] =\n        include_bytes!(\"../../tests/support/mediacheck.anki2\");\n\n    use std::collections::HashMap;\n    use std::path::Path;\n\n    use anki_io::create_dir;\n    use anki_io::read_to_string;\n    use anki_io::write_file;\n    use anki_io::write_file_and_flush;\n    use tempfile::tempdir;\n    use tempfile::TempDir;\n\n    use super::*;\n    use crate::collection::CollectionBuilder;\n    use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH;\n    use crate::tests::NoteAdder;\n\n    fn common_setup() -> Result<(TempDir, MediaManager, Collection)> {\n        let dir = tempdir()?;\n        let media_folder = dir.path().join(\"media\");\n        create_dir(&media_folder)?;\n        let media_db = dir.path().join(\"media.db\");\n        let col_path = dir.path().join(\"col.anki2\");\n        write_file(&col_path, MEDIACHECK_ANKI2)?;\n\n        let mgr = MediaManager::new(&media_folder, media_db.clone())?;\n        let col = CollectionBuilder::new(col_path)\n            .set_media_paths(media_folder, media_db)\n            .build()?;\n\n        Ok((dir, mgr, col))\n    }\n\n    #[test]\n    fn media_check() -> Result<()> {\n        let (_dir, mgr, mut col) = common_setup()?;\n\n        // add some test files\n        write_file(mgr.media_folder.join(\"zerobytes\"), \"\")?;\n        create_dir(mgr.media_folder.join(\"folder\"))?;\n        write_file(mgr.media_folder.join(\"normal.jpg\"), \"normal\")?;\n        write_file(mgr.media_folder.join(\"foo[.jpg\"), \"foo\")?;\n        write_file(mgr.media_folder.join(\"_under.jpg\"), \"foo\")?;\n        write_file(mgr.media_folder.join(\"unused.jpg\"), \"foo\")?;\n        write_file(mgr.media_folder.join(\".DS_Store\"), \".DS_Store\")?;\n\n        let (output, report) = {\n            let mut checker = col.media_checker()?;\n            let output = checker.check()?;\n            let summary = checker.summarize_output(&mut output.clone());\n            (output, summary)\n        };\n\n        assert_eq!(\n            output,\n            MediaCheckOutput {\n                unused: vec![\"unused.jpg\".into()],\n                missing: vec![\"ぱぱ.jpg\".into()],\n                missing_media_notes: vec![NoteId(1581236461568)],\n                renamed: vec![(\"foo[.jpg\".into(), \"foo.jpg\".into())]\n                    .into_iter()\n                    .collect(),\n                dirs: vec![\"folder\".to_string()],\n                oversize: vec![],\n                trash_count: 0,\n                trash_bytes: 0,\n                inlined_image_count: 0,\n            }\n        );\n\n        assert!(fs::metadata(mgr.media_folder.join(\"foo[.jpg\")).is_err());\n        assert!(fs::metadata(mgr.media_folder.join(\"foo.jpg\")).is_ok());\n\n        assert_eq!(\n            report,\n            \"Missing files: 1\nUnused files: 1\nRenamed files: 1\nSubfolders: 1\n\nSome files have been renamed for compatibility:\nRenamed: foo[.jpg -> foo.jpg\n\nFolders inside the media folder are not supported.\nFolder: folder\n\nThe following files are referenced by cards, but were not found in the media folder:\nMissing: ぱぱ.jpg\n\nThe following files were found in the media folder, but do not appear to be used on any cards:\nUnused: unused.jpg\n\"\n        );\n\n        Ok(())\n    }\n\n    fn files_in_dir(dir: &Path) -> Vec<String> {\n        let mut files = fs::read_dir(dir)\n            .unwrap()\n            .map(|dentry| {\n                let dentry = dentry.unwrap();\n                Ok(dentry.file_name().to_string_lossy().to_string())\n            })\n            .collect::<io::Result<Vec<_>>>()\n            .unwrap();\n        files.sort();\n        files\n    }\n\n    #[test]\n    fn trash_handling() -> Result<()> {\n        let (_dir, mgr, mut col) = common_setup()?;\n        let trash_folder = trash_folder(&mgr.media_folder)?;\n        write_file(trash_folder.join(\"test.jpg\"), \"test\")?;\n\n        let mut checker = col.media_checker()?;\n        checker.restore_trash()?;\n\n        // file should have been moved to media folder\n        assert_eq!(files_in_dir(&trash_folder), Vec::<String>::new());\n        assert_eq!(\n            files_in_dir(&mgr.media_folder),\n            vec![\"test.jpg\".to_string()]\n        );\n\n        // if we repeat the process, restoring should do the same thing if the contents\n        // are equal\n        write_file(trash_folder.join(\"test.jpg\"), \"test\")?;\n\n        let mut checker = col.media_checker()?;\n        checker.restore_trash()?;\n\n        assert_eq!(files_in_dir(&trash_folder), Vec::<String>::new());\n        assert_eq!(\n            files_in_dir(&mgr.media_folder),\n            vec![\"test.jpg\".to_string()]\n        );\n\n        // but rename if required\n        write_file(trash_folder.join(\"test.jpg\"), \"test2\")?;\n\n        let mut checker = col.media_checker()?;\n        checker.restore_trash()?;\n\n        assert_eq!(files_in_dir(&trash_folder), Vec::<String>::new());\n        assert_eq!(\n            files_in_dir(&mgr.media_folder),\n            vec![\n                \"test-109f4b3c50d7b0df729d299bc6f8e9ef9066971f.jpg\".to_string(),\n                \"test.jpg\".into()\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn unicode_normalization() -> Result<()> {\n        let (_dir, mgr, mut col) = common_setup()?;\n\n        write_file_and_flush(mgr.media_folder.join(\"ぱぱ.jpg\"), \"nfd encoding\")?;\n\n        let mut output = {\n            let mut checker = col.media_checker()?;\n            checker.check()\n        }?;\n\n        output.missing.sort();\n\n        if cfg!(target_vendor = \"apple\") {\n            // on a Mac, the file should not have been renamed, but the returned name\n            // should be in NFC format\n            assert_eq!(\n                output,\n                MediaCheckOutput {\n                    unused: vec![],\n                    missing: vec![\"foo[.jpg\".into(), \"normal.jpg\".into()],\n                    missing_media_notes: vec![NoteId(1581236386334)],\n                    renamed: Default::default(),\n                    dirs: vec![],\n                    oversize: vec![],\n                    trash_count: 0,\n                    trash_bytes: 0,\n                    inlined_image_count: 0,\n                }\n            );\n            assert!(fs::metadata(mgr.media_folder.join(\"ぱぱ.jpg\")).is_ok());\n        } else {\n            // on other platforms, the file should have been renamed to NFC\n            assert_eq!(\n                output,\n                MediaCheckOutput {\n                    unused: vec![],\n                    missing: vec![\"foo[.jpg\".into(), \"normal.jpg\".into()],\n                    missing_media_notes: vec![NoteId(1581236386334)],\n                    renamed: vec![(\"ぱぱ.jpg\".into(), \"ぱぱ.jpg\".into())]\n                        .into_iter()\n                        .collect(),\n                    dirs: vec![],\n                    oversize: vec![],\n                    trash_count: 0,\n                    trash_bytes: 0,\n                    inlined_image_count: 0,\n                }\n            );\n            assert!(fs::metadata(mgr.media_folder.join(\"ぱぱ.jpg\")).is_err());\n            assert!(fs::metadata(mgr.media_folder.join(\"ぱぱ.jpg\")).is_ok());\n        }\n\n        Ok(())\n    }\n\n    fn normalize_and_maybe_rename_files_helper(\n        checker: &mut MediaChecker,\n        field: &str,\n    ) -> HashSet<String> {\n        let mut seen = HashSet::new();\n        checker\n            .normalize_and_maybe_rename_files(field, &HashMap::new(), |fname| {\n                seen.insert(fname);\n            })\n            .unwrap();\n        seen\n    }\n\n    #[test]\n    fn html_encoding() -> Result<()> {\n        let (_dir, _mgr, mut col) = common_setup()?;\n        let mut checker = col.media_checker()?;\n\n        let mut field = \"[sound:a &amp; b.mp3]\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"a & b.mp3\"));\n\n        field = r#\"<img src=\"a&b.jpg\">\"#;\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"a&b.jpg\"));\n\n        field = r#\"<img src=\"a&amp;b.jpg\">\"#;\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"a&b.jpg\"));\n        Ok(())\n    }\n\n    #[test]\n    fn inlined_images() -> Result<()> {\n        let (_dir, mgr, mut col) = common_setup()?;\n        NoteAdder::basic(&mut col)\n            // b'foo'\n            .fields(&[\"foo\", \"<img src='data:image/jpg;base64,Zm9v'>\"])\n            .add(&mut col);\n        let mut checker = col.media_checker()?;\n        let output = checker.check()?;\n        assert_eq!(output.inlined_image_count, 1);\n        assert_eq!(\n            &read_to_string(\n                mgr.media_folder\n                    .join(\"paste-0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33.jpg\")\n            )?,\n            \"foo\"\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn html_chevron_in_non_source_attribute() -> Result<()> {\n        let (_dir, _mgr, mut col) = common_setup()?;\n        let mut checker = col.media_checker()?;\n\n        let field = \"<img alt=\\\"alt>\\\" src=\\\"foo.jpg\\\">\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo.jpg\"));\n\n        let field = \"<img alt='>a>l>t>' src='bar.jpg'>\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"bar.jpg\"));\n\n        let field = \"<img alt='\\\"alt>\\\"' src='double-in-single.jpg'>\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"double-in-single.jpg\"));\n\n        let field = \"<img alt='alt'> src='illegal.jpg'>\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(!seen.contains(\"illegal.jpg\"));\n\n        Ok(())\n    }\n    #[test]\n    fn multiple_images() -> Result<()> {\n        let (_dir, _mgr, mut col) = common_setup()?;\n        let mut checker = col.media_checker()?;\n\n        let field = \"<img alt='foo' src='foo-ss.jpg'><img alt='bar' src='bar-ss.jpg'>\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo-ss.jpg\"));\n        assert!(seen.contains(\"bar-ss.jpg\"));\n\n        let field = \"<img alt=\\\"foo\\\" src=\\\"foo-dd.jpg\\\"><img alt=\\\"bar\\\" src=\\\"bar-dd.jpg\\\">\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo-dd.jpg\"));\n        assert!(seen.contains(\"bar-dd.jpg\"));\n\n        let field = \"<img alt='foo' src='foo-sd.jpg'><img alt=\\\"bar\\\" src=\\\"bar-sd.jpg\\\">\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo-sd.jpg\"));\n        assert!(seen.contains(\"bar-sd.jpg\"));\n\n        Ok(())\n    }\n\n    #[test]\n    fn source_tags() -> Result<()> {\n        let (_dir, _mgr, mut col) = common_setup()?;\n        let mut checker = col.media_checker()?;\n\n        let field = \"<audio controls><source src='foo-ss.mp3' /><source type='audio/ogg' src='bar-ss.ogg' /></audio>\";\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo-ss.mp3\"));\n        assert!(seen.contains(\"bar-ss.ogg\"));\n\n        let field = r#\"\n            <picture>\n                <source src=\"foo-dd.webp\" media=\"(orientation: portrait)\" />\n                <img src=\"bar-dd.gif\" alt=\"fancy jif\" />\n            </picture>\n        \"#;\n        let seen = normalize_and_maybe_rename_files_helper(&mut checker, field);\n        assert!(seen.contains(\"foo-dd.webp\"));\n        assert!(seen.contains(\"bar-dd.gif\"));\n\n        Ok(())\n    }\n\n    #[test]\n    fn long_filename_rename_not_reported_as_unused() -> Result<()> {\n        let (_dir, mgr, mut col) = common_setup()?;\n\n        let long_filename = format!(\"{}.mp3\", \"a\".repeat(MAX_MEDIA_FILENAME_LENGTH + 1));\n\n        NoteAdder::basic(&mut col)\n            .fields(&[\"test\", &format!(\"[sound:{}]\", long_filename)])\n            .add(&mut col);\n\n        write_file(mgr.media_folder.join(&long_filename), \"audio data\")?;\n\n        let output = {\n            let mut checker = col.media_checker()?;\n            checker.check()?\n        };\n\n        assert!(output.renamed.contains_key(&long_filename));\n        let new_filename = output.renamed.get(&long_filename).unwrap();\n        assert!(new_filename.len() <= MAX_MEDIA_FILENAME_LENGTH);\n        assert!(!output.unused.contains(new_filename));\n        assert!(!output.missing.contains(new_filename));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/media/files.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::fs;\nuse std::fs::FileTimes;\nuse std::io;\nuse std::io::Read;\nuse std::path::Path;\nuse std::path::PathBuf;\nuse std::sync::LazyLock;\nuse std::time;\n\nuse anki_io::create_dir;\nuse anki_io::open_file;\nuse anki_io::set_file_times;\nuse anki_io::write_file;\nuse anki_io::FileIoError;\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\nuse regex::Regex;\nuse sha1::Digest;\nuse sha1::Sha1;\nuse tracing::debug;\nuse unic_ucd_category::GeneralCategory;\nuse unicode_normalization::is_nfc;\nuse unicode_normalization::UnicodeNormalization;\n\nuse crate::prelude::*;\nuse crate::sync::media::MAX_MEDIA_FILENAME_LENGTH;\n\nstatic WINDOWS_DEVICE_NAME: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?xi)\n            # starting with one of the following names\n            ^\n            (\n                CON | PRN | AUX | NUL | COM[1-9] | LPT[1-9]\n            )\n            # either followed by a dot, or no extension\n            (\n                \\. | $\n            )\n        \",\n    )\n    .unwrap()\n});\nstatic WINDOWS_TRAILING_CHAR: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?x)\n            # filenames can't end with a space or period\n            (\n                \\x20 | \\.\n            )    \n            $\n            \",\n    )\n    .unwrap()\n});\npub(crate) static NONSYNCABLE_FILENAME: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?xi)\n            ^\n            (:?\n                thumbs.db | .ds_store\n            )\n            $\n            \"#,\n    )\n    .unwrap()\n});\n\n/// True if character may cause problems on one or more platforms.\nfn disallowed_char(char: char) -> bool {\n    match char {\n        '[' | ']' | '<' | '>' | ':' | '\"' | '/' | '?' | '*' | '^' | '\\\\' | '|' => true,\n        c if c.is_ascii_control() => true,\n        // Macs do not allow invalid Unicode characters like 05F8 to be in a filename.\n        c if GeneralCategory::of(c) == GeneralCategory::Unassigned => true,\n        _ => false,\n    }\n}\n\nfn nonbreaking_space(char: char) -> bool {\n    char == '\\u{a0}'\n}\n\n/// Adjust filename into the format Anki expects.\n///\n/// - The filename is normalized to NFC.\n/// - Any problem characters are removed.\n/// - Windows device names like CON and PRN have '_' appended\n/// - The filename is limited to 120 bytes.\npub(crate) fn normalize_filename(fname: &str) -> Cow<'_, str> {\n    let mut output = Cow::Borrowed(fname);\n\n    if !is_nfc(output.as_ref()) {\n        output = output.chars().nfc().collect::<String>().into();\n    }\n\n    normalize_nfc_filename(output)\n}\n\n/// See normalize_filename(). This function expects NFC-normalized input.\npub(crate) fn normalize_nfc_filename(mut fname: Cow<'_, str>) -> Cow<'_, str> {\n    if fname.contains(disallowed_char) {\n        fname = fname.replace(disallowed_char, \"\").into()\n    }\n\n    // convert nonbreaking spaces to regular ones, as the filename extraction\n    // code treats nonbreaking spaces as regular ones\n    if fname.contains(nonbreaking_space) {\n        fname = fname.replace(nonbreaking_space, \" \").into()\n    }\n\n    if let Cow::Owned(o) = WINDOWS_DEVICE_NAME.replace_all(fname.as_ref(), \"${1}_${2}\") {\n        fname = o.into();\n    }\n\n    if WINDOWS_TRAILING_CHAR.is_match(fname.as_ref()) {\n        fname = format!(\"{}_\", fname.as_ref()).into();\n    }\n\n    if let Cow::Owned(o) = truncate_filename(fname.as_ref(), MAX_MEDIA_FILENAME_LENGTH) {\n        fname = o.into();\n    }\n\n    fname\n}\n\n/// Return the filename in NFC form if the filename is valid.\n///\n/// Returns None if the filename is not normalized\n/// (NFD, invalid chars, etc)\n///\n/// On Apple devices, the filename may be stored on disk in NFD encoding,\n/// but can be accessed as NFC. On these devices, if the filename\n/// is otherwise valid, the filename is returned as NFC.\n#[allow(clippy::collapsible_else_if)]\npub(crate) fn filename_if_normalized(fname: &str) -> Option<Cow<'_, str>> {\n    if cfg!(target_vendor = \"apple\") {\n        if !is_nfc(fname) {\n            let as_nfc = fname.chars().nfc().collect::<String>();\n            if let Cow::Borrowed(_) = normalize_nfc_filename(as_nfc.as_str().into()) {\n                Some(as_nfc.into())\n            } else {\n                None\n            }\n        } else {\n            if let Cow::Borrowed(_) = normalize_nfc_filename(fname.into()) {\n                Some(fname.into())\n            } else {\n                None\n            }\n        }\n    } else {\n        if let Cow::Borrowed(_) = normalize_filename(fname) {\n            Some(fname.into())\n        } else {\n            None\n        }\n    }\n}\n\n/// Write desired_name into folder, renaming if existing file has different\n/// content. Returns the used filename.\npub fn add_data_to_folder_uniquely<'a, P>(\n    folder: P,\n    desired_name: &'a str,\n    data: &[u8],\n    sha1: Sha1Hash,\n) -> Result<Cow<'a, str>, FileIoError>\nwhere\n    P: AsRef<Path>,\n{\n    // force lowercase to account for case-insensitive filesystems\n    // but not within normalize_filename, for existing media refs\n    let normalized_name: Cow<_> = normalize_filename(desired_name).to_lowercase().into();\n\n    let mut target_path = folder.as_ref().join(normalized_name.as_ref());\n\n    let existing_file_hash = existing_file_sha1(&target_path)?;\n    if existing_file_hash.is_none() {\n        // no file with that name exists yet\n        write_file(&target_path, data)?;\n        return Ok(normalized_name);\n    }\n\n    if existing_file_hash.unwrap() == sha1 {\n        // existing file has same checksum, nothing to do\n        return Ok(normalized_name);\n    }\n\n    // give it a unique name based on its hash\n    let hashed_name = add_hash_suffix_to_file_stem(normalized_name.as_ref(), &sha1);\n    target_path.set_file_name(&hashed_name);\n\n    write_file(&target_path, data)?;\n    Ok(hashed_name.into())\n}\n\n/// Convert foo.jpg into foo-abcde12345679.jpg\npub(crate) fn add_hash_suffix_to_file_stem(fname: &str, hash: &Sha1Hash) -> String {\n    // when appending a hash to make unique, it will be 40 bytes plus the hyphen.\n    let max_len = MAX_MEDIA_FILENAME_LENGTH - 40 - 1;\n\n    let (stem, ext) = split_and_truncate_filename(fname, max_len);\n\n    format!(\"{}-{}.{}\", stem, hex::encode(hash), ext)\n}\n\n/// If filename is longer than max_bytes, truncate it.\nfn truncate_filename(fname: &str, max_bytes: usize) -> Cow<'_, str> {\n    if fname.len() <= max_bytes {\n        return Cow::Borrowed(fname);\n    }\n\n    let (stem, ext) = split_and_truncate_filename(fname, max_bytes);\n\n    let mut new_name = if ext.is_empty() {\n        stem.to_string()\n    } else {\n        format!(\"{stem}.{ext}\")\n    };\n\n    // make sure we don't break Windows by ending with a space or dot\n    if WINDOWS_TRAILING_CHAR.is_match(&new_name) {\n        new_name.push('_');\n    }\n\n    new_name.into()\n}\n\n/// Split filename into stem and extension, and trim both so the\n/// resulting filename would be under max_bytes.\n/// Returns (stem, extension)\nfn split_and_truncate_filename(fname: &str, max_bytes: usize) -> (&str, &str) {\n    // the code assumes max_bytes will be at least 11\n    debug_assert!(max_bytes > 10);\n\n    let mut iter = fname.rsplitn(2, '.');\n    let mut ext = iter.next().unwrap();\n    let mut stem = if let Some(s) = iter.next() {\n        s\n    } else {\n        // no extension, so ext holds the full filename\n        let ext_tmp = ext;\n        ext = \"\";\n        ext_tmp\n    };\n\n    // cap extension to 10 bytes so stem_len can't be negative\n    ext = truncated_to_char_boundary(ext, 10);\n\n    // cap stem, allowing for the . and a trailing _\n    let stem_len = max_bytes - ext.len() - 2;\n    stem = truncated_to_char_boundary(stem, stem_len);\n\n    (stem, ext)\n}\n\n/// Return a substring on a valid UTF8 boundary.\n/// Based on a function in the Rust stdlib.\nfn truncated_to_char_boundary(s: &str, mut max: usize) -> &str {\n    if max >= s.len() {\n        s\n    } else {\n        while !s.is_char_boundary(max) {\n            max -= 1;\n        }\n        &s[..max]\n    }\n}\n\n/// Return the SHA1 of a file if it exists, or None.\nfn existing_file_sha1(path: &Path) -> Result<Option<Sha1Hash>, FileIoError> {\n    match sha1_of_file(path) {\n        Ok(o) => Ok(Some(o)),\n        Err(e) if e.is_not_found() => Ok(None),\n        Err(e) => Err(e),\n    }\n}\n\n/// Return the SHA1 of a file, failing if it doesn't exist.\npub(crate) fn sha1_of_file(path: &Path) -> Result<Sha1Hash, FileIoError> {\n    let mut file = open_file(path)?;\n    sha1_of_reader(&mut file).context(FileIoSnafu {\n        path,\n        op: FileOp::Read,\n    })\n}\n\n/// Return the SHA1 of a stream.\npub(crate) fn sha1_of_reader(reader: &mut impl Read) -> io::Result<Sha1Hash> {\n    let mut hasher = Sha1::new();\n    let mut buf = [0; 64 * 1024];\n    loop {\n        match reader.read(&mut buf) {\n            Ok(0) => break,\n            Ok(n) => hasher.update(&buf[0..n]),\n            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,\n            Err(e) => return Err(e),\n        };\n    }\n    Ok(hasher.finalize().into())\n}\n\n/// Return the SHA1 of provided data.\npub(crate) fn sha1_of_data(data: &[u8]) -> Sha1Hash {\n    let mut hasher = Sha1::new();\n    hasher.update(data);\n    hasher.finalize().into()\n}\n\npub(crate) fn mtime_as_i64<P: AsRef<Path>>(path: P) -> io::Result<i64> {\n    Ok(path\n        .as_ref()\n        .metadata()?\n        .modified()?\n        .duration_since(time::UNIX_EPOCH)\n        .unwrap()\n        .as_millis() as i64)\n}\n\npub fn remove_files<S>(media_folder: &Path, files: &[S]) -> Result<()>\nwhere\n    S: AsRef<str> + std::fmt::Debug,\n{\n    if files.is_empty() {\n        return Ok(());\n    }\n\n    let trash_folder = trash_folder(media_folder)?;\n\n    for file in files {\n        let src_path = media_folder.join(file.as_ref());\n        let dst_path = trash_folder.join(file.as_ref());\n\n        // if the file doesn't exist, nothing to do\n        if let Err(e) = fs::metadata(&src_path) {\n            if e.kind() == io::ErrorKind::NotFound {\n                return Ok(());\n            } else {\n                return Err(e.into());\n            }\n        }\n\n        // move file to trash, clobbering any existing file with the same name\n        fs::rename(&src_path, &dst_path)?;\n\n        // mark it as modified, so we can expire it in the future\n        let secs = time::SystemTime::now();\n        let times = FileTimes::new().set_accessed(secs).set_modified(secs);\n        if let Err(err) = set_file_times(&dst_path, times) {\n            // The libc utimes() call fails on (some? all?) Android devices. Since we don't\n            // do automatic expiry yet, we can safely ignore the error.\n            if !cfg!(target_os = \"android\") {\n                return Err(err.into());\n            }\n        }\n    }\n\n    Ok(())\n}\n\npub(super) fn trash_folder(media_folder: &Path) -> Result<PathBuf> {\n    let trash_folder = media_folder.with_file_name(\"media.trash\");\n    match create_dir(&trash_folder) {\n        Ok(()) => Ok(trash_folder),\n        Err(e) => {\n            if e.source.kind() == io::ErrorKind::AlreadyExists {\n                Ok(trash_folder)\n            } else {\n                Err(e.into())\n            }\n        }\n    }\n}\n\npub struct AddedFile {\n    pub fname: String,\n    pub sha1: Sha1Hash,\n    pub mtime: i64,\n    pub renamed_from: Option<String>,\n}\n\n/// Add a file received from AnkiWeb into the media folder.\n///\n/// Because AnkiWeb did not previously enforce file name limits and invalid\n/// characters, we'll need to rename the file if it is not valid.\npub(crate) fn add_file_from_ankiweb(\n    media_folder: &Path,\n    fname: &str,\n    data: &[u8],\n) -> Result<AddedFile> {\n    let sha1 = sha1_of_data(data);\n    let normalized = normalize_filename(fname);\n\n    // if the filename is already valid, we can write the file directly\n    let (renamed_from, path) = if let Cow::Borrowed(_) = normalized {\n        let path = media_folder.join(normalized.as_ref());\n        debug!(fname = normalized.as_ref(), \"write\");\n        write_file(&path, data)?;\n        (None, path)\n    } else {\n        // ankiweb sent us a non-normalized filename, so we'll rename it\n        let new_name = add_data_to_folder_uniquely(media_folder, fname, data, sha1)?;\n        debug!(\n            fname,\n            rename_to = new_name.as_ref(),\n            \"non-normalized filename received\"\n        );\n        (\n            Some(fname.to_string()),\n            media_folder.join(new_name.as_ref()),\n        )\n    };\n\n    let mtime = mtime_as_i64(path)?;\n\n    Ok(AddedFile {\n        fname: normalized.to_string(),\n        sha1,\n        mtime,\n        renamed_from,\n    })\n}\n\npub(crate) fn data_for_file(media_folder: &Path, fname: &str) -> Result<Option<Vec<u8>>> {\n    let mut file = match open_file(media_folder.join(fname)) {\n        Err(e) if e.is_not_found() => return Ok(None),\n        res => res?,\n    };\n    let mut buf = vec![];\n    file.read_to_end(&mut buf)?;\n    Ok(Some(buf))\n}\n\n#[cfg(test)]\nmod test {\n    use std::borrow::Cow;\n\n    use tempfile::tempdir;\n\n    use crate::media::files::add_data_to_folder_uniquely;\n    use crate::media::files::add_hash_suffix_to_file_stem;\n    use crate::media::files::normalize_filename;\n    use crate::media::files::remove_files;\n    use crate::media::files::sha1_of_data;\n    use crate::media::files::truncate_filename;\n    use crate::sync::media::MAX_MEDIA_FILENAME_LENGTH;\n\n    #[test]\n    fn normalize() {\n        assert_eq!(normalize_filename(\"foo.jpg\"), Cow::Borrowed(\"foo.jpg\"));\n        assert_eq!(\n            normalize_filename(\"con.jpg[]><:\\\"/?*^\\\\|\\0\\r\\n\").as_ref(),\n            \"con_.jpg\"\n        );\n\n        assert_eq!(normalize_filename(\"test.\").as_ref(), \"test._\");\n        assert_eq!(normalize_filename(\"test \").as_ref(), \"test _\");\n\n        let expected_stem_len = MAX_MEDIA_FILENAME_LENGTH - \".jpg\".len() - 1;\n        assert_eq!(\n            normalize_filename(&format!(\n                \"{}.jpg\",\n                \"x\".repeat(MAX_MEDIA_FILENAME_LENGTH * 2)\n            )),\n            \"x\".repeat(expected_stem_len) + \".jpg\"\n        );\n    }\n\n    #[test]\n    fn add_hash_suffix() {\n        let hash = sha1_of_data(b\"hello\");\n        assert_eq!(\n            add_hash_suffix_to_file_stem(\"test.jpg\", &hash).as_str(),\n            \"test-aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d.jpg\"\n        );\n    }\n\n    #[test]\n    fn adding_removing() {\n        let dir = tempdir().unwrap();\n        let dpath = dir.path();\n\n        // no existing file case\n        let h1 = sha1_of_data(b\"hello\");\n        assert_eq!(\n            add_data_to_folder_uniquely(dpath, \"test.mp3\", b\"hello\", h1).unwrap(),\n            \"test.mp3\"\n        );\n\n        // same contents case\n        assert_eq!(\n            add_data_to_folder_uniquely(dpath, \"test.mp3\", b\"hello\", h1).unwrap(),\n            \"test.mp3\"\n        );\n\n        // different contents, filenames differ only by case\n        let h2 = sha1_of_data(b\"hello1\");\n        assert_eq!(\n            add_data_to_folder_uniquely(dpath, \"Test.mp3\", b\"hello1\", h2).unwrap(),\n            \"test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3\"\n        );\n\n        // same contents, filenames differ only by case\n        assert_eq!(\n            add_data_to_folder_uniquely(dpath, \"test.mp3\", b\"hello1\", h2).unwrap(),\n            \"test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3\"\n        );\n\n        let mut written_files = std::fs::read_dir(dpath)\n            .unwrap()\n            .map(|d| d.unwrap().file_name().to_string_lossy().into_owned())\n            .collect::<Vec<_>>();\n        written_files.sort();\n        assert_eq!(\n            written_files,\n            vec![\n                \"test-88fdd585121a4ccb3d1540527aee53a77c77abb8.mp3\",\n                \"test.mp3\",\n            ]\n        );\n\n        // remove\n        remove_files(dpath, written_files.as_slice()).unwrap();\n    }\n\n    #[test]\n    fn truncation() {\n        let one_less = \"x\".repeat(MAX_MEDIA_FILENAME_LENGTH - 1);\n        assert_eq!(\n            truncate_filename(&one_less, MAX_MEDIA_FILENAME_LENGTH),\n            Cow::Borrowed(&one_less)\n        );\n        let equal = \"x\".repeat(MAX_MEDIA_FILENAME_LENGTH);\n        assert_eq!(\n            truncate_filename(&equal, MAX_MEDIA_FILENAME_LENGTH),\n            Cow::Borrowed(&equal)\n        );\n        let equal = format!(\"{}.jpg\", \"x\".repeat(MAX_MEDIA_FILENAME_LENGTH - 4));\n        assert_eq!(\n            truncate_filename(&equal, MAX_MEDIA_FILENAME_LENGTH),\n            Cow::Borrowed(&equal)\n        );\n        let one_more = \"x\".repeat(MAX_MEDIA_FILENAME_LENGTH + 1);\n        assert_eq!(\n            truncate_filename(&one_more, MAX_MEDIA_FILENAME_LENGTH),\n            Cow::<str>::Owned(\"x\".repeat(MAX_MEDIA_FILENAME_LENGTH - 2))\n        );\n        assert_eq!(\n            truncate_filename(\n                &\" \".repeat(MAX_MEDIA_FILENAME_LENGTH + 1),\n                MAX_MEDIA_FILENAME_LENGTH\n            ),\n            Cow::<str>::Owned(format!(\"{}_\", \" \".repeat(MAX_MEDIA_FILENAME_LENGTH - 2)))\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/media/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod check;\npub mod files;\nmod service;\n\nuse std::borrow::Cow;\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\nuse reqwest::Client;\n\nuse crate::media::files::add_data_to_folder_uniquely;\nuse crate::media::files::mtime_as_i64;\nuse crate::media::files::remove_files;\nuse crate::media::files::sha1_of_data;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::SyncAuth;\nuse crate::sync::media::database::client::changetracker::ChangeTracker;\npub use crate::sync::media::database::client::Checksums;\nuse crate::sync::media::database::client::MediaDatabase;\nuse crate::sync::media::database::client::MediaEntry;\nuse crate::sync::media::progress::MediaSyncProgress;\nuse crate::sync::media::syncer::MediaSyncer;\n\npub type Sha1Hash = [u8; 20];\n\nimpl Collection {\n    pub fn media(&self) -> Result<MediaManager> {\n        MediaManager::new(&self.media_folder, &self.media_db)\n    }\n}\n\npub struct MediaManager {\n    pub(crate) db: MediaDatabase,\n    pub(crate) media_folder: PathBuf,\n}\n\nimpl MediaManager {\n    pub fn new<P, P2>(media_folder: P, media_db: P2) -> Result<Self>\n    where\n        P: Into<PathBuf>,\n        P2: AsRef<Path>,\n    {\n        let media_folder = media_folder.into();\n        if media_folder.as_os_str().is_empty() {\n            invalid_input!(\"attempted media operation without media folder set\");\n        }\n        create_dir_all(&media_folder)?;\n        Ok(MediaManager {\n            db: MediaDatabase::new(media_db.as_ref())?,\n            media_folder,\n        })\n    }\n\n    /// Add a file to the media folder.\n    ///\n    /// If a file with differing contents already exists, a hash will be\n    /// appended to the name.\n    ///\n    /// Also notes the file in the media database.\n    pub fn add_file<'a>(&self, desired_name: &'a str, data: &[u8]) -> Result<Cow<'a, str>> {\n        let data_hash = sha1_of_data(data);\n\n        self.transact(|db| {\n            let chosen_fname =\n                add_data_to_folder_uniquely(&self.media_folder, desired_name, data, data_hash)?;\n            let file_mtime = mtime_as_i64(self.media_folder.join(chosen_fname.as_ref()))?;\n\n            let existing_entry = db.get_entry(&chosen_fname)?;\n            let new_sha1 = Some(data_hash);\n\n            let entry_update_required = existing_entry.map(|e| e.sha1 != new_sha1).unwrap_or(true);\n\n            if entry_update_required {\n                db.set_entry(&MediaEntry {\n                    fname: chosen_fname.to_string(),\n                    sha1: new_sha1,\n                    mtime: file_mtime,\n                    sync_required: true,\n                })?;\n            }\n\n            Ok(chosen_fname)\n        })\n    }\n\n    pub fn remove_files<S>(&self, filenames: &[S]) -> Result<()>\n    where\n        S: AsRef<str> + std::fmt::Debug,\n    {\n        self.transact(|db| {\n            remove_files(&self.media_folder, filenames)?;\n            for fname in filenames {\n                if let Some(mut entry) = db.get_entry(fname.as_ref())? {\n                    entry.sha1 = None;\n                    entry.mtime = 0;\n                    entry.sync_required = true;\n                    db.set_entry(&entry)?;\n                }\n            }\n            Ok(())\n        })\n    }\n\n    /// Opens a transaction and manages folder mtime, so user should perform not\n    /// only db ops, but also all file ops inside the closure.\n    pub(crate) fn transact<T>(&self, func: impl FnOnce(&MediaDatabase) -> Result<T>) -> Result<T> {\n        let start_folder_mtime = mtime_as_i64(&self.media_folder)?;\n        self.db.transact(|db| {\n            let out = func(db)?;\n\n            let mut meta = db.get_meta()?;\n            if meta.folder_mtime == start_folder_mtime {\n                // if media db was in sync with folder prior to this add,\n                // we can keep it in sync\n                meta.folder_mtime = mtime_as_i64(&self.media_folder)?;\n                db.set_meta(&meta)?;\n            } else {\n                // otherwise, leave it alone so that other pending changes\n                // get picked up later\n            }\n\n            Ok(out)\n        })\n    }\n\n    /// Set entry for a newly added file. Caller must ensure transaction.\n    pub(crate) fn add_entry(&self, fname: impl Into<String>, sha1: [u8; 20]) -> Result<()> {\n        let fname = fname.into();\n        let mtime = mtime_as_i64(self.media_folder.join(&fname))?;\n        self.db.set_entry(&MediaEntry {\n            fname,\n            mtime,\n            sha1: Some(sha1),\n            sync_required: true,\n        })\n    }\n\n    /// Sync media.\n    pub async fn sync_media(\n        self,\n        progress: ThrottlingProgressHandler<MediaSyncProgress>,\n        auth: SyncAuth,\n        client: Client,\n        server_usn: Option<Usn>,\n    ) -> Result<()> {\n        let client = HttpSyncClient::new(auth, client);\n        let mut syncer = MediaSyncer::new(self, progress, client)?;\n        syncer.sync(server_usn).await\n    }\n\n    pub fn all_checksums_after_checking(\n        &self,\n        progress: impl FnMut(usize) -> bool,\n    ) -> Result<Checksums> {\n        ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)?;\n        self.db.all_registered_checksums()\n    }\n\n    pub fn checksum_getter(&self) -> impl FnMut(&str) -> Result<Option<Sha1Hash>> + '_ {\n        |fname: &str| {\n            self.db\n                .get_entry(fname)\n                .map(|opt| opt.and_then(|entry| entry.sha1))\n        }\n    }\n\n    pub fn register_changes(&self, progress: &mut impl FnMut(usize) -> bool) -> Result<()> {\n        ChangeTracker::new(&self.media_folder, progress).register_changes(&self.db)\n    }\n\n    /// All checksums without registering changes first.\n    #[cfg(test)]\n    pub(crate) fn all_checksums_as_is(&self) -> Checksums {\n        self.db.all_registered_checksums().unwrap()\n    }\n}\n"
  },
  {
    "path": "rslib/src/media/service.rs",
    "content": "use std::collections::HashSet;\n\n// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::generic;\nuse anki_proto::media::AddMediaFileRequest;\nuse anki_proto::media::CheckMediaResponse;\nuse anki_proto::media::TrashMediaFilesRequest;\n\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::error::OrNotFound;\nuse crate::notes::service::to_i64s;\nuse crate::notetype::NotetypeId;\n\nimpl crate::services::MediaService for Collection {\n    fn check_media(&mut self) -> error::Result<CheckMediaResponse> {\n        self.transact_no_undo(|col| {\n            let mut checker = col.media_checker()?;\n            let mut output = checker.check()?;\n\n            let mut report = checker.summarize_output(&mut output);\n            col.report_media_field_referencing_templates(&mut report)?;\n\n            Ok(CheckMediaResponse {\n                unused: output.unused,\n                missing: output.missing,\n                missing_media_notes: to_i64s(output.missing_media_notes),\n                report,\n                have_trash: output.trash_count > 0,\n            })\n        })\n    }\n\n    fn add_media_file(&mut self, input: AddMediaFileRequest) -> error::Result<generic::String> {\n        Ok(self\n            .media()?\n            .add_file(&input.desired_name, &input.data)?\n            .to_string()\n            .into())\n    }\n\n    fn trash_media_files(&mut self, input: TrashMediaFilesRequest) -> error::Result<()> {\n        self.media()?.remove_files(&input.fnames)\n    }\n\n    fn empty_trash(&mut self) -> error::Result<()> {\n        self.media_checker()?.empty_trash()\n    }\n\n    fn restore_trash(&mut self) -> error::Result<()> {\n        self.media_checker()?.restore_trash()\n    }\n\n    fn extract_static_media_files(\n        &mut self,\n        ntid: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<generic::StringList> {\n        let ntid = NotetypeId::from(ntid);\n        let notetype = self.storage.get_notetype(ntid)?.or_not_found(ntid)?;\n        let mut files: HashSet<String> = HashSet::new();\n        let mut inserter = |name: String| {\n            files.insert(name);\n        };\n        notetype.gather_media_names(&mut inserter);\n\n        Ok(files.into_iter().collect::<Vec<_>>().into())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notes/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod service;\npub(crate) mod undo;\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse anki_proto::notes::note_fields_check_response::State as NoteFieldsState;\nuse itertools::Itertools;\nuse sha1::Digest;\nuse sha1::Sha1;\n\nuse crate::cloze::contains_cloze;\nuse crate::define_newtype;\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::OrInvalid;\nuse crate::notetype::CardGenContext;\nuse crate::notetype::NoteField;\nuse crate::ops::StateChanges;\nuse crate::prelude::*;\nuse crate::template::field_is_empty;\nuse crate::text::ensure_string_in_nfc;\nuse crate::text::normalize_to_nfc;\nuse crate::text::strip_html_preserving_media_filenames;\n\ndefine_newtype!(NoteId, i64);\n\n#[derive(Default)]\npub(crate) struct TransformNoteOutput {\n    pub changed: bool,\n    pub generate_cards: bool,\n    pub mark_modified: bool,\n    pub update_tags: bool,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub struct Note {\n    pub id: NoteId,\n    pub guid: String,\n    pub notetype_id: NotetypeId,\n    pub mtime: TimestampSecs,\n    pub usn: Usn,\n    pub tags: Vec<String>,\n    fields: Vec<String>,\n    pub(crate) sort_field: Option<String>,\n    pub(crate) checksum: Option<u32>,\n}\n\nimpl Note {\n    pub fn fields(&self) -> &Vec<String> {\n        &self.fields\n    }\n\n    pub fn into_fields(self) -> Vec<String> {\n        self.fields\n    }\n\n    pub fn set_field(&mut self, idx: usize, text: impl Into<String>) -> Result<()> {\n        require!(idx < self.fields.len(), \"field idx out of range\");\n\n        self.fields[idx] = text.into();\n        self.mark_dirty();\n\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct AddNoteRequest {\n    pub note: Note,\n    pub deck_id: DeckId,\n}\n\nimpl TryFrom<anki_proto::notes::AddNoteRequest> for AddNoteRequest {\n    type Error = AnkiError;\n\n    fn try_from(request: anki_proto::notes::AddNoteRequest) -> error::Result<Self, Self::Error> {\n        Ok(Self {\n            note: request.note.or_invalid(\"no note provided\")?.into(),\n            deck_id: DeckId(request.deck_id),\n        })\n    }\n}\n\nimpl Collection {\n    pub fn add_note(&mut self, note: &mut Note, did: DeckId) -> Result<OpOutput<usize>> {\n        self.transact(Op::AddNote, |col| col.add_note_inner(note, did))\n    }\n\n    pub fn add_notes(&mut self, requests: &mut [AddNoteRequest]) -> Result<OpOutput<()>> {\n        self.transact(Op::AddNote, |col| {\n            for request in requests {\n                col.add_note_inner(&mut request.note, request.deck_id)?;\n            }\n\n            Ok(())\n        })\n    }\n\n    /// Remove provided notes, and any cards that use them.\n    pub fn remove_notes(&mut self, nids: &[NoteId]) -> Result<OpOutput<usize>> {\n        let usn = self.usn()?;\n        self.transact(Op::RemoveNote, |col| col.remove_notes_inner(nids, usn))\n    }\n\n    /// Update cards and field cache after notes modified externally.\n    /// If gencards is false, skip card generation.\n    pub fn after_note_updates(\n        &mut self,\n        nids: &[NoteId],\n        generate_cards: bool,\n        mark_notes_modified: bool,\n    ) -> Result<OpOutput<usize>> {\n        self.transact(Op::UpdateNote, |col| {\n            col.after_note_updates_inner(nids, generate_cards, mark_notes_modified)\n        })\n    }\n}\n\n/// Information required for updating tags while leaving note content alone.\n/// Tags are stored in their DB form, separated by spaces.\n#[derive(Debug, PartialEq, Eq, Clone)]\npub(crate) struct NoteTags {\n    pub id: NoteId,\n    pub mtime: TimestampSecs,\n    pub usn: Usn,\n    pub tags: String,\n}\n\nimpl NoteTags {\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.mtime = TimestampSecs::now();\n        self.usn = usn;\n    }\n}\n\nimpl Note {\n    pub fn new(notetype: &Notetype) -> Self {\n        Note {\n            id: NoteId(0),\n            guid: base91_u64(),\n            notetype_id: notetype.id,\n            mtime: TimestampSecs(0),\n            usn: Usn(0),\n            tags: vec![],\n            fields: vec![\"\".to_string(); notetype.fields.len()],\n            sort_field: None,\n            checksum: None,\n        }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub(crate) fn new_from_storage(\n        id: NoteId,\n        guid: String,\n        notetype_id: NotetypeId,\n        mtime: TimestampSecs,\n        usn: Usn,\n        tags: Vec<String>,\n        fields: Vec<String>,\n        sort_field: Option<String>,\n        checksum: Option<u32>,\n    ) -> Self {\n        Self {\n            id,\n            guid,\n            notetype_id,\n            mtime,\n            usn,\n            tags,\n            fields,\n            sort_field,\n            checksum,\n        }\n    }\n\n    pub fn fields_mut(&mut self) -> &mut Vec<String> {\n        self.mark_dirty();\n        &mut self.fields\n    }\n\n    // Ensure we get an error if caller forgets to call prepare_for_update().\n    fn mark_dirty(&mut self) {\n        self.sort_field = None;\n        self.checksum = None;\n    }\n\n    /// Prepare note for saving to the database. Does not mark it as modified.\n    pub(crate) fn prepare_for_update(&mut self, nt: &Notetype, normalize_text: bool) -> Result<()> {\n        assert_eq!(nt.id, self.notetype_id);\n        let notetype_field_count = nt.fields.len().max(1);\n        require!(\n            notetype_field_count == self.fields.len(),\n            \"note has {} fields, expected {notetype_field_count}\",\n            self.fields.len()\n        );\n\n        for field in self.fields_mut() {\n            normalize_field(field, normalize_text);\n        }\n\n        let field1_nohtml = strip_html_preserving_media_filenames(&self.fields()[0]);\n        let checksum = field_checksum(field1_nohtml.as_ref());\n        let sort_field = if nt.config.sort_field_idx == 0 {\n            field1_nohtml\n        } else {\n            strip_html_preserving_media_filenames(\n                self.fields\n                    .get(nt.config.sort_field_idx as usize)\n                    .map(AsRef::as_ref)\n                    .unwrap_or(\"\"),\n            )\n        };\n        self.sort_field = Some(sort_field.into());\n        self.checksum = Some(checksum);\n        Ok(())\n    }\n\n    #[inline]\n    pub(crate) fn set_modified_with_mtime(&mut self, usn: Usn, mtime: TimestampSecs) {\n        self.mtime = mtime;\n        self.usn = usn;\n    }\n\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.set_modified_with_mtime(usn, TimestampSecs::now())\n    }\n\n    pub(crate) fn nonempty_fields<'a>(&self, fields: &'a [NoteField]) -> HashSet<&'a str> {\n        self.fields\n            .iter()\n            .enumerate()\n            .filter_map(|(ord, s)| {\n                if field_is_empty(s) {\n                    None\n                } else {\n                    fields.get(ord).map(|f| f.name.as_str())\n                }\n            })\n            .collect()\n    }\n\n    pub(crate) fn fields_map<'a>(\n        &'a self,\n        fields: &'a [NoteField],\n    ) -> HashMap<&'a str, Cow<'a, str>> {\n        self.fields\n            .iter()\n            .enumerate()\n            .map(|(ord, field_content)| {\n                (\n                    fields.get(ord).map(|f| f.name.as_str()).unwrap_or(\"\"),\n                    field_content.as_str().into(),\n                )\n            })\n            .collect()\n    }\n\n    /// Pad or merge fields to match note type.\n    pub(crate) fn fix_field_count(&mut self, nt: &Notetype) {\n        while self.fields.len() < nt.fields.len() {\n            self.fields.push(\"\".into())\n        }\n        while self.fields.len() > nt.fields.len() && self.fields.len() > 1 {\n            let last = self.fields.pop().unwrap();\n            self.fields\n                .last_mut()\n                .unwrap()\n                .push_str(&format!(\"; {last}\"));\n        }\n    }\n}\n\n/// Remove invalid characters and optionally ensure nfc normalization.\npub(crate) fn normalize_field(field: &mut String, normalize_text: bool) {\n    if field.contains(invalid_char_for_field) {\n        *field = field.replace(invalid_char_for_field, \"\");\n    }\n    if normalize_text {\n        ensure_string_in_nfc(field);\n    }\n}\n\nimpl From<Note> for anki_proto::notes::Note {\n    fn from(n: Note) -> Self {\n        anki_proto::notes::Note {\n            id: n.id.0,\n            guid: n.guid,\n            notetype_id: n.notetype_id.0,\n            mtime_secs: n.mtime.0 as u32,\n            usn: n.usn.0,\n            tags: n.tags,\n            fields: n.fields,\n        }\n    }\n}\n\nimpl From<anki_proto::notes::Note> for Note {\n    fn from(n: anki_proto::notes::Note) -> Self {\n        Note {\n            id: NoteId(n.id),\n            guid: n.guid,\n            notetype_id: NotetypeId(n.notetype_id),\n            mtime: TimestampSecs(n.mtime_secs as i64),\n            usn: Usn(n.usn),\n            tags: n.tags,\n            fields: n.fields,\n            sort_field: None,\n            checksum: None,\n        }\n    }\n}\n\n/// Text must be passed to strip_html_preserving_media_filenames() by\n/// caller prior to passing in here.\npub(crate) fn field_checksum(text: &str) -> u32 {\n    let mut hash = Sha1::new();\n    hash.update(text);\n    let digest = hash.finalize();\n    u32::from_be_bytes(digest[..4].try_into().unwrap())\n}\n\npub(crate) fn base91_u64() -> String {\n    anki_base91(rand::random())\n}\n\nfn anki_base91(n: u64) -> String {\n    to_base_n(\n        n,\n        b\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\\\n0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~\",\n    )\n}\n\npub fn to_base_n(mut n: u64, table: &[u8]) -> String {\n    let mut buf = String::new();\n    while n > 0 {\n        let tablelen = table.len() as u64;\n        let (q, r) = (n / tablelen, n % tablelen);\n        buf.push(table[r as usize] as char);\n        n = q;\n    }\n    buf.chars().rev().collect()\n}\n\nfn invalid_char_for_field(c: char) -> bool {\n    c.is_ascii_control() && c != '\\n' && c != '\\t'\n}\n\n/// Used when calling [Collection::update_note_inner_without_cards] and\n/// [Collection::update_note_inner_without_cards_using_mtime]\npub(crate) struct UpdateNoteInnerWithoutCardsArgs<'a> {\n    pub(crate) note: &'a mut Note,\n    pub(crate) original: &'a Note,\n    pub(crate) notetype: &'a Notetype,\n    pub(crate) usn: Usn,\n    pub(crate) mark_note_modified: bool,\n    pub(crate) normalize_text: bool,\n    pub(crate) update_tags: bool,\n}\n\nimpl Collection {\n    pub(crate) fn canonify_note_tags(&mut self, note: &mut Note, usn: Usn) -> Result<()> {\n        if !note.tags.is_empty() {\n            let tags = std::mem::take(&mut note.tags);\n            note.tags = self.canonify_tags(tags, usn)?.0;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn add_note_inner(&mut self, note: &mut Note, did: DeckId) -> Result<usize> {\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_invalid(\"missing note type\")?;\n        let last_deck = self.get_last_deck_added_to_for_notetype(note.notetype_id);\n        let ctx = CardGenContext::new(nt.as_ref(), last_deck, self.usn()?);\n        let normalize_text = self.get_config_bool(BoolKey::NormalizeNoteText);\n        self.canonify_note_tags(note, ctx.usn)?;\n        note.prepare_for_update(ctx.notetype, normalize_text)?;\n        note.set_modified(ctx.usn);\n        self.add_note_only_undoable(note)?;\n        let count = self.generate_cards_for_new_note(&ctx, note, did)?;\n        self.set_last_deck_for_notetype(note.notetype_id, did)?;\n        self.set_last_notetype_for_deck(did, note.notetype_id)?;\n        self.set_current_notetype_id(note.notetype_id)?;\n        Ok(count)\n    }\n\n    pub fn update_note(&mut self, note: &mut Note) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateNote, |col| col.update_note_inner(note))\n    }\n\n    pub(crate) fn update_notes_maybe_undoable(\n        &mut self,\n        notes: Vec<Note>,\n        undoable: bool,\n    ) -> Result<OpOutput<()>> {\n        if undoable {\n            self.transact(Op::UpdateNote, |col| {\n                for mut note in notes {\n                    col.update_note_inner(&mut note)?;\n                }\n                Ok(())\n            })\n        } else {\n            self.transact_no_undo(|col| {\n                for mut note in notes {\n                    col.update_note_inner(&mut note)?;\n                }\n                Ok(OpOutput {\n                    output: (),\n                    changes: OpChanges {\n                        op: Op::UpdateNote,\n                        changes: StateChanges {\n                            note: true,\n                            tag: true,\n                            card: true,\n                            ..Default::default()\n                        },\n                    },\n                })\n            })\n        }\n    }\n\n    pub(crate) fn update_note_inner(&mut self, note: &mut Note) -> Result<()> {\n        let mut existing_note = self.storage.get_note(note.id)?.or_not_found(note.id)?;\n        if !note_differs_from_db(&mut existing_note, note) {\n            // nothing to do\n            return Ok(());\n        }\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_invalid(\"missing note type\")?;\n        let last_deck = self.get_last_deck_added_to_for_notetype(note.notetype_id);\n        let ctx = CardGenContext::new(nt.as_ref(), last_deck, self.usn()?);\n        let norm = self.get_config_bool(BoolKey::NormalizeNoteText);\n        self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm, true)?;\n        Ok(())\n    }\n\n    pub(crate) fn update_note_inner_generating_cards(\n        &mut self,\n        ctx: &CardGenContext<&Notetype>,\n        note: &mut Note,\n        original: &Note,\n        mark_note_modified: bool,\n        normalize_text: bool,\n        update_tags: bool,\n    ) -> Result<()> {\n        self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs {\n            note,\n            original,\n            notetype: ctx.notetype,\n            usn: ctx.usn,\n            mark_note_modified,\n            normalize_text,\n            update_tags,\n        })?;\n        self.generate_cards_for_existing_note(ctx, note)\n    }\n\n    #[inline]\n    pub(crate) fn update_note_inner_without_cards_using_mtime(\n        &mut self,\n        UpdateNoteInnerWithoutCardsArgs {\n            note,\n            original,\n            notetype,\n            usn,\n            mark_note_modified,\n            normalize_text,\n            update_tags,\n        }: UpdateNoteInnerWithoutCardsArgs,\n        mtime: Option<TimestampSecs>,\n    ) -> Result<()> {\n        if update_tags {\n            self.canonify_note_tags(note, usn)?;\n        }\n        note.prepare_for_update(notetype, normalize_text)?;\n        if mark_note_modified {\n            if let Some(mtime) = mtime {\n                note.set_modified_with_mtime(usn, mtime);\n            } else {\n                note.set_modified(usn);\n            }\n        }\n        self.update_note_undoable(note, original)\n    }\n\n    pub(crate) fn update_note_inner_without_cards(\n        &mut self,\n        args: UpdateNoteInnerWithoutCardsArgs<'_>,\n    ) -> Result<()> {\n        self.update_note_inner_without_cards_using_mtime(args, None)\n    }\n\n    pub(crate) fn remove_notes_inner(&mut self, nids: &[NoteId], usn: Usn) -> Result<usize> {\n        let mut card_count = 0;\n        for nid in nids {\n            let nid = *nid;\n            if let Some(_existing_note) = self.storage.get_note(nid)? {\n                for card in self.storage.all_cards_of_note(nid)? {\n                    card_count += 1;\n                    self.remove_card_and_add_grave_undoable(card, usn)?;\n                }\n                self.remove_note_only_undoable(nid, usn)?;\n            }\n        }\n        Ok(card_count)\n    }\n\n    fn after_note_updates_inner(\n        &mut self,\n        nids: &[NoteId],\n        generate_cards: bool,\n        mark_notes_modified: bool,\n    ) -> Result<usize> {\n        self.transform_notes(nids, |_note, _nt| {\n            Ok(TransformNoteOutput {\n                changed: true,\n                generate_cards,\n                mark_modified: mark_notes_modified,\n                update_tags: true,\n            })\n        })\n    }\n\n    pub(crate) fn transform_notes<F>(\n        &mut self,\n        nids: &[NoteId],\n        mut transformer: F,\n    ) -> Result<usize>\n    where\n        F: FnMut(&mut Note, &Notetype) -> Result<TransformNoteOutput>,\n    {\n        let nids_by_notetype = self.storage.note_ids_by_notetype(nids)?;\n        let norm = self.get_config_bool(BoolKey::NormalizeNoteText);\n        let mut changed_notes = 0;\n        let usn = self.usn()?;\n\n        for (ntid, group) in &nids_by_notetype.into_iter().chunk_by(|tup| tup.0) {\n            let nt = self.get_notetype(ntid)?.or_invalid(\"missing note type\")?;\n\n            let mut genctx = None;\n            for (_, nid) in group {\n                // grab the note and transform it\n                let mut note = self.storage.get_note(nid)?.unwrap();\n                let original = note.clone();\n                let out = transformer(&mut note, &nt)?;\n                if !out.changed {\n                    continue;\n                }\n\n                if out.generate_cards {\n                    let ctx = genctx.get_or_insert_with(|| {\n                        CardGenContext::new(\n                            nt.as_ref(),\n                            self.get_last_deck_added_to_for_notetype(nt.id),\n                            usn,\n                        )\n                    });\n                    self.update_note_inner_generating_cards(\n                        ctx,\n                        &mut note,\n                        &original,\n                        out.mark_modified,\n                        norm,\n                        out.update_tags,\n                    )?;\n                } else {\n                    self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs {\n                        note: &mut note,\n                        original: &original,\n                        notetype: &nt,\n                        usn,\n                        mark_note_modified: out.mark_modified,\n                        normalize_text: norm,\n                        update_tags: out.update_tags,\n                    })?;\n                }\n\n                changed_notes += 1;\n            }\n        }\n\n        Ok(changed_notes)\n    }\n\n    /// Check if there is a cloze in a non-cloze field. Then check if the\n    /// note's first field is empty. For cloze notetypes, check whether there\n    /// is a cloze at all. Finally, check if the first field is a duplicate.\n    pub fn note_fields_check(&mut self, note: &Note) -> Result<NoteFieldsState> {\n        Ok({\n            let cloze_state = self.field_cloze_check(note)?;\n            if cloze_state == NoteFieldsState::FieldNotCloze {\n                NoteFieldsState::FieldNotCloze\n            } else if let Some(text) = note.fields.first() {\n                let field1 = if self.get_config_bool(BoolKey::NormalizeNoteText) {\n                    normalize_to_nfc(text)\n                } else {\n                    text.into()\n                };\n                let stripped = strip_html_preserving_media_filenames(&field1);\n                if stripped.trim().is_empty() {\n                    NoteFieldsState::Empty\n                } else if cloze_state != NoteFieldsState::Normal {\n                    cloze_state\n                } else if self.is_duplicate(&stripped, note)? {\n                    NoteFieldsState::Duplicate\n                } else {\n                    NoteFieldsState::Normal\n                }\n            } else {\n                NoteFieldsState::Empty\n            }\n        })\n    }\n\n    fn is_duplicate(&self, first_field: &str, note: &Note) -> Result<bool> {\n        let csum = field_checksum(first_field);\n        Ok(self\n            .storage\n            .note_fields_by_checksum(note.notetype_id, csum)?\n            .into_iter()\n            .any(|(nid, field)| {\n                nid != note.id && strip_html_preserving_media_filenames(&field) == first_field\n            }))\n    }\n\n    fn field_cloze_check(&mut self, note: &Note) -> Result<NoteFieldsState> {\n        let notetype = self\n            .get_notetype(note.notetype_id)?\n            .or_not_found(note.notetype_id)?;\n        let cloze_fields = notetype.cloze_fields();\n        let mut has_cloze = false;\n        let extraneous_cloze = note.fields.iter().enumerate().find_map(|(i, field)| {\n            if notetype.is_cloze() {\n                if contains_cloze(field) {\n                    if cloze_fields.contains(&i) {\n                        has_cloze = true;\n                        None\n                    } else {\n                        Some(NoteFieldsState::FieldNotCloze)\n                    }\n                } else {\n                    None\n                }\n            } else if contains_cloze(field) {\n                Some(NoteFieldsState::NotetypeNotCloze)\n            } else {\n                None\n            }\n        });\n        Ok(if let Some(state) = extraneous_cloze {\n            state\n        } else if notetype.is_cloze() && !has_cloze {\n            NoteFieldsState::MissingCloze\n        } else {\n            NoteFieldsState::Normal\n        })\n    }\n}\n\n/// The existing note pulled from the DB will have sfld and csum set, but the\n/// note we receive from the frontend won't. Temporarily zero them out and\n/// compare, then restore them again.\n/// Also set mtime to existing, since the frontend may have a stale mtime, and\n/// we'll bump it as we save in any case.\nfn note_differs_from_db(existing_note: &mut Note, note: &mut Note) -> bool {\n    let sort_field = existing_note.sort_field.take();\n    let checksum = existing_note.checksum.take();\n    note.mtime = existing_note.mtime;\n    let notes_differ = existing_note != note;\n    existing_note.sort_field = sort_field;\n    existing_note.checksum = checksum;\n    notes_differ\n}\n\n#[cfg(test)]\nmod test {\n    use super::anki_base91;\n    use super::field_checksum;\n    use crate::config::BoolKey;\n    use crate::decks::DeckId;\n    use crate::error::Result;\n    use crate::prelude::*;\n    use crate::search::SortMode;\n\n    #[test]\n    fn test_base91() {\n        // match the python implementation for now\n        assert_eq!(anki_base91(0), \"\");\n        assert_eq!(anki_base91(1), \"b\");\n        assert_eq!(anki_base91(u64::MAX), \"Rj&Z5m[>Zp\");\n        assert_eq!(anki_base91(1234567890), \"saAKk\");\n    }\n\n    #[test]\n    fn test_field_checksum() {\n        assert_eq!(field_checksum(\"test\"), 2840236005);\n        assert_eq!(field_checksum(\"今日\"), 1464653051);\n    }\n\n    #[test]\n    fn adding_cards() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col\n            .get_notetype_by_name(\"basic (and reversed card)\")?\n            .unwrap();\n\n        let mut note = nt.new_note();\n        // if no cards are generated, 1 card is added\n        col.add_note(&mut note, DeckId(1)).unwrap();\n        let existing = col.storage.existing_cards_for_note(note.id)?;\n        assert_eq!(existing.len(), 1);\n        assert_eq!(existing[0].ord, 0);\n\n        // nothing changes if the first field is filled\n        note.fields[0] = \"test\".into();\n        col.update_note(&mut note).unwrap();\n        let existing = col.storage.existing_cards_for_note(note.id)?;\n        assert_eq!(existing.len(), 1);\n        assert_eq!(existing[0].ord, 0);\n\n        // second field causes another card to be generated\n        note.fields[1] = \"test\".into();\n        col.update_note(&mut note).unwrap();\n        let existing = col.storage.existing_cards_for_note(note.id)?;\n        assert_eq!(existing.len(), 2);\n        assert_eq!(existing[1].ord, 1);\n\n        // cloze cards also generate card 0 if no clozes are found\n        let nt = col.get_notetype_by_name(\"cloze\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1)).unwrap();\n        let existing = col.storage.existing_cards_for_note(note.id)?;\n        assert_eq!(existing.len(), 1);\n        assert_eq!(existing[0].ord, 0);\n        assert_eq!(existing[0].original_deck_id, DeckId(1));\n\n        // and generate cards for any cloze deletions\n        note.fields[0] = \"{{c1::foo}} {{c2::bar}} {{c3::baz}} {{c0::quux}} {{c501::over}}\".into();\n        col.update_note(&mut note)?;\n        let existing = col.storage.existing_cards_for_note(note.id)?;\n        let mut ords = existing.iter().map(|a| a.ord).collect::<Vec<_>>();\n        ords.sort_unstable();\n        assert_eq!(ords, vec![0, 1, 2, 499]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn normalization() -> Result<()> {\n        let mut col = Collection::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.fields[0] = \"\\u{fa47}\".into();\n        col.add_note(&mut note, DeckId(1))?;\n        assert_eq!(note.fields[0], \"\\u{6f22}\");\n        // non-normalized searches should be converted\n        assert_eq!(col.search_cards(\"\\u{fa47}\", SortMode::NoOrder)?.len(), 1);\n        assert_eq!(\n            col.search_cards(\"front:\\u{fa47}\", SortMode::NoOrder)?.len(),\n            1\n        );\n        let cids = col.search_cards(\"\", SortMode::NoOrder)?;\n        col.remove_cards_and_orphaned_notes(&cids)?;\n\n        // if normalization turned off, note text is entered as-is\n        let mut note = nt.new_note();\n        note.fields[0] = \"\\u{fa47}\".into();\n        col.set_config(BoolKey::NormalizeNoteText, &false).unwrap();\n        col.add_note(&mut note, DeckId(1))?;\n        assert_eq!(note.fields[0], \"\\u{fa47}\");\n        // normalized searches won't match\n        assert_eq!(col.search_cards(\"\\u{6f22}\", SortMode::NoOrder)?.len(), 0);\n        // but original characters will\n        assert_eq!(col.search_cards(\"\\u{fa47}\", SortMode::NoOrder)?.len(), 1);\n\n        Ok(())\n    }\n\n    #[test]\n    fn undo() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col\n            .get_notetype_by_name(\"basic (and reversed card)\")?\n            .unwrap();\n\n        let assert_initial = |col: &mut Collection| -> Result<()> {\n            assert_eq!(col.search_notes_unordered(\"\")?.len(), 0);\n            assert_eq!(col.search_cards(\"\", SortMode::NoOrder)?.len(), 0);\n            assert_eq!(\n                col.storage.db_scalar::<u32>(\"select count() from graves\")?,\n                0\n            );\n            assert!(col.get_next_card()?.is_none());\n            Ok(())\n        };\n\n        let assert_after_add = |col: &mut Collection| -> Result<()> {\n            assert_eq!(col.search_notes_unordered(\"\")?.len(), 1);\n            assert_eq!(col.search_cards(\"\", SortMode::NoOrder)?.len(), 2);\n            assert_eq!(\n                col.storage.db_scalar::<u32>(\"select count() from graves\")?,\n                0\n            );\n            assert!(col.get_next_card()?.is_some());\n            Ok(())\n        };\n\n        assert_initial(&mut col)?;\n\n        let mut note = nt.new_note();\n        note.set_field(0, \"a\")?;\n        note.set_field(1, \"b\")?;\n\n        col.add_note(&mut note, DeckId(1)).unwrap();\n\n        assert_after_add(&mut col)?;\n        col.undo()?;\n        assert_initial(&mut col)?;\n        col.redo()?;\n        assert_after_add(&mut col)?;\n        col.undo()?;\n        assert_initial(&mut col)?;\n\n        let assert_after_remove = |col: &mut Collection| -> Result<()> {\n            assert_eq!(col.search_notes_unordered(\"\")?.len(), 0);\n            assert_eq!(col.search_cards(\"\", SortMode::NoOrder)?.len(), 0);\n            // 1 note + 2 cards\n            assert_eq!(\n                col.storage.db_scalar::<u32>(\"select count() from graves\")?,\n                3\n            );\n            assert!(col.get_next_card()?.is_none());\n            Ok(())\n        };\n\n        col.redo()?;\n        assert_after_add(&mut col)?;\n        let nids = col.search_notes_unordered(\"\")?;\n        col.remove_notes(&nids)?;\n        assert_after_remove(&mut col)?;\n        col.undo()?;\n        assert_after_add(&mut col)?;\n        col.redo()?;\n        assert_after_remove(&mut col)?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notes/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse crate::cloze::cloze_number_in_fields;\nuse crate::collection::Collection;\nuse crate::decks::DeckId;\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::OrInvalid;\nuse crate::error::OrNotFound;\nuse crate::notes::AddNoteRequest;\nuse crate::notes::Note;\nuse crate::notes::NoteId;\nuse crate::prelude::IntoNewtypeVec;\n\npub(crate) fn to_i64s(ids: Vec<NoteId>) -> Vec<i64> {\n    ids.into_iter().map(Into::into).collect()\n}\n\nimpl crate::services::NotesService for Collection {\n    fn new_note(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<anki_proto::notes::Note> {\n        let ntid = input.into();\n\n        let nt = self.get_notetype(ntid)?.or_not_found(ntid)?;\n        Ok(nt.new_note().into())\n    }\n\n    fn add_note(\n        &mut self,\n        input: anki_proto::notes::AddNoteRequest,\n    ) -> error::Result<anki_proto::notes::AddNoteResponse> {\n        let mut note: Note = input.note.or_invalid(\"no note provided\")?.into();\n        let changes = self.add_note(&mut note, DeckId(input.deck_id))?;\n        Ok(anki_proto::notes::AddNoteResponse {\n            note_id: note.id.0,\n            changes: Some(changes.into()),\n        })\n    }\n\n    fn add_notes(\n        &mut self,\n        input: anki_proto::notes::AddNotesRequest,\n    ) -> error::Result<anki_proto::notes::AddNotesResponse> {\n        let mut requests = input\n            .requests\n            .into_iter()\n            .map(TryInto::try_into)\n            .collect::<error::Result<Vec<AddNoteRequest>, AnkiError>>()?;\n        let changes = self.add_notes(&mut requests)?;\n        Ok(anki_proto::notes::AddNotesResponse {\n            nids: requests.iter().map(|r| r.note.id.0).collect(),\n            changes: Some(changes.into()),\n        })\n    }\n\n    fn defaults_for_adding(\n        &mut self,\n        input: anki_proto::notes::DefaultsForAddingRequest,\n    ) -> error::Result<anki_proto::notes::DeckAndNotetype> {\n        let home_deck: DeckId = input.home_deck_of_current_review_card.into();\n        self.defaults_for_adding(home_deck).map(Into::into)\n    }\n\n    fn default_deck_for_notetype(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<anki_proto::decks::DeckId> {\n        Ok(self\n            .default_deck_for_notetype(input.into())?\n            .unwrap_or(DeckId(0))\n            .into())\n    }\n\n    fn update_notes(\n        &mut self,\n        input: anki_proto::notes::UpdateNotesRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let notes = input\n            .notes\n            .into_iter()\n            .map(Into::into)\n            .collect::<Vec<Note>>();\n        self.update_notes_maybe_undoable(notes, !input.skip_undo_entry)\n            .map(Into::into)\n    }\n\n    fn get_note(\n        &mut self,\n        input: anki_proto::notes::NoteId,\n    ) -> error::Result<anki_proto::notes::Note> {\n        let nid = input.into();\n        self.storage\n            .get_note(nid)?\n            .or_not_found(nid)\n            .map(Into::into)\n    }\n\n    fn remove_notes(\n        &mut self,\n        input: anki_proto::notes::RemoveNotesRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        if !input.note_ids.is_empty() {\n            self.remove_notes(\n                &input\n                    .note_ids\n                    .into_iter()\n                    .map(Into::into)\n                    .collect::<Vec<_>>(),\n            )\n        } else {\n            let nids = self.storage.note_ids_of_cards(\n                &input\n                    .card_ids\n                    .into_iter()\n                    .map(Into::into)\n                    .collect::<Vec<_>>(),\n            )?;\n            self.remove_notes(&nids.into_iter().collect::<Vec<_>>())\n        }\n        .map(Into::into)\n    }\n\n    fn cloze_numbers_in_note(\n        &mut self,\n        note: anki_proto::notes::Note,\n    ) -> error::Result<anki_proto::notes::ClozeNumbersInNoteResponse> {\n        let set = cloze_number_in_fields(note.fields);\n        Ok(anki_proto::notes::ClozeNumbersInNoteResponse {\n            numbers: set.into_iter().map(|n| n as u32).collect(),\n        })\n    }\n\n    fn after_note_updates(\n        &mut self,\n        input: anki_proto::notes::AfterNoteUpdatesRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.after_note_updates(\n            &to_note_ids(input.nids),\n            input.generate_cards,\n            input.mark_notes_modified,\n        )\n        .map(Into::into)\n    }\n\n    fn field_names_for_notes(\n        &mut self,\n        input: anki_proto::notes::FieldNamesForNotesRequest,\n    ) -> error::Result<anki_proto::notes::FieldNamesForNotesResponse> {\n        let nids: Vec<_> = input.nids.into_iter().map(NoteId).collect();\n        self.storage\n            .field_names_for_notes(&nids)\n            .map(|fields| anki_proto::notes::FieldNamesForNotesResponse { fields })\n    }\n\n    fn note_fields_check(\n        &mut self,\n        input: anki_proto::notes::Note,\n    ) -> error::Result<anki_proto::notes::NoteFieldsCheckResponse> {\n        let note: Note = input.into();\n\n        self.note_fields_check(&note)\n            .map(|r| anki_proto::notes::NoteFieldsCheckResponse { state: r as i32 })\n    }\n\n    fn cards_of_note(\n        &mut self,\n        input: anki_proto::notes::NoteId,\n    ) -> error::Result<anki_proto::cards::CardIds> {\n        self.storage\n            .all_card_ids_of_note_in_template_order(NoteId(input.nid))\n            .map(|v| anki_proto::cards::CardIds {\n                cids: v.into_iter().map(Into::into).collect(),\n            })\n    }\n\n    fn get_single_notetype_of_notes(\n        &mut self,\n        input: anki_proto::notes::NoteIds,\n    ) -> error::Result<anki_proto::notetypes::NotetypeId> {\n        self.get_single_notetype_of_notes(&input.note_ids.into_newtype(NoteId))\n            .map(Into::into)\n    }\n}\n\npub(crate) fn to_note_ids(ids: Vec<i64>) -> Vec<NoteId> {\n    ids.into_iter().map(NoteId).collect()\n}\n\nimpl From<anki_proto::notes::NoteId> for NoteId {\n    fn from(nid: anki_proto::notes::NoteId) -> Self {\n        NoteId(nid.nid)\n    }\n}\n\nimpl From<NoteId> for anki_proto::notes::NoteId {\n    fn from(nid: NoteId) -> Self {\n        anki_proto::notes::NoteId { nid: nid.0 }\n    }\n}\n"
  },
  {
    "path": "rslib/src/notes/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::NoteTags;\nuse crate::collection::undo::UndoableCollectionChange;\nuse crate::prelude::*;\nuse crate::undo::UndoableChange;\n\n#[derive(Debug)]\npub(crate) enum UndoableNoteChange {\n    Added(Box<Note>),\n    Updated(Box<Note>),\n    Removed(Box<Note>),\n    GraveAdded(Box<(NoteId, Usn)>),\n    GraveRemoved(Box<(NoteId, Usn)>),\n    TagsUpdated(Box<NoteTags>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_note_change(&mut self, change: UndoableNoteChange) -> Result<()> {\n        match change {\n            UndoableNoteChange::Added(note) => self.remove_note_without_grave(*note),\n            UndoableNoteChange::Updated(note) => {\n                let current = self\n                    .storage\n                    .get_note(note.id)?\n                    .or_invalid(\"note disappeared\")?;\n                self.update_note_undoable(&note, &current)\n            }\n            UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note),\n            UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1),\n            UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1),\n            UndoableNoteChange::TagsUpdated(note_tags) => {\n                let current = self\n                    .storage\n                    .get_note_tags_by_id(note_tags.id)?\n                    .or_invalid(\"note disappeared\")?;\n                self.update_note_tags_undoable(&note_tags, current)\n            }\n        }\n    }\n\n    /// Saves in the undo queue, and commits to DB.\n    /// No validation, card generation or normalization is done.\n    pub(crate) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> {\n        self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone())));\n        self.storage.update_note(note)?;\n\n        Ok(())\n    }\n\n    /// Remove a note. Cards must already have been deleted.\n    pub(crate) fn remove_note_only_undoable(&mut self, nid: NoteId, usn: Usn) -> Result<()> {\n        if let Some(note) = self.storage.get_note(nid)? {\n            self.save_undo(UndoableNoteChange::Removed(Box::new(note)));\n            self.storage.remove_note(nid)?;\n            self.add_note_grave(nid, usn)?;\n        }\n        Ok(())\n    }\n\n    /// If note is edited multiple times in quick succession, avoid creating\n    /// extra undo entries.\n    pub(crate) fn maybe_coalesce_note_undo_entry(&mut self, changes: &OpChanges) {\n        if changes.op != Op::UpdateNote {\n            return;\n        }\n        let Some(previous_op) = self.previous_undo_op() else {\n            return;\n        };\n        if previous_op.kind != Op::UpdateNote {\n            return;\n        }\n        let Some(current_op) = self.current_undo_op() else {\n            return;\n        };\n        if let (\n            [UndoableChange::Note(UndoableNoteChange::Updated(previous)), UndoableChange::Collection(UndoableCollectionChange::Modified(_))],\n            [UndoableChange::Note(UndoableNoteChange::Updated(current)), UndoableChange::Collection(UndoableCollectionChange::Modified(_))],\n        ) = (&previous_op.changes[..], &current_op.changes[..])\n        {\n            if previous.id == current.id && previous_op.timestamp.elapsed_secs() < 60 {\n                self.clear_last_op();\n            }\n        }\n    }\n\n    /// Add a note, not adding any cards.\n    pub(crate) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> {\n        self.storage.add_note(note)?;\n        self.save_undo(UndoableNoteChange::Added(Box::new(note.clone())));\n\n        Ok(())\n    }\n\n    /// Add a note, not adding any cards. Caller guarantees id is unique.\n    pub(crate) fn add_note_only_with_id_undoable(&mut self, note: &mut Note) -> Result<()> {\n        require!(self.storage.add_note_if_unique(note)?, \"note id existed\");\n        self.save_undo(UndoableNoteChange::Added(Box::new(note.clone())));\n        Ok(())\n    }\n\n    pub(crate) fn update_note_tags_undoable(\n        &mut self,\n        tags: &NoteTags,\n        original: NoteTags,\n    ) -> Result<()> {\n        self.save_undo(UndoableNoteChange::TagsUpdated(Box::new(original)));\n        self.storage.update_note_tags(tags)\n    }\n\n    fn remove_note_without_grave(&mut self, note: Note) -> Result<()> {\n        self.storage.remove_note(note.id)?;\n        self.save_undo(UndoableNoteChange::Removed(Box::new(note)));\n        Ok(())\n    }\n\n    fn restore_deleted_note(&mut self, note: Note) -> Result<()> {\n        self.storage.add_or_update_note(&note)?;\n        self.save_undo(UndoableNoteChange::Added(Box::new(note)));\n        Ok(())\n    }\n\n    fn add_note_grave(&mut self, nid: NoteId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableNoteChange::GraveAdded(Box::new((nid, usn))));\n        self.storage.add_note_grave(nid, usn)\n    }\n\n    fn remove_note_grave(&mut self, nid: NoteId, usn: Usn) -> Result<()> {\n        self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn))));\n        self.storage.remove_note_grave(nid)\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/cardgen.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::ops::Deref;\n\nuse itertools::Itertools;\nuse rand::rngs::StdRng;\nuse rand::Rng;\nuse rand::SeedableRng;\n\nuse super::Notetype;\nuse crate::cloze::cloze_number_in_fields;\nuse crate::notetype::NotetypeKind;\nuse crate::prelude::*;\nuse crate::template::ParsedTemplate;\n\n/// Info about an existing card required when generating new cards\n#[derive(Debug, PartialEq, Eq)]\npub(crate) struct AlreadyGeneratedCardInfo {\n    pub id: CardId,\n    pub nid: NoteId,\n    pub ord: u32,\n    pub original_deck_id: DeckId,\n    pub position_if_new: Option<u32>,\n}\n\n#[derive(Debug)]\npub(crate) struct CardToGenerate {\n    pub ord: u32,\n    pub did: Option<DeckId>,\n    pub due: Option<u32>,\n}\n\n/// Info required to determine whether a particular card ordinal should exist,\n/// and which deck it should be placed in.\npub(crate) struct SingleCardGenContext {\n    template: Option<ParsedTemplate>,\n    target_deck_id: Option<DeckId>,\n}\n\n/// Info required to determine which cards should be generated when note\n/// added/updated, and where they should be placed.\npub(crate) struct CardGenContext<N: Deref<Target = Notetype>> {\n    pub usn: Usn,\n    pub notetype: N,\n    /// The last deck that was added to with this note type\n    pub last_deck: Option<DeckId>,\n    cards: Vec<SingleCardGenContext>,\n}\n\n// store for data that needs to be looked up multiple times\n#[derive(Default)]\npub(crate) struct CardGenCache {\n    next_position: Option<u32>,\n    deck_configs: HashMap<DeckId, DeckConfig>,\n}\n\nimpl<N: Deref<Target = Notetype>> CardGenContext<N> {\n    pub(crate) fn new(nt: N, last_deck: Option<DeckId>, usn: Usn) -> CardGenContext<N> {\n        let cards = nt\n            .templates\n            .iter()\n            .map(|tmpl| SingleCardGenContext {\n                template: tmpl.parsed_question(),\n                target_deck_id: tmpl.target_deck_id(),\n            })\n            .collect();\n        CardGenContext {\n            usn,\n            last_deck,\n            notetype: nt,\n            cards,\n        }\n    }\n\n    /// If template[ord] generates a non-empty question given nonempty_fields,\n    /// return the provided deck id, or an overridden one. If question is\n    /// empty, return None.\n    fn is_nonempty(&self, card_ord: usize, nonempty_fields: &HashSet<&str>) -> bool {\n        let card = &self.cards[card_ord];\n        let template = match card.template {\n            Some(ref template) => template,\n            None => {\n                // template failed to parse; card can not be generated\n                return false;\n            }\n        };\n\n        template.renders_with_fields(nonempty_fields)\n    }\n\n    /// Returns the cards that need to be generated for the provided note.\n    pub(crate) fn new_cards_required(\n        &self,\n        note: &Note,\n        existing: &[AlreadyGeneratedCardInfo],\n        ensure_not_empty: bool,\n    ) -> Vec<CardToGenerate> {\n        let extracted = extract_data_from_existing_cards(existing);\n        let cards = match self.notetype.config.kind() {\n            NotetypeKind::Normal => self.new_cards_required_normal(note, &extracted),\n            NotetypeKind::Cloze => self.new_cards_required_cloze(note, &extracted),\n        };\n        if extracted.existing_ords.is_empty() && cards.is_empty() && ensure_not_empty {\n            // if there are no existing cards and no cards will be generated,\n            // we add card 0 to ensure the note always has at least one card\n            vec![CardToGenerate {\n                ord: 0,\n                did: extracted.deck_id,\n                due: extracted.due,\n            }]\n        } else {\n            cards\n        }\n    }\n\n    fn new_cards_required_normal(\n        &self,\n        note: &Note,\n        extracted: &ExtractedCardInfo,\n    ) -> Vec<CardToGenerate> {\n        let mut nonempty_fields = note.nonempty_fields(&self.notetype.fields);\n        // Include Tags as a nonempty field when note has tags to render {{#Tags}}\n        if !note.tags.is_empty() {\n            nonempty_fields.insert(\"Tags\");\n        }\n\n        self.cards\n            .iter()\n            .enumerate()\n            .filter_map(|(ord, card)| {\n                if !extracted.existing_ords.contains(&(ord as u32))\n                    && self.is_nonempty(ord, &nonempty_fields)\n                {\n                    Some(CardToGenerate {\n                        ord: ord as u32,\n                        did: card.target_deck_id.or(extracted.deck_id),\n                        due: extracted.due,\n                    })\n                } else {\n                    None\n                }\n            })\n            .collect()\n    }\n\n    fn new_cards_required_cloze(\n        &self,\n        note: &Note,\n        extracted: &ExtractedCardInfo,\n    ) -> Vec<CardToGenerate> {\n        // gather all cloze numbers\n        let set = cloze_number_in_fields(note.fields());\n        set.into_iter()\n            .filter_map(|cloze_ord| {\n                let card_ord = cloze_ord.saturating_sub(1).min(499);\n                if extracted.existing_ords.contains(&(card_ord as u32)) {\n                    None\n                } else {\n                    Some(CardToGenerate {\n                        ord: card_ord as u32,\n                        did: extracted.deck_id,\n                        due: extracted.due,\n                    })\n                }\n            })\n            .collect()\n    }\n}\n\n// this could be reworked in the future to avoid the extra vec allocation\npub(super) fn group_generated_cards_by_note(\n    items: Vec<AlreadyGeneratedCardInfo>,\n) -> Vec<(NoteId, Vec<AlreadyGeneratedCardInfo>)> {\n    let mut out = vec![];\n    for (key, group) in &items.into_iter().chunk_by(|c| c.nid) {\n        out.push((key, group.collect()));\n    }\n    out\n}\n\n#[derive(Debug, PartialEq, Eq, Default)]\npub(crate) struct ExtractedCardInfo {\n    // if set, the due position new cards should be given\n    pub due: Option<u32>,\n    // if set, the deck all current cards are in\n    pub deck_id: Option<DeckId>,\n    pub existing_ords: HashSet<u32>,\n}\n\npub(crate) fn extract_data_from_existing_cards(\n    cards: &[AlreadyGeneratedCardInfo],\n) -> ExtractedCardInfo {\n    let mut due = None;\n    let mut deck_ids = HashSet::new();\n    for card in cards {\n        if due.is_none() && card.position_if_new.is_some() {\n            due = card.position_if_new;\n        }\n        deck_ids.insert(card.original_deck_id);\n    }\n    let existing_ords: HashSet<_> = cards.iter().map(|c| c.ord).collect();\n    ExtractedCardInfo {\n        due,\n        deck_id: if deck_ids.len() == 1 {\n            deck_ids.into_iter().next()\n        } else {\n            None\n        },\n        existing_ords,\n    }\n}\n\nimpl Collection {\n    pub(crate) fn generate_cards_for_new_note(\n        &mut self,\n        ctx: &CardGenContext<impl Deref<Target = Notetype>>,\n        note: &Note,\n        target_deck_id: DeckId,\n    ) -> Result<usize> {\n        self.generate_cards_for_note(\n            ctx,\n            note,\n            &[],\n            Some(target_deck_id),\n            &mut Default::default(),\n        )\n    }\n\n    pub(crate) fn generate_cards_for_existing_note(\n        &mut self,\n        ctx: &CardGenContext<impl Deref<Target = Notetype>>,\n        note: &Note,\n    ) -> Result<()> {\n        let existing = self.storage.existing_cards_for_note(note.id)?;\n        self.generate_cards_for_note(ctx, note, &existing, ctx.last_deck, &mut Default::default())?;\n        Ok(())\n    }\n\n    fn generate_cards_for_note(\n        &mut self,\n        ctx: &CardGenContext<impl Deref<Target = Notetype>>,\n        note: &Note,\n        existing: &[AlreadyGeneratedCardInfo],\n        target_deck_id: Option<DeckId>,\n        cache: &mut CardGenCache,\n    ) -> Result<usize> {\n        let cards = ctx.new_cards_required(note, existing, true);\n        if cards.is_empty() {\n            return Ok(0);\n        }\n        self.add_generated_cards(note.id, &cards, target_deck_id, cache)?;\n        Ok(cards.len())\n    }\n\n    pub(crate) fn generate_cards_for_notetype(\n        &mut self,\n        ctx: &CardGenContext<impl Deref<Target = Notetype>>,\n    ) -> Result<()> {\n        let existing_cards = self.storage.existing_cards_for_notetype(ctx.notetype.id)?;\n        let by_note = group_generated_cards_by_note(existing_cards);\n        let mut cache = CardGenCache::default();\n        for (nid, existing_cards) in by_note {\n            if ctx.notetype.config.kind() == NotetypeKind::Normal\n                && existing_cards.len() == ctx.notetype.templates.len()\n            {\n                // in a normal note type, if card count matches template count, we don't need\n                // to load the note contents to know if all cards have been generated\n                continue;\n            }\n            cache.next_position = None;\n            let note = self.storage.get_note(nid)?.unwrap();\n            self.generate_cards_for_note(ctx, &note, &existing_cards, None, &mut cache)?;\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn add_generated_cards(\n        &mut self,\n        nid: NoteId,\n        cards: &[CardToGenerate],\n        target_deck_id: Option<DeckId>,\n        cache: &mut CardGenCache,\n    ) -> Result<()> {\n        for c in cards {\n            let (did, dcid) = self.deck_for_adding(c.did.or(target_deck_id))?;\n            let due = if let Some(due) = c.due {\n                // use existing due number if provided\n                due\n            } else {\n                self.due_for_deck(did, dcid, cache)?\n            };\n            let mut card = Card::new(nid, c.ord as u16, did, due as i32);\n            self.add_card(&mut card)?;\n        }\n\n        Ok(())\n    }\n\n    // not sure if entry() can be used due to get_deck_config() returning a result\n    #[allow(clippy::map_entry)]\n    fn due_for_deck(\n        &mut self,\n        did: DeckId,\n        dcid: DeckConfigId,\n        cache: &mut CardGenCache,\n    ) -> Result<u32> {\n        if !cache.deck_configs.contains_key(&did) {\n            let conf = self.get_deck_config(dcid, true)?.unwrap();\n            cache.deck_configs.insert(did, conf);\n        }\n        // set if not yet set\n        if cache.next_position.is_none() {\n            cache.next_position = Some(self.get_and_update_next_card_position().unwrap_or(0));\n        }\n        let next_pos = cache.next_position.unwrap();\n\n        match cache\n            .deck_configs\n            .get(&did)\n            .unwrap()\n            .inner\n            .new_card_insert_order()\n        {\n            crate::deckconfig::NewCardInsertOrder::Random => Ok(random_position(next_pos)),\n            crate::deckconfig::NewCardInsertOrder::Due => Ok(next_pos),\n        }\n    }\n\n    /// If deck ID does not exist or points to a filtered deck, fall back on\n    /// default.\n    fn deck_for_adding(&mut self, did: Option<DeckId>) -> Result<(DeckId, DeckConfigId)> {\n        if let Some(did) = did {\n            if let Some(deck) = self.deck_conf_if_normal(did)? {\n                return Ok(deck);\n            }\n        }\n\n        self.default_deck_conf()\n    }\n\n    fn default_deck_conf(&mut self) -> Result<(DeckId, DeckConfigId)> {\n        // currently hard-coded to 1, we could create this as needed in the future\n        self.deck_conf_if_normal(DeckId(1))?\n            .or_invalid(\"invalid default deck\")\n    }\n\n    /// If deck exists and and is a normal deck, return its ID and config\n    fn deck_conf_if_normal(&mut self, did: DeckId) -> Result<Option<(DeckId, DeckConfigId)>> {\n        Ok(self\n            .get_deck(did)?\n            .and_then(|d| d.config_id().map(|conf_id| (did, conf_id))))\n    }\n}\n\nfn random_position(highest_position: u32) -> u32 {\n    let mut rng = StdRng::seed_from_u64(highest_position as u64);\n    rng.random_range(1..highest_position.max(1000))\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::collection::CollectionBuilder;\n\n    #[test]\n    fn random() {\n        // predictable output and a minimum range of 1000\n        assert_eq!(random_position(5), 180);\n        assert_eq!(random_position(500), 13);\n        assert_eq!(random_position(5001), 3731);\n    }\n\n    /// Tests if a basic template generates one card if the Front field has\n    /// content inside\n    #[test]\n    fn new_cards_required_normal_basic() {\n        // create a new temporary collection\n        let mut col = CollectionBuilder::default().build().unwrap();\n        let note_type = col.get_notetype_by_name(\"Basic\").unwrap().unwrap();\n        // create a new note of the basic type\n        let mut note = note_type.new_note();\n        // create a new context for the card generation\n        let context = CardGenContext::new(note_type, None, Usn(-1));\n        // set the front field of the note to \"Hello World\"\n        note.set_field(0, \"Hello World\").unwrap();\n\n        let cards = context.new_cards_required(&note, &[], true);\n        assert_eq!(cards.len(), 1);\n        assert_eq!(cards[0].ord, 0);\n    }\n\n    /// Tests if a cloze note with a single deletion generates one card\n    #[test]\n    fn new_cards_required_cloze_basic() {\n        let mut col = CollectionBuilder::default().build().unwrap();\n        let note_type = col.get_notetype_by_name(\"Cloze\").unwrap().unwrap();\n        let mut note = note_type.new_note();\n        let context = CardGenContext::new(note_type, None, Usn(-1));\n        note.set_field(0, \"Hello {{c1::World}}\").unwrap();\n\n        let cards = context.new_cards_required(&note, &[], true);\n        assert_eq!(cards.len(), 1);\n        assert_eq!(cards[0].ord, 0);\n    }\n\n    /// Tests if multiple cloze deletions generate multiple cards\n    #[test]\n    fn new_cards_required_cloze_multi() {\n        let mut col = CollectionBuilder::default().build().unwrap();\n        let note_type = col.get_notetype_by_name(\"Cloze\").unwrap().unwrap();\n        let mut note = note_type.new_note();\n        let context = CardGenContext::new(note_type, None, Usn(-1));\n        note.set_field(0, \"{{c1::Rome}} is in {{c2::Italy}}\")\n            .unwrap();\n\n        let cards = context.new_cards_required(&note, &[], true);\n        assert_eq!(cards.len(), 2);\n        // using a HashSet to check ordinals without assuming order since cloze\n        // cards can return in any order\n        let ords: HashSet<u32> = cards.iter().map(|c| c.ord).collect();\n        assert!(ords.contains(&0));\n        assert!(ords.contains(&1));\n    }\n\n    /// Tests if the {{#Tags}} conditional generates a card if note has tags\n    #[test]\n    fn new_cards_required_normal_tags_conditional() {\n        let mut col = CollectionBuilder::default().build().unwrap();\n        // cloning the inner Notetype so we can modify the template\n        let arc_note_type = col.get_notetype_by_name(\"Basic\").unwrap().unwrap();\n        let mut note_type = (*arc_note_type).clone();\n        note_type.templates[0].config.q_format = \"{{#Tags}}{{Front}}{{/Tags}}\".to_string();\n        let mut note = note_type.new_note();\n        let context = CardGenContext::new(&note_type, None, Usn(-1));\n        note.set_field(0, \"Hello\").unwrap();\n        note.tags = vec![\"vocabolary\".to_string(), \"english\".to_string()];\n\n        let cards = context.new_cards_required(&note, &[], true);\n        assert_eq!(cards.len(), 1);\n        assert_eq!(cards[0].ord, 0);\n    }\n\n    /// Tests if the {{#Tags}} conditional does not render when the note has no\n    /// tags\n    #[test]\n    fn new_cards_required_normal_tags_empty() {\n        let mut col = CollectionBuilder::default().build().unwrap();\n        // cloning the inner Notetype so we can modify the template\n        let arc_note_type = col.get_notetype_by_name(\"Basic\").unwrap().unwrap();\n        let mut note_type = (*arc_note_type).clone();\n        note_type.templates[0].config.q_format = \"{{#Tags}}{{Front}}{{/Tags}}\".to_string();\n        let mut note = note_type.new_note();\n        let context = CardGenContext::new(&note_type, None, Usn(-1));\n        note.set_field(0, \"Hello\").unwrap();\n        note.tags = vec![];\n\n        let cards = context.new_cards_required(&note, &[], true);\n        assert_eq!(cards.len(), 1);\n        assert_eq!(cards[0].ord, 0);\n    }\n\n    /// Tests if card generation skips ordinals that already exist(duplication)\n    #[test]\n    fn new_cards_required_skip_existing_cards() {\n        let mut col = CollectionBuilder::default().build().unwrap();\n        let note_type = col\n            .get_notetype_by_name(\"Basic (and reversed card)\")\n            .unwrap()\n            .unwrap();\n        let mut note = note_type.new_note();\n        let context = CardGenContext::new(note_type, None, Usn(-1));\n        note.set_field(0, \"Cat\").unwrap();\n        note.set_field(1, \"Neko\").unwrap();\n\n        // simulating that card 0 already exists in the database\n        let existing = vec![AlreadyGeneratedCardInfo {\n            id: CardId(1),\n            nid: NoteId(100),\n            ord: 0,\n            original_deck_id: DeckId(1),\n            position_if_new: None,\n        }];\n        let cards = context.new_cards_required(&note, &existing, true);\n\n        assert_eq!(cards.len(), 1);\n        assert_eq!(cards[0].ord, 1);\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/checks.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::fmt::Write;\nuse std::ops::Deref;\nuse std::sync::LazyLock;\n\nuse anki_i18n::without_unicode_isolation;\nuse regex::Captures;\nuse regex::Match;\nuse regex::Regex;\n\nuse super::CardTemplate;\nuse crate::latex::LATEX;\nuse crate::prelude::*;\nuse crate::text::HTML_MEDIA_TAGS;\nuse crate::text::SOUND_TAG;\n\n#[derive(Debug, PartialEq, Eq)]\nstruct Template<'a> {\n    notetype: &'a str,\n    card_type: &'a str,\n    front: bool,\n}\n\nstatic FIELD_REPLACEMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\{\\{.+\\}\\}\").unwrap());\n\nimpl Collection {\n    pub fn report_media_field_referencing_templates(&mut self, buf: &mut String) -> Result<()> {\n        let notetypes = self.get_all_notetypes()?;\n        let templates = media_field_referencing_templates(notetypes.iter().map(Deref::deref));\n        write_template_report(buf, &templates, &self.tr);\n        Ok(())\n    }\n}\n\nfn media_field_referencing_templates<'a>(\n    notetypes: impl Iterator<Item = &'a Notetype>,\n) -> Vec<Template<'a>> {\n    notetypes\n        .flat_map(|notetype| {\n            notetype.templates.iter().flat_map(|card_type| {\n                card_type\n                    .sides()\n                    .into_iter()\n                    .filter(|&(format, _front)| references_media_field(format))\n                    .map(|(_format, front)| Template::new(&notetype.name, &card_type.name, front))\n            })\n        })\n        .collect()\n}\n\nfn references_media_field(format: &str) -> bool {\n    for regex in [&*HTML_MEDIA_TAGS, &*SOUND_TAG, &*LATEX] {\n        if regex\n            .captures_iter(format)\n            .any(captures_contain_field_replacement)\n        {\n            return true;\n        }\n    }\n    false\n}\n\nfn captures_contain_field_replacement(caps: Captures) -> bool {\n    caps.iter()\n        .skip(1)\n        .any(|opt| opt.is_some_and(match_contains_field_replacement))\n}\n\nfn match_contains_field_replacement(m: Match) -> bool {\n    FIELD_REPLACEMENT.is_match(m.as_str())\n}\n\nfn write_template_report(buf: &mut String, templates: &[Template], tr: &I18n) {\n    if templates.is_empty() {\n        return;\n    }\n    writeln!(\n        buf,\n        \"\\n{}\",\n        &tr.media_check_template_references_field_header()\n    )\n    .unwrap();\n    for template in templates {\n        writeln!(buf, \"{}\", template.as_str(tr)).unwrap();\n    }\n}\n\nimpl<'a> Template<'a> {\n    fn new(notetype: &'a str, card_type: &'a str, front: bool) -> Self {\n        Template {\n            notetype,\n            card_type,\n            front,\n        }\n    }\n\n    fn as_str(&self, tr: &I18n) -> String {\n        without_unicode_isolation(&tr.media_check_notetype_template(\n            self.notetype,\n            self.card_type,\n            self.side_name(tr),\n        ))\n    }\n\n    fn side_name<'tr>(&self, tr: &'tr I18n) -> Cow<'tr, str> {\n        if self.front {\n            tr.card_templates_front_template()\n        } else {\n            tr.card_templates_back_template()\n        }\n    }\n}\n\nimpl CardTemplate {\n    fn sides(&self) -> [(&str, bool); 2] {\n        [\n            (&self.config.q_format, true),\n            (&self.config.a_format, false),\n        ]\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use std::iter::once;\n\n    use super::*;\n\n    #[test]\n    fn should_report_media_field_referencing_template() {\n        let notetype = \"foo\";\n        let card_type = \"bar\";\n        let mut nt = Notetype {\n            name: notetype.into(),\n            ..Default::default()\n        };\n        nt.add_field(\"baz\");\n        nt.add_template(card_type, \"<img src=baz>\", \"<img src={{baz}}>\");\n\n        let templates = media_field_referencing_templates(once(&nt));\n\n        let expected = Template {\n            notetype,\n            card_type,\n            front: false,\n        };\n        assert_eq!(templates, &[expected]);\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/cloze_styling.css",
    "content": ".cloze {\n    font-weight: bold;\n    color: blue;\n}\n.nightMode .cloze {\n    color: lightblue;\n}\n"
  },
  {
    "path": "rslib/src/notetype/emptycards.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::fmt::Write;\n\nuse super::cardgen::group_generated_cards_by_note;\nuse super::CardGenContext;\nuse super::Notetype;\nuse super::NotetypeId;\nuse super::NotetypeKind;\nuse crate::card::CardId;\nuse crate::collection::Collection;\nuse crate::error::Result;\nuse crate::notes::NoteId;\n\npub struct EmptyCardsForNote {\n    pub nid: NoteId,\n    // (ordinal, card id)\n    pub empty: Vec<(u32, CardId)>,\n    pub current_count: usize,\n}\n\nimpl Collection {\n    fn empty_cards_for_notetype(&self, nt: &Notetype) -> Result<Vec<EmptyCardsForNote>> {\n        let last_deck = self.get_last_deck_added_to_for_notetype(nt.id);\n        let ctx = CardGenContext::new(nt, last_deck, self.usn()?);\n        let existing_cards = self.storage.existing_cards_for_notetype(nt.id)?;\n        let by_note = group_generated_cards_by_note(existing_cards);\n        let mut out = Vec::with_capacity(by_note.len());\n\n        for (nid, existing) in by_note {\n            let note = self.storage.get_note(nid)?.unwrap();\n            let cards = ctx.new_cards_required(&note, &[], false);\n            let nonempty_ords: HashSet<_> = cards.into_iter().map(|c| c.ord).collect();\n            let current_count = existing.len();\n            let empty: Vec<_> = existing\n                .into_iter()\n                .filter_map(|e| {\n                    if !nonempty_ords.contains(&e.ord) {\n                        Some((e.ord, e.id))\n                    } else {\n                        None\n                    }\n                })\n                .collect();\n            if !empty.is_empty() {\n                out.push(EmptyCardsForNote {\n                    nid,\n                    empty,\n                    current_count,\n                })\n            }\n        }\n\n        Ok(out)\n    }\n\n    pub fn empty_cards(&mut self) -> Result<Vec<(NotetypeId, Vec<EmptyCardsForNote>)>> {\n        self.storage\n            .get_all_notetype_names()?\n            .into_iter()\n            .map(|(id, _name)| {\n                let nt = self.get_notetype(id)?.unwrap();\n                self.empty_cards_for_notetype(&nt).map(|v| (id, v))\n            })\n            .collect()\n    }\n\n    /// Create a report on empty cards. Mutates the provided data to sort\n    /// ordinals.\n    pub fn empty_cards_report(\n        &mut self,\n        empty: &mut [(NotetypeId, Vec<EmptyCardsForNote>)],\n    ) -> Result<String> {\n        let nts = self.get_all_notetypes()?;\n        let mut buf = String::new();\n        for (ntid, notes) in empty {\n            if !notes.is_empty() {\n                let nt = nts.iter().find(|nt| nt.id == *ntid).unwrap();\n                write!(\n                    buf,\n                    \"<div><b>{}</b></div><ol>\",\n                    self.tr.empty_cards_for_note_type(nt.name.clone())\n                )\n                .unwrap();\n\n                for note in notes {\n                    note.empty.sort_unstable();\n                    let templates = match nt.config.kind() {\n                        // \"Front, Back\"\n                        NotetypeKind::Normal => note\n                            .empty\n                            .iter()\n                            .map(|(ord, _)| {\n                                nt.templates\n                                    .get(*ord as usize)\n                                    .map(|t| t.name.clone())\n                                    .unwrap_or_else(|| format!(\"Card {}\", *ord + 1))\n                            })\n                            .collect::<Vec<_>>()\n                            .join(\", \"),\n                        // \"Cloze 1, 3\"\n                        NotetypeKind::Cloze => format!(\n                            \"{} {}\",\n                            self.tr.notetypes_cloze_name(),\n                            note.empty\n                                .iter()\n                                .map(|(ord, _)| (ord + 1).to_string())\n                                .collect::<Vec<_>>()\n                                .join(\", \")\n                        ),\n                    };\n                    let class = if note.current_count == note.empty.len() {\n                        \"allempty\"\n                    } else {\n                        \"\"\n                    };\n                    write!(\n                        buf,\n                        \"<li class={}>[anki:nid:{}] {}</li>\",\n                        class,\n                        note.nid,\n                        self.tr.empty_cards_count_line(\n                            note.empty.len(),\n                            note.current_count,\n                            templates\n                        )\n                    )\n                    .unwrap();\n                }\n\n                buf.push_str(\"</ol>\");\n            }\n        }\n\n        Ok(buf)\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/fields.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::NoteFieldConfig;\nuse super::NoteFieldProto;\nuse crate::prelude::*;\n\n#[derive(Debug, PartialEq, Clone)]\npub struct NoteField {\n    pub ord: Option<u32>,\n    pub name: String,\n    pub config: NoteFieldConfig,\n}\n\nimpl From<NoteField> for NoteFieldProto {\n    fn from(f: NoteField) -> Self {\n        NoteFieldProto {\n            ord: f.ord.map(Into::into),\n            name: f.name,\n            config: Some(f.config),\n        }\n    }\n}\n\nimpl From<NoteFieldProto> for NoteField {\n    fn from(f: NoteFieldProto) -> Self {\n        NoteField {\n            ord: f.ord.map(|n| n.val),\n            name: f.name,\n            config: f.config.unwrap_or_default(),\n        }\n    }\n}\n\nimpl NoteField {\n    pub fn new(name: impl Into<String>) -> Self {\n        NoteField {\n            ord: None,\n            name: name.into(),\n            config: NoteFieldConfig {\n                id: Some(rand::random()),\n                sticky: false,\n                rtl: false,\n                plain_text: false,\n                font_name: \"Arial\".into(),\n                font_size: 20,\n                description: \"\".into(),\n                collapsed: false,\n                exclude_from_search: false,\n                tag: None,\n                prevent_deletion: false,\n                other: vec![],\n            },\n        }\n    }\n\n    /// Fix the name of the field if it's valid. Otherwise explain why it's not.\n    pub(crate) fn fix_name(&mut self) -> Result<()> {\n        require!(!self.name.is_empty(), \"Empty field name\");\n        let bad_chars = |c| c == ':' || c == '{' || c == '}' || c == '\"';\n        if self.name.contains(bad_chars) {\n            self.name = self.name.replace(bad_chars, \"\");\n        }\n        // and leading/trailing whitespace and special chars\n        let bad_start_chars = |c: char| c == '#' || c == '/' || c == '^' || c.is_whitespace();\n        let trimmed = self.name.trim().trim_start_matches(bad_start_chars);\n        require!(!trimmed.is_empty(), \"Field name: {}\", self.name);\n        if trimmed.len() != self.name.len() {\n            self.name = trimmed.into();\n        }\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn name() {\n        let mut field = NoteField::new(\"  # /^ t:e{s\\\"t} field name #/^  \");\n        assert_eq!(field.fix_name(), Ok(()));\n        assert_eq!(&field.name, \"test field name #/^\");\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/header.tex",
    "content": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n"
  },
  {
    "path": "rslib/src/notetype/merge.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardTemplate;\nuse crate::notetype::NoteField;\nuse crate::prelude::*;\n\nimpl Notetype {\n    /// Inserts not yet existing fields ands templates from `other`.\n    pub(crate) fn merge(&mut self, other: &Self) {\n        self.merge_fields(other);\n        if !self.is_cloze() {\n            self.merge_templates(other);\n        }\n    }\n\n    pub(crate) fn merge_all<'a>(&mut self, others: impl IntoIterator<Item = &'a Self>) {\n        for other in others {\n            self.merge(other);\n        }\n    }\n\n    /// Inserts not yet existing fields from `other`.\n    fn merge_fields(&mut self, other: &Self) {\n        for (index, field) in other.fields.iter().enumerate() {\n            match self.find_field(field) {\n                Some(i) if i == index => (),\n                Some(i) => self.fields.swap(i, index),\n                None => {\n                    let mut missing = field.clone();\n                    missing.ord.take();\n                    self.fields.insert(index, missing);\n                }\n            }\n        }\n    }\n\n    fn find_field(&self, like: &NoteField) -> Option<usize> {\n        self.fields\n            .iter()\n            .enumerate()\n            .find_map(|(i, f)| f.is_match(like).then_some(i))\n    }\n\n    /// Inserts not yet existing templates from `other`.\n    fn merge_templates(&mut self, other: &Self) {\n        for (index, template) in other.templates.iter().enumerate() {\n            match self.find_template(template) {\n                Some(i) if i == index => (),\n                Some(i) => self.templates.swap(i, index),\n                None => {\n                    let mut missing = template.clone();\n                    missing.ord.take();\n                    self.templates.insert(index, missing);\n                }\n            }\n        }\n    }\n\n    fn find_template(&self, like: &CardTemplate) -> Option<usize> {\n        self.templates\n            .iter()\n            .enumerate()\n            .find_map(|(i, t)| t.is_match(like).then_some(i))\n    }\n}\n\nimpl NoteField {\n    /// True if both ids are identical, but not [None], or at least one id is\n    /// [None] and the names are identical.\n    pub(crate) fn is_match(&self, other: &Self) -> bool {\n        if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) {\n            id == other_id\n        } else {\n            self.name == other.name\n        }\n    }\n}\n\nimpl CardTemplate {\n    /// True if both ids are identical, but not [None], or at least one id is\n    /// [None] and the names are identical.\n    pub(crate) fn is_match(&self, other: &Self) -> bool {\n        if let (Some(id), Some(other_id)) = (self.config.id, other.config.id) {\n            id == other_id\n        } else {\n            self.name == other.name\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use itertools::assert_equal;\n\n    use super::*;\n    use crate::notetype::stock;\n\n    impl Notetype {\n        fn field_ids(&self) -> impl Iterator<Item = Option<i64>> + '_ {\n            self.fields.iter().map(|field| field.config.id)\n        }\n\n        fn template_ids(&self) -> impl Iterator<Item = Option<i64>> + '_ {\n            self.templates.iter().map(|template| template.config.id)\n        }\n    }\n\n    #[test]\n    fn merge_new_fields() {\n        let mut basic = stock::basic(&I18n::template_only());\n        let mut other = basic.clone();\n        other.add_field(\"with id\");\n        other.add_field(\"without id\");\n        other.fields[3].config.id.take();\n        basic.merge(&other);\n        assert_equal(basic.field_ids(), other.field_ids());\n        assert_equal(basic.field_names(), other.field_names());\n    }\n\n    #[test]\n    fn skip_merging_field_with_existing_id() {\n        let mut basic = stock::basic(&I18n::template_only());\n        let mut other = basic.clone();\n        other.fields[1].name = String::from(\"renamed\");\n        basic.merge(&other);\n        assert_equal(basic.field_ids(), other.field_ids());\n        assert_equal(basic.field_names(), [\"Front\", \"Back\"].iter());\n    }\n\n    #[test]\n    fn align_field_order() {\n        let mut basic = stock::basic(&I18n::template_only());\n        let mut other = basic.clone();\n        other.fields.swap(0, 1);\n        basic.merge(&other);\n        assert_equal(basic.field_ids(), other.field_ids());\n        assert_equal(basic.field_names(), other.field_names());\n    }\n\n    #[test]\n    fn merge_new_templates() {\n        let mut basic = stock::basic(&I18n::template_only());\n        let mut other = basic.clone();\n        other.add_template(\"with id\", \"\", \"\");\n        other.add_template(\"without id\", \"\", \"\");\n        other.templates[2].config.id.take();\n        basic.merge(&other);\n        assert_equal(basic.template_ids(), other.template_ids());\n        assert_equal(basic.template_names(), other.template_names());\n    }\n\n    #[test]\n    fn skip_merging_template_with_existing_id() {\n        let mut basic = stock::basic(&I18n::template_only());\n        let mut other = basic.clone();\n        other.templates[0].name = String::from(\"renamed\");\n        basic.merge(&other);\n        assert_equal(basic.template_ids(), other.template_ids());\n        assert_equal(basic.template_names(), std::iter::once(\"Card 1\"));\n    }\n\n    #[test]\n    fn align_template_order() {\n        let mut basic_rev = stock::basic_forward_reverse(&I18n::template_only());\n        let mut other = basic_rev.clone();\n        other.templates.swap(0, 1);\n        basic_rev.merge(&other);\n        assert_equal(basic_rev.template_ids(), other.template_ids());\n        assert_equal(basic_rev.template_names(), other.template_names());\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod cardgen;\nmod checks;\nmod emptycards;\nmod fields;\nmod merge;\nmod notetypechange;\nmod render;\nmod restore;\npub(crate) mod schema11;\nmod schemachange;\nmod service;\npub(crate) mod stock;\nmod templates;\npub(crate) mod undo;\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::iter::FromIterator;\nuse std::sync::Arc;\nuse std::sync::LazyLock;\n\npub use anki_proto::notetypes::notetype::config::card_requirement::Kind as CardRequirementKind;\npub use anki_proto::notetypes::notetype::config::CardRequirement;\npub use anki_proto::notetypes::notetype::config::Kind as NotetypeKind;\npub use anki_proto::notetypes::notetype::field::Config as NoteFieldConfig;\npub use anki_proto::notetypes::notetype::template::Config as CardTemplateConfig;\npub use anki_proto::notetypes::notetype::Config as NotetypeConfig;\npub use anki_proto::notetypes::notetype::Field as NoteFieldProto;\npub use anki_proto::notetypes::notetype::Template as CardTemplateProto;\npub use anki_proto::notetypes::Notetype as NotetypeProto;\npub(crate) use cardgen::AlreadyGeneratedCardInfo;\npub(crate) use cardgen::CardGenContext;\npub use fields::NoteField;\npub use notetypechange::ChangeNotetypeInput;\npub use notetypechange::NotetypeChangeInfo;\nuse regex::Regex;\npub(crate) use render::RenderCardOutput;\npub use schema11::CardTemplateSchema11;\npub use schema11::NoteFieldSchema11;\npub use schema11::NotetypeSchema11;\npub use stock::all_stock_notetypes;\npub use templates::CardTemplate;\nuse unicase::UniCase;\n\nuse crate::define_newtype;\nuse crate::error::CardTypeError;\nuse crate::error::CardTypeErrorDetails;\nuse crate::error::CardTypeSnafu;\nuse crate::error::MissingClozeSnafu;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::Node;\nuse crate::search::SearchNode;\nuse crate::storage::comma_separated_ids;\nuse crate::template::FieldRequirements;\nuse crate::template::ParsedTemplate;\nuse crate::text::ensure_string_in_nfc;\nuse crate::text::extract_underscored_css_imports;\nuse crate::text::extract_underscored_references;\n\ndefine_newtype!(NotetypeId, i64);\n\npub(crate) const DEFAULT_CSS: &str = include_str!(\"styling.css\");\npub(crate) const DEFAULT_CLOZE_CSS: &str = include_str!(\"cloze_styling.css\");\npub(crate) const DEFAULT_LATEX_HEADER: &str = include_str!(\"header.tex\");\npub(crate) const DEFAULT_LATEX_FOOTER: &str = r\"\\end{document}\";\n/// New entries must be handled in render.rs/add_special_fields().\nstatic SPECIAL_FIELDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {\n    HashSet::from_iter(vec![\n        \"FrontSide\",\n        \"Card\",\n        \"CardFlag\",\n        \"Deck\",\n        \"Subdeck\",\n        \"Tags\",\n        \"Type\",\n        \"CardID\",\n    ])\n});\n\n#[derive(Debug, PartialEq, Clone)]\npub struct Notetype {\n    pub id: NotetypeId,\n    pub name: String,\n    pub mtime_secs: TimestampSecs,\n    pub usn: Usn,\n    pub fields: Vec<NoteField>,\n    pub templates: Vec<CardTemplate>,\n    pub config: NotetypeConfig,\n}\n\nimpl Default for Notetype {\n    fn default() -> Self {\n        Notetype {\n            id: NotetypeId(0),\n            name: \"\".into(),\n            mtime_secs: TimestampSecs(0),\n            usn: Usn(0),\n            fields: vec![],\n            templates: vec![],\n            config: Notetype::new_config(),\n        }\n    }\n}\n\nimpl Notetype {\n    pub(crate) fn new_config() -> NotetypeConfig {\n        NotetypeConfig {\n            css: DEFAULT_CSS.into(),\n            latex_pre: DEFAULT_LATEX_HEADER.into(),\n            latex_post: DEFAULT_LATEX_FOOTER.into(),\n            ..Default::default()\n        }\n    }\n\n    pub(crate) fn new_cloze_config() -> NotetypeConfig {\n        let mut config = Self::new_config();\n        config.css += DEFAULT_CLOZE_CSS;\n        config.kind = NotetypeKind::Cloze as i32;\n        config\n    }\n}\n\nimpl Notetype {\n    pub fn new_note(&self) -> Note {\n        Note::new(self)\n    }\n\n    /// Return the template for the given card ordinal. Cloze notetypes\n    /// always return the first and only template.\n    pub fn get_template(&self, card_ord: u16) -> Result<&CardTemplate> {\n        let template = if self.config.kind() == NotetypeKind::Cloze {\n            self.templates.first()\n        } else {\n            self.templates.get(card_ord as usize)\n        };\n\n        template.or_not_found(card_ord)\n    }\n}\n\nimpl Collection {\n    /// Add a new notetype, and allocate it an ID.\n    pub fn add_notetype(\n        &mut self,\n        notetype: &mut Notetype,\n        skip_checks: bool,\n    ) -> Result<OpOutput<()>> {\n        self.transact(Op::AddNotetype, |col| {\n            let usn = col.usn()?;\n            notetype.set_modified(usn);\n            col.add_notetype_inner(notetype, usn, skip_checks)\n        })\n    }\n\n    /// Saves changes to a note type. This will force a full sync if templates\n    /// or fields have been added/removed/reordered.\n    ///\n    /// This does not assign ordinals to the provided notetype, so if you wish\n    /// to make use of template_idx, the notetype must be fetched again.\n    pub fn update_notetype(\n        &mut self,\n        notetype: &mut Notetype,\n        skip_checks: bool,\n    ) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdateNotetype, |col| {\n            let original = col\n                .storage\n                .get_notetype(notetype.id)?\n                .or_not_found(notetype.id)?;\n            let usn = col.usn()?;\n            notetype.set_modified(usn);\n            col.add_or_update_notetype_with_existing_id_inner(\n                notetype,\n                Some(original),\n                usn,\n                skip_checks,\n            )\n        })\n    }\n\n    /// Used to support the current importing code; does not mark notetype as\n    /// modified, and does not support undo.\n    pub fn add_or_update_notetype_with_existing_id(\n        &mut self,\n        notetype: &mut Notetype,\n        skip_checks: bool,\n    ) -> Result<()> {\n        self.transact_no_undo(|col| {\n            let usn = col.usn()?;\n            let existing = col.storage.get_notetype(notetype.id)?;\n            col.add_or_update_notetype_with_existing_id_inner(notetype, existing, usn, skip_checks)\n        })\n    }\n\n    pub fn get_notetype_by_name(&mut self, name: &str) -> Result<Option<Arc<Notetype>>> {\n        if let Some(ntid) = self.storage.get_notetype_id(name)? {\n            self.get_notetype(ntid)\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub fn get_notetype(&mut self, ntid: NotetypeId) -> Result<Option<Arc<Notetype>>> {\n        if let Some(nt) = self.state.notetype_cache.get(&ntid) {\n            return Ok(Some(nt.clone()));\n        }\n        if let Some(nt) = self.storage.get_notetype(ntid)? {\n            let nt = Arc::new(nt);\n            self.state.notetype_cache.insert(ntid, nt.clone());\n            Ok(Some(nt))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub fn get_all_notetypes(&mut self) -> Result<Vec<Arc<Notetype>>> {\n        self.storage\n            .get_all_notetype_ids()?\n            .into_iter()\n            .filter_map(|ntid| self.get_notetype(ntid).transpose())\n            .collect()\n    }\n\n    pub fn get_all_notetypes_of_search_notes(\n        &mut self,\n    ) -> Result<HashMap<NotetypeId, Arc<Notetype>>> {\n        self.storage\n            .all_notetypes_of_search_notes()?\n            .into_iter()\n            .map(|ntid| {\n                self.get_notetype(ntid)\n                    .transpose()\n                    .unwrap()\n                    .map(|nt| (ntid, nt))\n            })\n            .collect()\n    }\n\n    pub fn remove_notetype(&mut self, ntid: NotetypeId) -> Result<OpOutput<()>> {\n        self.transact(Op::RemoveNotetype, |col| col.remove_notetype_inner(ntid))\n    }\n\n    /// Return the notetype used by `note_ids`, or an error if not exactly 1\n    /// notetype is in use.\n    pub fn get_single_notetype_of_notes(&mut self, note_ids: &[NoteId]) -> Result<NotetypeId> {\n        require!(!note_ids.is_empty(), \"no note id provided\");\n\n        let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into();\n        let note1 = self\n            .storage\n            .get_note(*note_ids.first().unwrap())?\n            .or_not_found(note_ids[0])?;\n\n        if self\n            .search_notes_unordered(note1.notetype_id.and(nids_node))?\n            .len()\n            != note_ids.len()\n        {\n            Err(AnkiError::MultipleNotetypesSelected)\n        } else {\n            Ok(note1.notetype_id)\n        }\n    }\n}\n\nimpl Notetype {\n    pub(crate) fn ensure_names_unique(&mut self) {\n        let mut names = HashSet::new();\n        for t in &mut self.templates {\n            loop {\n                let name = UniCase::new(t.name.clone());\n                if !names.contains(&name) {\n                    names.insert(name);\n                    break;\n                }\n                t.name.push('+');\n            }\n        }\n        names.clear();\n        for t in &mut self.fields {\n            loop {\n                let name = UniCase::new(t.name.clone());\n                if !names.contains(&name) {\n                    names.insert(name);\n                    break;\n                }\n                t.name.push('+');\n            }\n        }\n    }\n\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.mtime_secs = TimestampSecs::now();\n        self.usn = usn;\n    }\n\n    fn updated_requirements(\n        &self,\n        parsed: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n    ) -> Vec<CardRequirement> {\n        let field_map: HashMap<&str, u16> = self\n            .fields\n            .iter()\n            .enumerate()\n            .map(|(idx, field)| (field.name.as_str(), idx as u16))\n            .collect();\n        parsed\n            .iter()\n            .enumerate()\n            .map(|(ord, (qtmpl, _atmpl))| {\n                if let Some(tmpl) = qtmpl {\n                    let mut req = match tmpl.requirements(&field_map) {\n                        FieldRequirements::Any(ords) => CardRequirement {\n                            card_ord: ord as u32,\n                            kind: CardRequirementKind::Any as i32,\n                            field_ords: ords.into_iter().map(|n| n as u32).collect(),\n                        },\n                        FieldRequirements::All(ords) => CardRequirement {\n                            card_ord: ord as u32,\n                            kind: CardRequirementKind::All as i32,\n                            field_ords: ords.into_iter().map(|n| n as u32).collect(),\n                        },\n                        FieldRequirements::None => CardRequirement {\n                            card_ord: ord as u32,\n                            kind: CardRequirementKind::None as i32,\n                            field_ords: vec![],\n                        },\n                    };\n                    req.field_ords.sort_unstable();\n                    req\n                } else {\n                    // template parsing failures make card unsatisfiable\n                    CardRequirement {\n                        card_ord: ord as u32,\n                        kind: CardRequirementKind::None as i32,\n                        field_ords: vec![],\n                    }\n                }\n            })\n            .collect()\n    }\n\n    /// Adjust sort index to match repositioned fields.\n    fn reposition_sort_idx(&mut self) {\n        self.config.sort_field_idx = self\n            .fields\n            .iter()\n            .enumerate()\n            .find_map(|(idx, f)| {\n                if f.ord == Some(self.config.sort_field_idx) {\n                    Some(idx as u32)\n                } else {\n                    None\n                }\n            })\n            .unwrap_or_else(|| {\n                // provided ordinal not on any existing field; cap to bounds\n                self.config\n                    .sort_field_idx\n                    .clamp(0, (self.fields.len() - 1) as u32)\n            });\n    }\n\n    fn ensure_template_fronts_unique(&self) -> Result<(), CardTypeError> {\n        static CARD_TAG: LazyLock<Regex> =\n            LazyLock::new(|| Regex::new(r\"\\{\\{\\s*Card\\s*\\}\\}\").unwrap());\n\n        let mut map = HashMap::new();\n        for (index, card) in self.templates.iter().enumerate() {\n            if let Some(old_index) = map.insert(&card.config.q_format, index) {\n                if !CARD_TAG.is_match(&card.config.q_format) {\n                    return Err(CardTypeError {\n                        notetype: self.name.clone(),\n                        ordinal: index,\n                        source: CardTypeErrorDetails::Duplicate { index: old_index },\n                    });\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Ensure no templates are None, every front template contains at least one\n    /// field, and all used field names belong to a field of this notetype.\n    fn ensure_valid_parsed_templates(\n        &self,\n        templates: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n    ) -> Result<(), CardTypeError> {\n        for (ordinal, sides) in templates.iter().enumerate() {\n            self.ensure_valid_parsed_card_templates(sides)\n                .context(CardTypeSnafu {\n                    notetype: &self.name,\n                    ordinal,\n                })?;\n        }\n        Ok(())\n    }\n\n    fn ensure_valid_parsed_card_templates(\n        &self,\n        sides: &(Option<ParsedTemplate>, Option<ParsedTemplate>),\n    ) -> Result<(), CardTypeErrorDetails> {\n        if let (Some(q), Some(a)) = sides {\n            let q_fields = q.all_referenced_field_names();\n            if q_fields.is_empty() {\n                return Err(CardTypeErrorDetails::NoFrontField);\n            }\n            if let Some(unknown_field) =\n                self.first_unknown_field_name(q_fields.union(&a.all_referenced_field_names()))\n            {\n                return Err(CardTypeErrorDetails::NoSuchField {\n                    field: unknown_field.to_string(),\n                });\n            }\n            Ok(())\n        } else {\n            Err(CardTypeErrorDetails::TemplateParseError)\n        }\n    }\n\n    /// Return the first non-empty name in names that does not denote a special\n    /// field or a field of this notetype.\n    fn first_unknown_field_name<T, I>(&self, names: T) -> Option<I>\n    where\n        T: IntoIterator<Item = I>,\n        I: AsRef<str>,\n    {\n        names.into_iter().find(|name| {\n            // The empty field name is allowed as it may be used by add-ons.\n            !name.as_ref().is_empty()\n                && !SPECIAL_FIELDS.contains(&name.as_ref())\n                && self.fields.iter().all(|field| field.name != name.as_ref())\n        })\n    }\n\n    fn ensure_cloze_if_cloze_notetype(\n        &self,\n        parsed_templates: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n    ) -> Result<(), CardTypeError> {\n        if self.is_cloze() && missing_cloze_filter(parsed_templates) {\n            MissingClozeSnafu.fail().context(CardTypeSnafu {\n                notetype: &self.name,\n                ordinal: 0usize,\n            })\n        } else {\n            Ok(())\n        }\n    }\n\n    pub(crate) fn normalize_names(&mut self) {\n        ensure_string_in_nfc(&mut self.name);\n        for f in &mut self.fields {\n            ensure_string_in_nfc(&mut f.name);\n        }\n        for t in &mut self.templates {\n            ensure_string_in_nfc(&mut t.name);\n        }\n    }\n\n    pub(crate) fn add_field<S: Into<String>>(&mut self, name: S) -> &mut NoteFieldConfig {\n        self.fields.push(NoteField::new(name));\n        self.fields.last_mut().map(|f| &mut f.config).unwrap()\n    }\n\n    pub(crate) fn add_template<S1, S2, S3>(&mut self, name: S1, qfmt: S2, afmt: S3)\n    where\n        S1: Into<String>,\n        S2: Into<String>,\n        S3: Into<String>,\n    {\n        self.templates.push(CardTemplate::new(name, qfmt, afmt));\n    }\n\n    pub(crate) fn prepare_for_update(\n        &mut self,\n        existing: Option<&Notetype>,\n        skip_checks: bool,\n    ) -> Result<()> {\n        require!(!self.fields.is_empty(), \"1 field required\");\n        require!(!self.templates.is_empty(), \"1 template required\");\n        let bad_chars = |c| c == '\"';\n        if self.name.contains(bad_chars) {\n            self.name = self.name.replace(bad_chars, \"\");\n        }\n        require!(!self.name.is_empty(), \"Empty notetype name\");\n        self.normalize_names();\n        self.fix_field_names()?;\n        self.fix_template_names()?;\n        self.ensure_names_unique();\n        self.reposition_sort_idx();\n\n        let mut parsed_templates = self.parsed_templates();\n        let mut parsed_browser_templates = self.parsed_browser_templates();\n        let reqs = self.updated_requirements(&parsed_templates);\n\n        // handle renamed+deleted fields\n        if let Some(existing) = existing {\n            let fields = self.renamed_and_removed_fields(existing);\n            if !fields.is_empty() {\n                self.update_templates_for_renamed_and_removed_fields(\n                    fields,\n                    &mut parsed_templates,\n                    &mut parsed_browser_templates,\n                );\n            }\n        }\n        self.config.reqs = reqs;\n        if !skip_checks {\n            self.check_templates(parsed_templates)?;\n        }\n\n        Ok(())\n    }\n\n    fn check_templates(\n        &self,\n        parsed_templates: Vec<(Option<ParsedTemplate>, Option<ParsedTemplate>)>,\n    ) -> Result<()> {\n        self.ensure_template_fronts_unique()\n            .and(self.ensure_valid_parsed_templates(&parsed_templates))\n            .and(self.ensure_cloze_if_cloze_notetype(&parsed_templates))?;\n        Ok(())\n    }\n\n    fn renamed_and_removed_fields(&self, current: &Notetype) -> HashMap<String, Option<String>> {\n        let mut remaining_ords = HashSet::new();\n        // gather renames\n        let mut map: HashMap<String, Option<String>> = self\n            .fields\n            .iter()\n            .filter_map(|field| {\n                if let Some(existing_ord) = field.ord {\n                    remaining_ords.insert(existing_ord);\n                    if let Some(existing_field) = current.fields.get(existing_ord as usize) {\n                        if existing_field.name != field.name {\n                            return Some((existing_field.name.clone(), Some(field.name.clone())));\n                        }\n                    }\n                }\n                None\n            })\n            .collect();\n        // and add any fields that have been removed\n        for (idx, field) in current.fields.iter().enumerate() {\n            if !remaining_ords.contains(&(idx as u32)) {\n                map.insert(field.name.clone(), None);\n            }\n        }\n\n        map\n    }\n\n    /// Update templates to reflect field deletions and renames.\n    /// Any templates that failed to parse will be ignored.\n    fn update_templates_for_renamed_and_removed_fields(\n        &mut self,\n        fields: HashMap<String, Option<String>>,\n        parsed: &mut [(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n        parsed_browser: &mut [(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n    ) {\n        let first_remaining_field_name = &self.fields.first().unwrap().name;\n        let is_cloze = self.is_cloze();\n\n        let q_update_fields = |q_opt: &mut Option<ParsedTemplate>, template_target: &mut String| {\n            if let Some(q) = q_opt {\n                q.rename_and_remove_fields(&fields);\n                if !q.contains_field_replacement() || is_cloze && !q.contains_cloze_replacement() {\n                    q.add_missing_field_replacement(first_remaining_field_name, is_cloze);\n                }\n                *template_target = q.template_to_string();\n            }\n        };\n\n        let a_update_fields = |a_opt: &mut Option<ParsedTemplate>, template_target: &mut String| {\n            if let Some(a) = a_opt {\n                a.rename_and_remove_fields(&fields);\n                if is_cloze && !a.contains_cloze_replacement() {\n                    a.add_missing_field_replacement(first_remaining_field_name, is_cloze);\n                }\n                *template_target = a.template_to_string();\n            }\n        };\n\n        // Update main templates\n        for (idx, (q_opt, a_opt)) in parsed.iter_mut().enumerate() {\n            q_update_fields(q_opt, &mut self.templates[idx].config.q_format);\n\n            a_update_fields(a_opt, &mut self.templates[idx].config.a_format);\n        }\n\n        // Update browser templates, if they exist\n        for (idx, (q_browser_opt, a_browser_opt)) in parsed_browser.iter_mut().enumerate() {\n            q_update_fields(\n                q_browser_opt,\n                &mut self.templates[idx].config.q_format_browser,\n            );\n\n            a_update_fields(\n                a_browser_opt,\n                &mut self.templates[idx].config.a_format_browser,\n            );\n        }\n    }\n\n    fn parsed_templates(&self) -> Vec<(Option<ParsedTemplate>, Option<ParsedTemplate>)> {\n        self.templates\n            .iter()\n            .map(|t| (t.parsed_question(), t.parsed_answer()))\n            .collect()\n    }\n    fn parsed_browser_templates(&self) -> Vec<(Option<ParsedTemplate>, Option<ParsedTemplate>)> {\n        self.templates\n            .iter()\n            .map(|t| {\n                (\n                    t.parsed_question_format_for_browser(),\n                    t.parsed_answer_format_for_browser(),\n                )\n            })\n            .collect()\n    }\n\n    fn fix_field_names(&mut self) -> Result<()> {\n        self.fields.iter_mut().try_for_each(NoteField::fix_name)\n    }\n\n    fn fix_template_names(&mut self) -> Result<()> {\n        self.templates\n            .iter_mut()\n            .try_for_each(CardTemplate::fix_name)\n    }\n\n    /// Find the field index of the provided field name.\n    pub(crate) fn get_field_ord(&self, field_name: &str) -> Option<usize> {\n        let field_name = UniCase::new(field_name);\n        self.fields\n            .iter()\n            .enumerate()\n            .filter_map(|(idx, f)| {\n                if UniCase::new(&f.name) == field_name {\n                    Some(idx)\n                } else {\n                    None\n                }\n            })\n            .next()\n    }\n\n    pub(crate) fn is_cloze(&self) -> bool {\n        matches!(self.config.kind(), NotetypeKind::Cloze)\n    }\n\n    /// Return all clozable fields. A field is clozable when it belongs to a\n    /// cloze notetype and a 'cloze' filter is applied to it in the\n    /// template.\n    pub(crate) fn cloze_fields(&self) -> HashSet<usize> {\n        if !self.is_cloze() {\n            HashSet::new()\n        } else if let Some((Some(front), _)) = self.parsed_templates().first() {\n            front\n                .all_referenced_cloze_field_names()\n                .iter()\n                .filter_map(|name| self.get_field_ord(name))\n                .collect()\n        } else {\n            HashSet::new()\n        }\n    }\n\n    pub(crate) fn gather_media_names(&self, inserter: &mut impl FnMut(String)) {\n        for name in extract_underscored_css_imports(&self.config.css) {\n            inserter(name.to_string());\n        }\n        for template in &self.templates {\n            for template_side in [&template.config.q_format, &template.config.a_format] {\n                for name in extract_underscored_references(template_side) {\n                    inserter(name.to_string());\n                }\n            }\n        }\n    }\n}\n\n/// True if the slice is empty or either template of the first tuple doesn't\n/// have a cloze field.\nfn missing_cloze_filter(\n    parsed_templates: &[(Option<ParsedTemplate>, Option<ParsedTemplate>)],\n) -> bool {\n    parsed_templates\n        .first()\n        .map_or(true, |t| !has_cloze(&t.0) || !has_cloze(&t.1))\n}\n\n/// True if the template is non-empty and has a cloze field.\nfn has_cloze(template: &Option<ParsedTemplate>) -> bool {\n    template\n        .as_ref()\n        .is_some_and(|t| !t.all_referenced_cloze_field_names().is_empty())\n}\n\nimpl From<Notetype> for NotetypeProto {\n    fn from(nt: Notetype) -> Self {\n        NotetypeProto {\n            id: nt.id.0,\n            name: nt.name,\n            mtime_secs: nt.mtime_secs.0,\n            usn: nt.usn.0,\n            config: Some(nt.config),\n            fields: nt.fields.into_iter().map(Into::into).collect(),\n            templates: nt.templates.into_iter().map(Into::into).collect(),\n        }\n    }\n}\n\nimpl Collection {\n    pub(crate) fn ensure_notetype_name_unique(\n        &self,\n        notetype: &mut Notetype,\n        usn: Usn,\n    ) -> Result<()> {\n        loop {\n            match self.storage.get_notetype_id(&notetype.name)? {\n                Some(id) if id == notetype.id => {\n                    break;\n                }\n                None => break,\n                _ => (),\n            }\n            notetype.name += \"+\";\n            notetype.set_modified(usn);\n        }\n\n        Ok(())\n    }\n\n    /// Caller must set notetype as modified if appropriate.\n    pub(crate) fn add_notetype_inner(\n        &mut self,\n        notetype: &mut Notetype,\n        usn: Usn,\n        skip_checks: bool,\n    ) -> Result<()> {\n        notetype.prepare_for_update(None, skip_checks)?;\n        self.ensure_notetype_name_unique(notetype, usn)?;\n        self.add_notetype_undoable(notetype)?;\n        self.set_current_notetype_id(notetype.id)\n    }\n\n    /// - Caller must set notetype as modified if appropriate.\n    /// - This only supports undo when an existing notetype is passed in.\n    pub(crate) fn add_or_update_notetype_with_existing_id_inner(\n        &mut self,\n        notetype: &mut Notetype,\n        original: Option<Notetype>,\n        usn: Usn,\n        skip_checks: bool,\n    ) -> Result<()> {\n        let normalize = self.get_config_bool(BoolKey::NormalizeNoteText);\n        notetype.prepare_for_update(original.as_ref(), skip_checks)?;\n        self.ensure_notetype_name_unique(notetype, usn)?;\n\n        if let Some(original) = original {\n            self.update_notes_for_changed_fields(\n                notetype,\n                original.fields.len(),\n                original.config.sort_field_idx,\n                normalize,\n            )?;\n            self.update_cards_for_changed_templates(notetype, &original.templates)?;\n            self.update_notetype_undoable(notetype, original)?;\n        } else {\n            // adding with existing id for old undo code, bypass undo\n            self.state.notetype_cache.remove(&notetype.id);\n            self.storage\n                .add_or_update_notetype_with_existing_id(notetype)?;\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn remove_notetype_inner(&mut self, ntid: NotetypeId) -> Result<()> {\n        let notetype = if let Some(notetype) = self.storage.get_notetype(ntid)? {\n            notetype\n        } else {\n            // already removed\n            return Ok(());\n        };\n\n        // remove associated cards/notes\n        let usn = self.usn()?;\n        let note_ids = self.search_notes_unordered(ntid)?;\n        self.remove_notes_inner(&note_ids, usn)?;\n\n        // remove notetype\n        self.set_schema_modified()?;\n        self.state.notetype_cache.remove(&ntid);\n        self.clear_aux_config_for_notetype(ntid)?;\n        self.remove_notetype_only_undoable(notetype)?;\n\n        // update last-used notetype\n        let all = self.storage.get_all_notetype_names()?;\n        if all.is_empty() {\n            let mut nt = all_stock_notetypes(&self.tr).remove(0);\n            self.add_notetype_inner(&mut nt, self.usn()?, true)?;\n            self.set_current_notetype_id(nt.id)\n        } else {\n            self.set_current_notetype_id(all[0].0)\n        }\n    }\n}\n\n// Tests\n//---------------------------------------\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn update_templates_after_removing_crucial_fields() {\n        // Normal Test (all front fields removed)\n        let mut nt_norm = Notetype::default();\n        nt_norm.add_field(\"baz\"); // Fields \"foo\" and \"bar\" were removed\n        nt_norm.fields[0].ord = Some(2);\n\n        nt_norm.add_template(\"Card 1\", \"front {{foo}}\", \"back {{bar}}\");\n        nt_norm.templates[0].ord = Some(0);\n        let mut parsed = nt_norm.parsed_templates();\n        let mut parsed_browser = nt_norm.parsed_browser_templates();\n\n        let mut field_map: HashMap<String, Option<String>> = HashMap::new();\n        field_map.insert(\"foo\".to_owned(), None);\n        field_map.insert(\"bar\".to_owned(), None);\n\n        nt_norm.update_templates_for_renamed_and_removed_fields(\n            field_map,\n            &mut parsed,\n            &mut parsed_browser,\n        );\n        assert_eq!(nt_norm.templates[0].config.q_format, \"front {{baz}}\");\n        assert_eq!(nt_norm.templates[0].config.a_format, \"back \");\n\n        // Cloze Test 1/2 (front and back cloze fields removed)\n        let mut nt_cloze = Notetype {\n            config: Notetype::new_cloze_config(),\n            ..Default::default()\n        };\n        nt_cloze.add_field(\"baz\"); // Fields \"foo\" and \"bar\" were removed\n        nt_cloze.fields[0].ord = Some(2);\n\n        nt_cloze.add_template(\"Card 1\", \"front {{cloze:foo}}\", \"back {{cloze:bar}}\");\n        nt_cloze.templates[0].ord = Some(0);\n        let mut parsed = nt_cloze.parsed_templates();\n\n        let mut field_map: HashMap<String, Option<String>> = HashMap::new();\n        field_map.insert(\"foo\".to_owned(), None);\n        field_map.insert(\"bar\".to_owned(), None);\n\n        nt_cloze.update_templates_for_renamed_and_removed_fields(\n            field_map,\n            &mut parsed,\n            &mut parsed_browser,\n        );\n        assert_eq!(nt_cloze.templates[0].config.q_format, \"front {{cloze:baz}}\");\n        assert_eq!(nt_cloze.templates[0].config.a_format, \"back {{cloze:baz}}\");\n\n        // Cloze Test 2/2 (only back cloze field is removed)\n        let mut nt_cloze = Notetype {\n            config: Notetype::new_cloze_config(),\n            ..Default::default()\n        };\n        nt_cloze.add_field(\"foo\");\n        nt_cloze.fields[0].ord = Some(0);\n        nt_cloze.add_field(\"baz\");\n        nt_cloze.fields[1].ord = Some(2);\n        // ^ only field \"bar\" was removed\n\n        nt_cloze.add_template(\"Card 1\", \"front {{cloze:foo}}\", \"back {{cloze:bar}}\");\n        nt_cloze.templates[0].ord = Some(0);\n        let mut parsed = nt_cloze.parsed_templates();\n\n        let mut field_map: HashMap<String, Option<String>> = HashMap::new();\n        field_map.insert(\"bar\".to_owned(), None);\n\n        nt_cloze.update_templates_for_renamed_and_removed_fields(\n            field_map,\n            &mut parsed,\n            &mut parsed_browser,\n        );\n        assert_eq!(nt_cloze.templates[0].config.q_format, \"front {{cloze:foo}}\");\n        assert_eq!(nt_cloze.templates[0].config.a_format, \"back {{cloze:foo}}\");\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/notetypechange.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Updates to notes/cards when a note is moved to a different notetype.\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse super::CardGenContext;\nuse super::Notetype;\nuse super::NotetypeKind;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::Node;\nuse crate::search::SearchNode;\nuse crate::search::TemplateKind;\nuse crate::storage::comma_separated_ids;\n\n#[derive(Debug)]\npub struct ChangeNotetypeInput {\n    pub current_schema: TimestampMillis,\n    pub note_ids: Vec<NoteId>,\n    pub old_notetype_name: String,\n    pub old_notetype_id: NotetypeId,\n    pub new_notetype_id: NotetypeId,\n    pub new_fields: Vec<Option<usize>>,\n    pub new_templates: Option<Vec<Option<usize>>>,\n}\n\n#[derive(Debug)]\npub struct NotetypeChangeInfo {\n    pub input: ChangeNotetypeInput,\n    pub old_notetype_name: String,\n    pub old_field_names: Vec<String>,\n    pub old_template_names: Vec<String>,\n    pub new_field_names: Vec<String>,\n    pub new_template_names: Vec<String>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct TemplateMap {\n    pub removed: Vec<usize>,\n    pub remapped: HashMap<usize, usize>,\n}\n\nimpl TemplateMap {\n    fn new(new_templates: Vec<Option<usize>>, old_template_count: usize) -> Self {\n        let mut seen: HashSet<usize> = HashSet::new();\n        let remapped: HashMap<_, _> = new_templates\n            .iter()\n            .enumerate()\n            .filter_map(|(new_idx, old_idx)| {\n                if let Some(old_idx) = *old_idx {\n                    seen.insert(old_idx);\n                    if old_idx != new_idx {\n                        return Some((old_idx, new_idx));\n                    }\n                }\n\n                None\n            })\n            .collect();\n\n        let removed: Vec<_> = (0..old_template_count)\n            .filter(|idx| !seen.contains(idx))\n            .collect();\n\n        TemplateMap { removed, remapped }\n    }\n}\n\nimpl Collection {\n    pub fn notetype_change_info(\n        &mut self,\n        old_notetype_id: NotetypeId,\n        new_notetype_id: NotetypeId,\n    ) -> Result<NotetypeChangeInfo> {\n        let old_notetype = self\n            .get_notetype(old_notetype_id)?\n            .or_not_found(old_notetype_id)?;\n        let new_notetype = self\n            .get_notetype(new_notetype_id)?\n            .or_not_found(new_notetype_id)?;\n\n        let current_schema = self.storage.get_collection_timestamps()?.schema_change;\n        let old_notetype_name = &old_notetype.name;\n        let new_fields = default_field_map(&old_notetype, &new_notetype);\n        let new_templates = default_template_map(&old_notetype, &new_notetype);\n        Ok(NotetypeChangeInfo {\n            input: ChangeNotetypeInput {\n                current_schema,\n                note_ids: vec![],\n                old_notetype_name: old_notetype_name.clone(),\n                old_notetype_id,\n                new_notetype_id,\n                new_fields,\n                new_templates,\n            },\n            old_notetype_name: old_notetype_name.clone(),\n            old_field_names: old_notetype.fields.iter().map(|f| f.name.clone()).collect(),\n            old_template_names: old_notetype\n                .templates\n                .iter()\n                .map(|f| f.name.clone())\n                .collect(),\n            new_field_names: new_notetype.fields.iter().map(|f| f.name.clone()).collect(),\n            new_template_names: new_notetype\n                .templates\n                .iter()\n                .map(|f| f.name.clone())\n                .collect(),\n        })\n    }\n\n    pub fn change_notetype_of_notes(&mut self, input: ChangeNotetypeInput) -> Result<OpOutput<()>> {\n        self.transact(Op::ChangeNotetype, |col| {\n            col.change_notetype_of_notes_inner(input)\n        })\n    }\n}\n\nfn default_template_map(\n    current_notetype: &Notetype,\n    new_notetype: &Notetype,\n) -> Option<Vec<Option<usize>>> {\n    if current_notetype.config.kind() == NotetypeKind::Cloze\n        || new_notetype.config.kind() == NotetypeKind::Cloze\n    {\n        // clozes can't be remapped\n        None\n    } else {\n        // name -> (ordinal, is_used)\n        let mut existing_templates: HashMap<&str, (usize, bool)> = current_notetype\n            .templates\n            .iter()\n            .map(|template| {\n                (\n                    template.name.as_str(),\n                    (template.ord.unwrap() as usize, false),\n                )\n            })\n            .collect();\n\n        // match by name\n        let mut new_templates: Vec<_> = new_notetype\n            .templates\n            .iter()\n            .map(|template| {\n                existing_templates\n                    .get_mut(template.name.as_str())\n                    .map(|(idx, used)| {\n                        *used = true;\n                        *idx\n                    })\n            })\n            .collect();\n\n        // fill in gaps with any unused templates\n        let mut remaining_templates: Vec<_> = existing_templates\n            .values()\n            .filter_map(|(idx, used)| if !used { Some(idx) } else { None })\n            .collect();\n        remaining_templates.sort_unstable();\n        new_templates\n            .iter_mut()\n            .filter(|o| o.is_none())\n            .zip(remaining_templates)\n            .for_each(|(template, old_idx)| *template = Some(*old_idx));\n\n        Some(new_templates)\n    }\n}\n\nfn default_field_map(current_notetype: &Notetype, new_notetype: &Notetype) -> Vec<Option<usize>> {\n    // name -> (ordinal, is_used)\n    let mut existing_fields: HashMap<&str, (usize, bool)> = current_notetype\n        .fields\n        .iter()\n        .map(|field| (field.name.as_str(), (field.ord.unwrap() as usize, false)))\n        .collect();\n\n    // match by name\n    let mut new_fields: Vec<_> = new_notetype\n        .fields\n        .iter()\n        .map(|field| {\n            existing_fields\n                .get_mut(field.name.as_str())\n                .map(|(idx, used)| {\n                    *used = true;\n                    *idx\n                })\n        })\n        .collect();\n\n    // fill in gaps with any unused fields\n    let mut remaining_fields: Vec<_> = existing_fields\n        .values()\n        .filter_map(|(idx, used)| if !used { Some(idx) } else { None })\n        .collect();\n    remaining_fields.sort_unstable();\n    new_fields\n        .iter_mut()\n        .filter(|o| o.is_none())\n        .zip(remaining_fields)\n        .for_each(|(field, old_idx)| *field = Some(*old_idx));\n\n    new_fields\n}\n\nimpl Collection {\n    pub(crate) fn change_notetype_of_notes_inner(\n        &mut self,\n        input: ChangeNotetypeInput,\n    ) -> Result<()> {\n        require!(\n            input.current_schema == self.storage.get_collection_timestamps()?.schema_change,\n            \"schema changed\"\n        );\n\n        let usn = self.usn()?;\n        self.set_schema_modified()?;\n        if let Some(new_templates) = input.new_templates {\n            let old_notetype = self\n                .get_notetype(input.old_notetype_id)?\n                .or_not_found(input.old_notetype_id)?;\n            self.update_cards_for_new_notetype(\n                &input.note_ids,\n                old_notetype.templates.len(),\n                new_templates,\n                usn,\n            )?;\n        } else {\n            self.maybe_remove_cards_with_missing_template(\n                &input.note_ids,\n                input.new_notetype_id,\n                usn,\n            )?;\n        }\n        self.update_notes_for_new_notetype_and_generate_cards(\n            &input.note_ids,\n            &input.new_fields,\n            input.new_notetype_id,\n            usn,\n        )?;\n\n        Ok(())\n    }\n\n    /// Rewrite notes to match new notetype, and assigns new notetype id.\n    ///\n    /// `new_fields` should be the length of the new notetype's fields, and is a\n    /// list of the previous field index each field should be mapped to. If\n    /// None, the field is left empty.\n    fn update_notes_for_new_notetype_and_generate_cards(\n        &mut self,\n        note_ids: &[NoteId],\n        new_fields: &[Option<usize>],\n        new_notetype_id: NotetypeId,\n        usn: Usn,\n    ) -> Result<()> {\n        let notetype = self\n            .get_notetype(new_notetype_id)?\n            .or_not_found(new_notetype_id)?;\n        let last_deck = self.get_last_deck_added_to_for_notetype(notetype.id);\n        let ctx = CardGenContext::new(notetype.as_ref(), last_deck, usn);\n\n        for nid in note_ids {\n            let mut note = self.storage.get_note(*nid)?.or_not_found(nid)?;\n            let original = note.clone();\n            remap_fields(note.fields_mut(), new_fields);\n            note.notetype_id = new_notetype_id;\n            self.update_note_inner_generating_cards(\n                &ctx, &mut note, &original, true, false, false,\n            )?;\n        }\n\n        Ok(())\n    }\n\n    fn update_cards_for_new_notetype(\n        &mut self,\n        note_ids: &[NoteId],\n        old_template_count: usize,\n        new_templates: Vec<Option<usize>>,\n        usn: Usn,\n    ) -> Result<()> {\n        let nids: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into();\n        let map = TemplateMap::new(new_templates, old_template_count);\n        self.remove_unmapped_cards(&map, nids.clone(), usn)?;\n        self.rewrite_remapped_cards(&map, nids, usn)?;\n\n        Ok(())\n    }\n\n    fn remove_unmapped_cards(\n        &mut self,\n        map: &TemplateMap,\n        nids: Node,\n        usn: Usn,\n    ) -> Result<(), AnkiError> {\n        if !map.removed.is_empty() {\n            let ords =\n                SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16)));\n            for card in self.all_cards_for_search(nids.and(ords))? {\n                self.remove_card_and_add_grave_undoable(card, usn)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    fn rewrite_remapped_cards(\n        &mut self,\n        map: &TemplateMap,\n        nids: Node,\n        usn: Usn,\n    ) -> Result<(), AnkiError> {\n        if !map.remapped.is_empty() {\n            let ords = SearchBuilder::any(\n                map.remapped\n                    .keys()\n                    .map(|o| TemplateKind::Ordinal(*o as u16)),\n            );\n            for mut card in self.all_cards_for_search(nids.and(ords))? {\n                let original = card.clone();\n                card.template_idx =\n                    *map.remapped.get(&(card.template_idx as usize)).unwrap() as u16;\n                self.update_card_inner(&mut card, original, usn)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// If provided notetype is a normal notetype, remove any card ordinals that\n    /// don't have a template associated with them. While recent Anki versions\n    /// should be able to handle this case, it can cause crashes on older\n    /// clients.\n    fn maybe_remove_cards_with_missing_template(\n        &mut self,\n        note_ids: &[NoteId],\n        notetype_id: NotetypeId,\n        usn: Usn,\n    ) -> Result<()> {\n        let notetype = self.get_notetype(notetype_id)?.or_not_found(notetype_id)?;\n\n        if notetype.config.kind() == NotetypeKind::Normal {\n            // cloze -> normal change requires clean up\n            for card in self\n                .storage\n                .all_cards_of_notes_above_ordinal(note_ids, notetype.templates.len() - 1)?\n            {\n                self.remove_card_and_add_grave_undoable(card, usn)?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\n/// Rewrite the field list from a note to match a new notetype's fields.\nfn remap_fields(fields: &mut Vec<String>, new_fields: &[Option<usize>]) {\n    *fields = new_fields\n        .iter()\n        .map(|field| {\n            if let Some(idx) = *field {\n                // clone required as same field can be mapped multiple times\n                fields.get(idx).map(ToString::to_string).unwrap_or_default()\n            } else {\n                String::new()\n            }\n        })\n        .collect();\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::error::Result;\n\n    #[test]\n    fn field_map() -> Result<()> {\n        let mut col = Collection::new();\n        let mut basic = col\n            .storage\n            .get_notetype(col.get_current_notetype_id().unwrap())?\n            .unwrap();\n\n        // no matching field names; fields are assigned in order\n        let cloze = col.get_notetype_by_name(\"Cloze\")?.unwrap().as_ref().clone();\n        assert_eq!(&default_field_map(&basic, &cloze), &[Some(0), Some(1)]);\n\n        basic.add_field(\"idx2\");\n        basic.add_field(\"idx3\");\n        basic.add_field(\"Text\"); // 4\n        basic.add_field(\"idx5\");\n        // re-fetch to get ordinals\n        col.update_notetype(&mut basic, false)?;\n        let basic = col.get_notetype(basic.id)?.unwrap();\n\n        // if names match, assignments are out of order; unmatched entries\n        // are filled sequentially\n        assert_eq!(&default_field_map(&basic, &cloze), &[Some(4), Some(0)]);\n\n        // unmatched entries are filled sequentially until exhausted\n        assert_eq!(\n            &default_field_map(&cloze, &basic),\n            &[\n                // front\n                Some(1),\n                // back\n                None,\n                // idx2\n                None,\n                // idx3\n                None,\n                // text\n                Some(0),\n                // idx5\n                None,\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn template_map() {\n        let new_templates = vec![None, Some(0)];\n\n        assert_eq!(\n            TemplateMap::new(new_templates.clone(), 1),\n            TemplateMap {\n                removed: vec![],\n                remapped: vec![(0, 1)].into_iter().collect()\n            }\n        );\n\n        assert_eq!(\n            TemplateMap::new(new_templates, 2),\n            TemplateMap {\n                removed: vec![1],\n                remapped: vec![(0, 1)].into_iter().collect()\n            }\n        );\n    }\n\n    #[test]\n    fn basic() -> Result<()> {\n        let mut col = Collection::new();\n        let basic = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = basic.new_note();\n        note.set_field(0, \"1\")?;\n        note.set_field(1, \"2\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        let basic2 = col\n            .get_notetype_by_name(\"Basic (and reversed card)\")?\n            .unwrap();\n\n        let first_card = col.storage.all_cards_of_note(note.id)?[0].clone();\n        assert_eq!(first_card.template_idx, 0);\n\n        // switch the existing card to ordinal 2\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            new_templates: Some(vec![None, Some(0)]),\n            ..col.notetype_change_info(basic.id, basic2.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n\n        // cards arrive in creation order, so the existing card will come first\n        let cards = col.storage.all_cards_of_note(note.id)?;\n        assert_eq!(cards[0].id, first_card.id);\n        assert_eq!(cards[0].template_idx, 1);\n\n        // a new forward card should also have been generated\n        assert_eq!(cards[1].template_idx, 0);\n        assert_ne!(cards[1].id, first_card.id);\n\n        Ok(())\n    }\n\n    #[test]\n    fn field_count_change() -> Result<()> {\n        let mut col = Collection::new();\n        let basic = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = basic.new_note();\n        note.set_field(0, \"1\")?;\n        note.set_field(1, \"2\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        let basic2 = col\n            .get_notetype_by_name(\"Basic (optional reversed card)\")?\n            .unwrap();\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            ..col.notetype_change_info(basic.id, basic2.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n\n        Ok(())\n    }\n\n    #[test]\n    fn cloze() -> Result<()> {\n        let mut col = Collection::new();\n        let basic = col\n            .get_notetype_by_name(\"Basic (and reversed card)\")?\n            .unwrap();\n        let mut note = basic.new_note();\n        note.set_field(0, \"1\")?;\n        note.set_field(1, \"2\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        let cloze = col.get_notetype_by_name(\"Cloze\")?.unwrap();\n\n        // changing to cloze should leave all the existing cards alone\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            ..col.notetype_change_info(basic.id, cloze.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n        let cards = col.storage.all_cards_of_note(note.id)?;\n        assert_eq!(cards.len(), 2);\n\n        // and back again should also work\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            ..col.notetype_change_info(cloze.id, basic.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n        let cards = col.storage.all_cards_of_note(note.id)?;\n        assert_eq!(cards.len(), 2);\n\n        // but any cards above the available templates should be removed when converting\n        // from cloze->normal\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            ..col.notetype_change_info(basic.id, cloze.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n\n        let basic1 = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let input = ChangeNotetypeInput {\n            note_ids: vec![note.id],\n            ..col.notetype_change_info(cloze.id, basic1.id)?.input\n        };\n        col.change_notetype_of_notes(input)?;\n        let cards = col.storage.all_cards_of_note(note.id)?;\n        assert_eq!(cards.len(), 1);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/render.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\n\nuse super::CardTemplate;\nuse super::Notetype;\nuse super::NotetypeKind;\nuse crate::prelude::*;\nuse crate::template::field_is_empty;\nuse crate::template::render_card;\nuse crate::template::ParsedTemplate;\nuse crate::template::RenderCardRequest;\nuse crate::template::RenderedNode;\n\n#[derive(Debug)]\npub struct RenderCardOutput {\n    pub qnodes: Vec<RenderedNode>,\n    pub anodes: Vec<RenderedNode>,\n    pub css: String,\n    pub latex_svg: bool,\n    pub is_empty: bool,\n}\n\nimpl RenderCardOutput {\n    /// The question text. This is only valid to call when partial_render=false.\n    pub fn question(&self) -> Cow<'_, str> {\n        match self.qnodes.as_slice() {\n            [RenderedNode::Text { text }] => text.into(),\n            _ => \"not fully rendered\".into(),\n        }\n    }\n\n    /// The answer text. This is only valid to call when partial_render=false.\n    pub fn answer(&self) -> Cow<'_, str> {\n        match self.anodes.as_slice() {\n            [RenderedNode::Text { text }] => text.into(),\n            _ => \"not fully rendered\".into(),\n        }\n    }\n}\n\nimpl Collection {\n    /// Render an existing card saved in the database.\n    pub fn render_existing_card(\n        &mut self,\n        cid: CardId,\n        browser: bool,\n        partial_render: bool,\n    ) -> Result<RenderCardOutput> {\n        let card = self.storage.get_card(cid)?.or_invalid(\"no such card\")?;\n        let note = self\n            .storage\n            .get_note(card.note_id)?\n            .or_invalid(\"no such note\")?;\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_invalid(\"no such notetype\")?;\n        let template = match nt.config.kind() {\n            NotetypeKind::Normal => nt.templates.get(card.template_idx as usize),\n            NotetypeKind::Cloze => nt.templates.first(),\n        }\n        .or_invalid(\"missing template\")?;\n\n        self.render_card(&note, &card, &nt, template, browser, partial_render)\n    }\n\n    /// Render a card that may not yet have been added.\n    /// The provided ordinal will be used if the template has not yet been\n    /// saved. If fill_empty is set, note will be mutated.\n    pub fn render_uncommitted_card(\n        &mut self,\n        note: &mut Note,\n        template: &CardTemplate,\n        card_ord: u16,\n        fill_empty: bool,\n        partial_render: bool,\n    ) -> Result<RenderCardOutput> {\n        let card = self.existing_or_synthesized_card(note.id, template.ord, card_ord)?;\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_invalid(\"no such notetype\")?;\n\n        if fill_empty {\n            fill_empty_fields(note, &template.config.q_format, &nt, &self.tr);\n        }\n\n        self.render_card(note, &card, &nt, template, false, partial_render)\n    }\n\n    fn existing_or_synthesized_card(\n        &self,\n        nid: NoteId,\n        template_ord: Option<u32>,\n        card_ord: u16,\n    ) -> Result<Card> {\n        // fetch existing card\n        if let Some(ord) = template_ord {\n            if let Some(card) = self.storage.get_card_by_ordinal(nid, ord as u16)? {\n                return Ok(card);\n            }\n        }\n\n        // no existing card; synthesize one\n        Ok(Card {\n            template_idx: card_ord,\n            ..Default::default()\n        })\n    }\n\n    pub fn render_card(\n        &mut self,\n        note: &Note,\n        card: &Card,\n        nt: &Notetype,\n        template: &CardTemplate,\n        browser: bool,\n        partial_render: bool,\n    ) -> Result<RenderCardOutput> {\n        let mut field_map = note.fields_map(&nt.fields);\n\n        self.add_special_fields(&mut field_map, note, card, nt, template)?;\n        // due to lifetime restrictions we need to add card number here\n        let card_num = format!(\"c{}\", card.template_idx + 1);\n        field_map.entry(&card_num).or_insert_with(|| \"1\".into());\n\n        let (qfmt, afmt) = if browser {\n            (\n                template.question_format_for_browser(),\n                template.answer_format_for_browser(),\n            )\n        } else {\n            (\n                template.config.q_format.as_str(),\n                template.config.a_format.as_str(),\n            )\n        };\n\n        let response = render_card(RenderCardRequest {\n            qfmt,\n            afmt,\n            field_map: &field_map,\n            card_ord: card.template_idx,\n            is_cloze: nt.is_cloze(),\n            browser,\n            tr: &self.tr,\n            partial_render,\n        })?;\n        Ok(RenderCardOutput {\n            qnodes: response.qnodes,\n            anodes: response.anodes,\n            css: nt.config.css.clone(),\n            latex_svg: nt.config.latex_svg,\n            is_empty: response.is_empty,\n        })\n    }\n\n    /// Add special fields if they don't clobber note fields.\n    /// The fields supported here must coincide with SPECIAL_FIELDS in\n    /// notetype/mod.rs, apart from FrontSide which is handled by Python.\n    fn add_special_fields(\n        &mut self,\n        map: &mut HashMap<&str, Cow<str>>,\n        note: &Note,\n        card: &Card,\n        nt: &Notetype,\n        template: &CardTemplate,\n    ) -> Result<()> {\n        let tags = note.tags.join(\" \");\n        map.entry(\"Tags\").or_insert_with(|| tags.into());\n        map.entry(\"Type\").or_insert_with(|| nt.name.clone().into());\n        let deck_name: Cow<str> = self\n            .get_deck(card.original_deck_id.or(card.deck_id))?\n            .map(|d| d.human_name().into())\n            .unwrap_or_else(|| \"(Deck)\".into());\n        let subdeck_name = deck_name.rsplit(\"::\").next().unwrap();\n        map.entry(\"Subdeck\")\n            .or_insert_with(|| subdeck_name.to_string().into());\n        map.entry(\"Deck\")\n            .or_insert_with(|| deck_name.to_string().into());\n        map.entry(\"CardFlag\")\n            .or_insert_with(|| flag_name(card.flags).into());\n        map.entry(\"Card\")\n            .or_insert_with(|| template.name.clone().into());\n        map.entry(\"CardID\")\n            .or_insert_with(|| card.id.to_string().into());\n\n        Ok(())\n    }\n}\n\nfn flag_name(n: u8) -> String {\n    format!(\"flag{n}\")\n}\n\nfn fill_empty_fields(note: &mut Note, qfmt: &str, nt: &Notetype, tr: &I18n) {\n    if let Ok(tmpl) = ParsedTemplate::from_text(qfmt) {\n        let cloze_fields = tmpl.all_referenced_cloze_field_names();\n\n        for (val, field) in note.fields_mut().iter_mut().zip(nt.fields.iter()) {\n            if field_is_empty(val) {\n                if cloze_fields.contains(&field.name.as_str()) {\n                    *val = tr.card_templates_sample_cloze().into();\n                } else {\n                    *val = format!(\"({})\", field.name);\n                }\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::collection::CollectionBuilder;\n    use crate::notetype::SPECIAL_FIELDS;\n\n    #[test]\n    fn can_render_fully() -> Result<()> {\n        let mut col = CollectionBuilder::default().build()?;\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = Note::new(&nt);\n        note.set_field(0, \"front\")?;\n        note.set_field(1, \"back\")?;\n        let out: RenderCardOutput =\n            col.render_uncommitted_card(&mut note, &nt.templates[0], 0, false, false)?;\n        assert_eq!(&out.question(), \"front\");\n        assert_eq!(&out.answer(), \"front\\n\\n<hr id=answer>\\n\\nback\");\n\n        // should work even if unknown filters are encountered\n        let mut tmpl = nt.templates[0].clone();\n        tmpl.config.q_format = \"{{some_filter:Front}}{{another_filter:}}\".into();\n        let out = col.render_uncommitted_card(&mut note, &nt.templates[0], 0, false, false)?;\n        assert_eq!(&out.question(), \"front\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn special_fields_complete() -> Result<()> {\n        let mut col = CollectionBuilder::default().build()?;\n        let mut map = HashMap::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let note = Note::new(&nt);\n        let card = Card::new(0.into(), 0.try_into().unwrap(), 0.into(), 0);\n        let tmpl = nt.templates[0].clone();\n\n        col.add_special_fields(&mut map, &note, &card, &nt, &tmpl)?;\n\n        assert!(map.iter().all(|val| SPECIAL_FIELDS.contains(val.0)));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/restore.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::notetypes::stock_notetype::Kind;\nuse anki_proto::notetypes::stock_notetype::OriginalStockKind;\n\nuse crate::notetype::stock::get_original_stock_notetype;\nuse crate::notetype::stock::StockKind;\nuse crate::prelude::*;\n\nimpl Collection {\n    /// If force_kind is not Unknown, it will be used in preference to the kind\n    /// stored in the notetype. If Unknown, and the kind stored in the\n    /// notetype is also Unknown, an error will be returned.\n    pub(crate) fn restore_notetype_to_stock(\n        &mut self,\n        notetype_id: NotetypeId,\n        force_kind: Option<StockKind>,\n    ) -> Result<OpOutput<()>> {\n        let mut nt = self\n            .storage\n            .get_notetype(notetype_id)?\n            .or_not_found(notetype_id)?;\n        let stock_kind = match (nt.config.original_stock_kind(), force_kind) {\n            (_, Some(force_kind)) => match force_kind {\n                Kind::Basic => OriginalStockKind::Basic,\n                Kind::BasicAndReversed => OriginalStockKind::BasicAndReversed,\n                Kind::BasicOptionalReversed => OriginalStockKind::BasicOptionalReversed,\n                Kind::BasicTyping => OriginalStockKind::BasicTyping,\n                Kind::Cloze => OriginalStockKind::Cloze,\n                Kind::ImageOcclusion => OriginalStockKind::ImageOcclusion,\n            },\n            (stock, _) => stock,\n        };\n        if stock_kind == OriginalStockKind::Unknown {\n            invalid_input!(\"unknown original notetype kind\");\n        }\n\n        let mut stock_nt = get_original_stock_notetype(stock_kind, &self.tr)?;\n        for (idx, item) in stock_nt.templates.iter_mut().enumerate() {\n            item.ord = Some(idx as u32);\n        }\n        nt.templates = stock_nt.templates;\n        for (idx, item) in stock_nt.fields.iter_mut().enumerate() {\n            item.ord = Some(idx as u32);\n        }\n        nt.fields = stock_nt.fields;\n        nt.config.css = stock_nt.config.css;\n        if force_kind.is_some() {\n            nt.config.original_stock_kind = stock_kind as i32;\n            nt.config.kind = stock_nt.config.kind;\n        }\n        self.update_notetype(&mut nt, false)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn adding_and_removing_fields_and_templates() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let note = NoteAdder::basic(&mut col)\n            .fields(&[\"front\", \"back\"])\n            .add(&mut col);\n\n        col.restore_notetype_to_stock(nt.id, Some(StockKind::BasicOptionalReversed))?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.fields(), &[\"front\", \"back\", \"\"]);\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            1\n        );\n\n        col.restore_notetype_to_stock(nt.id, Some(StockKind::BasicAndReversed))?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.fields(), &[\"front\", \"back\"]);\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            2\n        );\n\n        col.restore_notetype_to_stock(nt.id, Some(StockKind::Cloze))?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.fields(), &[\"front\", \"back\"]);\n        assert_eq!(\n            col.storage.db_scalar::<u32>(\"select count(*) from cards\")?,\n            1\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/schema11.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse phf::phf_set;\nuse phf::Set;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_json::Value;\nuse serde_repr::Deserialize_repr;\nuse serde_repr::Serialize_repr;\nuse serde_tuple::Serialize_tuple;\n\nuse super::CardRequirementKind;\nuse super::NotetypeId;\nuse crate::decks::DeckId;\nuse crate::notetype::CardRequirement;\nuse crate::notetype::CardTemplate;\nuse crate::notetype::CardTemplateConfig;\nuse crate::notetype::NoteField;\nuse crate::notetype::NoteFieldConfig;\nuse crate::notetype::Notetype;\nuse crate::notetype::NotetypeConfig;\nuse crate::serde::default_on_invalid;\nuse crate::serde::deserialize_bool_from_anything;\nuse crate::serde::deserialize_number_from_string;\nuse crate::serde::is_default;\nuse crate::timestamp::TimestampSecs;\nuse crate::types::Usn;\n\n#[derive(Serialize_repr, Deserialize_repr, PartialEq, Eq, Debug, Clone)]\n#[repr(u8)]\npub enum NotetypeKind {\n    Standard = 0,\n    Cloze = 1,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct NotetypeSchema11 {\n    #[serde(deserialize_with = \"deserialize_number_from_string\")]\n    pub(crate) id: NotetypeId,\n    pub(crate) name: String,\n    #[serde(rename = \"type\")]\n    pub(crate) kind: NotetypeKind,\n    #[serde(rename = \"mod\")]\n    pub(crate) mtime: TimestampSecs,\n    pub(crate) usn: Usn,\n    pub(crate) sortf: u16,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) did: Option<DeckId>,\n    pub(crate) tmpls: Vec<CardTemplateSchema11>,\n    pub(crate) flds: Vec<NoteFieldSchema11>,\n    #[serde(deserialize_with = \"default_on_invalid\")]\n    pub(crate) css: String,\n    #[serde(default)]\n    pub(crate) latex_pre: String,\n    #[serde(default)]\n    pub(crate) latex_post: String,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub latexsvg: bool,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) req: CardRequirementsSchema11,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub(crate) original_stock_kind: i32,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub(crate) original_id: Option<i64>,\n    #[serde(flatten)]\n    pub(crate) other: HashMap<String, Value>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default, Clone)]\npub(crate) struct CardRequirementsSchema11(pub(crate) Vec<CardRequirementSchema11>);\n\n#[derive(Serialize_tuple, Deserialize, Debug, Clone)]\npub(crate) struct CardRequirementSchema11 {\n    pub(crate) card_ord: u16,\n    pub(crate) kind: FieldRequirementKindSchema11,\n    pub(crate) field_ords: Vec<u16>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[serde(rename_all = \"lowercase\")]\npub enum FieldRequirementKindSchema11 {\n    Any,\n    All,\n    None,\n}\n\nimpl NotetypeSchema11 {\n    pub fn latex_uses_svg(&self) -> bool {\n        self.latexsvg\n    }\n}\n\nimpl From<NotetypeSchema11> for Notetype {\n    fn from(nt: NotetypeSchema11) -> Self {\n        Notetype {\n            id: nt.id,\n            name: nt.name,\n            mtime_secs: nt.mtime,\n            usn: nt.usn,\n            config: NotetypeConfig {\n                kind: nt.kind as i32,\n                sort_field_idx: nt.sortf as u32,\n                css: nt.css,\n                target_deck_id_unused: nt.did.unwrap_or(DeckId(0)).0,\n                latex_pre: nt.latex_pre,\n                latex_post: nt.latex_post,\n                latex_svg: nt.latexsvg,\n                reqs: nt.req.0.into_iter().map(Into::into).collect(),\n                original_stock_kind: nt.original_stock_kind,\n                original_id: nt.original_id,\n                other: other_to_bytes(&nt.other),\n            },\n            fields: nt.flds.into_iter().map(Into::into).collect(),\n            templates: nt.tmpls.into_iter().map(Into::into).collect(),\n        }\n    }\n}\n\nfn other_to_bytes(other: &HashMap<String, Value>) -> Vec<u8> {\n    if other.is_empty() {\n        vec![]\n    } else {\n        serde_json::to_vec(other).unwrap_or_else(|e| {\n            // theoretically should never happen\n            println!(\"serialization failed for {other:?}: {e}\");\n            vec![]\n        })\n    }\n}\n\npub(crate) fn parse_other_fields(\n    bytes: &[u8],\n    reserved: &Set<&'static str>,\n) -> HashMap<String, Value> {\n    if bytes.is_empty() {\n        Default::default()\n    } else {\n        let mut map: HashMap<String, Value> = serde_json::from_slice(bytes).unwrap_or_else(|e| {\n            println!(\"deserialization failed for other: {e}\");\n            Default::default()\n        });\n        map.retain(|k, _v| !reserved.contains(k));\n        map\n    }\n}\n\nimpl From<Notetype> for NotetypeSchema11 {\n    fn from(p: Notetype) -> Self {\n        let c = p.config;\n        NotetypeSchema11 {\n            id: p.id,\n            name: p.name,\n            kind: if c.kind == 1 {\n                NotetypeKind::Cloze\n            } else {\n                NotetypeKind::Standard\n            },\n            mtime: p.mtime_secs,\n            usn: p.usn,\n            sortf: c.sort_field_idx as u16,\n            did: if c.target_deck_id_unused == 0 {\n                None\n            } else {\n                Some(DeckId(c.target_deck_id_unused))\n            },\n            tmpls: p.templates.into_iter().map(Into::into).collect(),\n            flds: p.fields.into_iter().map(Into::into).collect(),\n            css: c.css,\n            latex_pre: c.latex_pre,\n            latex_post: c.latex_post,\n            latexsvg: c.latex_svg,\n            req: CardRequirementsSchema11(c.reqs.into_iter().map(Into::into).collect()),\n            original_stock_kind: c.original_stock_kind,\n            original_id: c.original_id,\n            other: parse_other_fields(&c.other, &RESERVED_NOTETYPE_KEYS),\n        }\n    }\n}\n\nstatic RESERVED_NOTETYPE_KEYS: Set<&'static str> = phf_set! {\n    \"latexPost\",\n    \"flds\",\n    \"css\",\n    \"originalStockKind\",\n    \"originalId\",\n    \"id\",\n    \"usn\",\n    \"mod\",\n    \"req\",\n    \"latexPre\",\n    \"name\",\n    \"did\",\n    \"tmpls\",\n    \"type\",\n    \"sortf\",\n    \"latexsvg\"\n};\n\nimpl From<CardRequirementSchema11> for CardRequirement {\n    fn from(r: CardRequirementSchema11) -> Self {\n        CardRequirement {\n            card_ord: r.card_ord as u32,\n            kind: match r.kind {\n                FieldRequirementKindSchema11::Any => CardRequirementKind::Any,\n                FieldRequirementKindSchema11::All => CardRequirementKind::All,\n                FieldRequirementKindSchema11::None => CardRequirementKind::None,\n            } as i32,\n            field_ords: r.field_ords.into_iter().map(|n| n as u32).collect(),\n        }\n    }\n}\n\nimpl From<CardRequirement> for CardRequirementSchema11 {\n    fn from(p: CardRequirement) -> Self {\n        CardRequirementSchema11 {\n            card_ord: p.card_ord as u16,\n            kind: match p.kind() {\n                CardRequirementKind::Any => FieldRequirementKindSchema11::Any,\n                CardRequirementKind::All => FieldRequirementKindSchema11::All,\n                CardRequirementKind::None => FieldRequirementKindSchema11::None,\n            },\n            field_ords: p.field_ords.into_iter().map(|n| n as u16).collect(),\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct NoteFieldSchema11 {\n    pub(crate) name: String,\n    pub(crate) ord: Option<u16>,\n    #[serde(deserialize_with = \"deserialize_bool_from_anything\")]\n    pub(crate) sticky: bool,\n    #[serde(deserialize_with = \"deserialize_bool_from_anything\")]\n    pub(crate) rtl: bool,\n    pub(crate) font: String,\n    pub(crate) size: u16,\n\n    // These were not in schema 11, but need to be listed here so that the setting is not lost\n    // on downgrade/upgrade.\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) description: String,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) plain_text: bool,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) collapsed: bool,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) exclude_from_search: bool,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) id: Option<i64>,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) tag: Option<u32>,\n\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) prevent_deletion: bool,\n\n    #[serde(flatten)]\n    pub(crate) other: HashMap<String, Value>,\n}\n\nimpl Default for NoteFieldSchema11 {\n    fn default() -> Self {\n        Self {\n            name: String::new(),\n            ord: None,\n            sticky: false,\n            rtl: false,\n            plain_text: false,\n            font: \"Arial\".to_string(),\n            size: 20,\n            description: String::new(),\n            collapsed: false,\n            exclude_from_search: false,\n            id: None,\n            tag: None,\n            prevent_deletion: false,\n            other: Default::default(),\n        }\n    }\n}\n\nimpl From<NoteFieldSchema11> for NoteField {\n    fn from(f: NoteFieldSchema11) -> Self {\n        NoteField {\n            ord: f.ord.map(|o| o as u32),\n            name: f.name,\n            config: NoteFieldConfig {\n                sticky: f.sticky,\n                rtl: f.rtl,\n                plain_text: f.plain_text,\n                font_name: f.font,\n                font_size: f.size as u32,\n                description: f.description,\n                collapsed: f.collapsed,\n                exclude_from_search: f.exclude_from_search,\n                id: f.id,\n                tag: f.tag,\n                prevent_deletion: f.prevent_deletion,\n                other: other_to_bytes(&f.other),\n            },\n        }\n    }\n}\n\nimpl From<NoteField> for NoteFieldSchema11 {\n    fn from(p: NoteField) -> Self {\n        let conf = p.config;\n        NoteFieldSchema11 {\n            name: p.name,\n            ord: p.ord.map(|o| o as u16),\n            sticky: conf.sticky,\n            rtl: conf.rtl,\n            plain_text: conf.plain_text,\n            font: conf.font_name,\n            size: conf.font_size as u16,\n            description: conf.description,\n            collapsed: conf.collapsed,\n            exclude_from_search: conf.exclude_from_search,\n            id: conf.id,\n            tag: conf.tag,\n            prevent_deletion: conf.prevent_deletion,\n            other: parse_other_fields(&conf.other, &RESERVED_FIELD_KEYS),\n        }\n    }\n}\n\nstatic RESERVED_FIELD_KEYS: Set<&'static str> = phf_set! {\n    \"name\",\n    \"ord\",\n    \"sticky\",\n    \"rtl\",\n    \"plainText\",\n    \"font\",\n    \"size\",\n    \"collapsed\",\n    \"description\",\n    \"excludeFromSearch\",\n    \"id\",\n    \"tag\",\n    \"preventDeletion\",\n};\n\n#[derive(Serialize, Deserialize, Debug, Default, Clone)]\npub struct CardTemplateSchema11 {\n    pub(crate) name: String,\n    pub(crate) ord: Option<u16>,\n    pub(crate) qfmt: String,\n    #[serde(default)]\n    pub(crate) afmt: String,\n    #[serde(default)]\n    pub(crate) bqfmt: String,\n    #[serde(default)]\n    pub(crate) bafmt: String,\n    #[serde(deserialize_with = \"default_on_invalid\", default)]\n    pub(crate) did: Option<DeckId>,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) bfont: String,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) bsize: u8,\n    #[serde(default, deserialize_with = \"default_on_invalid\")]\n    pub(crate) id: Option<i64>,\n    #[serde(flatten)]\n    pub(crate) other: HashMap<String, Value>,\n}\n\nimpl From<CardTemplateSchema11> for CardTemplate {\n    fn from(t: CardTemplateSchema11) -> Self {\n        CardTemplate {\n            ord: t.ord.map(|t| t as u32),\n            name: t.name,\n            mtime_secs: TimestampSecs(0),\n            usn: Usn(0),\n            config: CardTemplateConfig {\n                q_format: t.qfmt,\n                a_format: t.afmt,\n                q_format_browser: t.bqfmt,\n                a_format_browser: t.bafmt,\n                target_deck_id: t.did.unwrap_or(DeckId(0)).0,\n                browser_font_name: t.bfont,\n                browser_font_size: t.bsize as u32,\n                id: t.id,\n                other: other_to_bytes(&t.other),\n            },\n        }\n    }\n}\n\nimpl From<CardTemplate> for CardTemplateSchema11 {\n    fn from(p: CardTemplate) -> Self {\n        let conf = p.config;\n        CardTemplateSchema11 {\n            name: p.name,\n            ord: p.ord.map(|o| o as u16),\n            qfmt: conf.q_format,\n            afmt: conf.a_format,\n            bqfmt: conf.q_format_browser,\n            bafmt: conf.a_format_browser,\n            did: if conf.target_deck_id > 0 {\n                Some(DeckId(conf.target_deck_id))\n            } else {\n                None\n            },\n            bfont: conf.browser_font_name,\n            bsize: conf.browser_font_size as u8,\n            id: conf.id,\n            other: parse_other_fields(&conf.other, &RESERVED_TEMPLATE_KEYS),\n        }\n    }\n}\n\nstatic RESERVED_TEMPLATE_KEYS: Set<&'static str> = phf_set! {\n    \"name\",\n    \"ord\",\n    \"did\",\n    \"afmt\",\n    \"bafmt\",\n    \"qfmt\",\n    \"bqfmt\",\n    \"bfont\",\n    \"bsize\",\n    \"id\",\n};\n\n#[cfg(test)]\nmod tests {\n    use itertools::Itertools;\n\n    use super::*;\n    use crate::notetype::stock::basic;\n    use crate::prelude::*;\n\n    #[test]\n    fn all_reserved_fields_are_removed() -> Result<()> {\n        let mut nt = basic(&I18n::template_only());\n\n        let key_source = NotetypeSchema11::from(nt.clone());\n        nt.config.other = serde_json::to_vec(&key_source)?;\n        nt.fields[0].config.other = serde_json::to_vec(&key_source.flds[0])?;\n        nt.templates[0].config.other = serde_json::to_vec(&key_source.tmpls[0])?;\n        let s11 = NotetypeSchema11::from(nt);\n\n        let empty: &[&String] = &[];\n        assert_eq!(&s11.other.keys().collect_vec(), empty);\n        assert_eq!(&s11.flds[0].other.keys().collect_vec(), empty);\n        assert_eq!(&s11.tmpls[0].other.keys().collect_vec(), empty);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/schemachange.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Updates to notes/cards when the structure of a notetype is changed.\n\nuse std::collections::HashMap;\nuse std::mem;\n\nuse super::CardGenContext;\nuse super::CardTemplate;\nuse super::Notetype;\nuse crate::notes::UpdateNoteInnerWithoutCardsArgs;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::TemplateKind;\n\n/// True if any ordinals added, removed or reordered.\nfn ords_changed(ords: &[Option<u32>], previous_len: usize) -> bool {\n    ords.len() != previous_len\n        || ords\n            .iter()\n            .enumerate()\n            .any(|(idx, &ord)| ord != Some(idx as u32))\n}\n\n#[derive(Default, PartialEq, Eq, Debug)]\nstruct TemplateOrdChanges {\n    added: Vec<u32>,\n    removed: Vec<u16>,\n    // map of old->new\n    moved: HashMap<u16, u16>,\n}\n\nimpl TemplateOrdChanges {\n    fn new(ords: Vec<Option<u32>>, previous_len: u32) -> Self {\n        let mut changes = TemplateOrdChanges::default();\n        let mut removed: Vec<_> = (0..previous_len).map(|v| Some(v as u16)).collect();\n        for (idx, old_ord) in ords.into_iter().enumerate() {\n            if let Some(old_ord) = old_ord {\n                if let Some(entry) = removed.get_mut(old_ord as usize) {\n                    // guard required to ensure we don't panic if invalid high ordinal received\n                    *entry = None;\n                }\n                if old_ord == idx as u32 {\n                    // no action\n                } else {\n                    changes.moved.insert(old_ord as u16, idx as u16);\n                }\n            } else {\n                changes.added.push(idx as u32);\n            }\n        }\n\n        changes.removed = removed.into_iter().flatten().collect();\n\n        changes\n    }\n\n    fn is_empty(&self) -> bool {\n        *self == Self::default()\n    }\n}\n\nimpl Collection {\n    /// Rewrite notes to match the updated field schema.\n    /// Caller must create transaction.\n    pub(crate) fn update_notes_for_changed_fields(\n        &mut self,\n        nt: &Notetype,\n        previous_field_count: usize,\n        previous_sort_idx: u32,\n        normalize_text: bool,\n    ) -> Result<()> {\n        let usn = self.usn()?;\n        let ords: Vec<_> = nt.fields.iter().map(|f| f.ord).collect();\n        if !ords_changed(&ords, previous_field_count) {\n            if nt.config.sort_field_idx != previous_sort_idx {\n                // only need to update sort field\n                self.set_schema_modified()?;\n                let nids = self.search_notes_unordered(nt.id)?;\n                for nid in nids {\n                    let mut note = self.storage.get_note(nid)?.unwrap();\n                    let original = note.clone();\n                    self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs {\n                        note: &mut note,\n                        original: &original,\n                        notetype: nt,\n                        usn,\n                        mark_note_modified: true,\n                        normalize_text,\n                        update_tags: false,\n                    })?;\n                }\n            } else {\n                // nothing to do\n            }\n            return Ok(());\n        }\n\n        // fields have changed\n        self.set_schema_modified()?;\n        let nids = self.search_notes_unordered(nt.id)?;\n        let usn = self.usn()?;\n        for nid in nids {\n            let mut note = self.storage.get_note(nid)?.unwrap();\n            let original = note.clone();\n            note.reorder_fields(&ords);\n            self.update_note_inner_without_cards(UpdateNoteInnerWithoutCardsArgs {\n                note: &mut note,\n                original: &original,\n                notetype: nt,\n                usn,\n                mark_note_modified: true,\n                normalize_text,\n                update_tags: false,\n            })?;\n        }\n        Ok(())\n    }\n\n    /// Update cards after card templates added, removed or reordered.\n    /// Does not remove cards where the template still exists but creates an\n    /// empty card. Caller must create transaction.\n    pub(crate) fn update_cards_for_changed_templates(\n        &mut self,\n        nt: &Notetype,\n        old_templates: &[CardTemplate],\n    ) -> Result<()> {\n        let usn = self.usn()?;\n        let ords: Vec<_> = nt.templates.iter().map(|f| f.ord).collect();\n        let changes = TemplateOrdChanges::new(ords, old_templates.len() as u32);\n\n        if !changes.is_empty() {\n            self.set_schema_modified()?;\n        }\n\n        // remove any cards where the template was deleted\n        if !changes.removed.is_empty() {\n            let ords =\n                SearchBuilder::any(changes.removed.iter().cloned().map(TemplateKind::Ordinal));\n            for card in self.all_cards_for_search(nt.id.and(ords))? {\n                self.remove_card_and_add_grave_undoable(card, usn)?;\n            }\n        }\n        // update ordinals for cards with a repositioned template\n        if !changes.moved.is_empty() {\n            let ords = SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal));\n            for mut card in self.all_cards_for_search(nt.id.and(ords))? {\n                let original = card.clone();\n                card.template_idx = *changes.moved.get(&card.template_idx).unwrap();\n                self.update_card_inner(&mut card, original, usn)?;\n            }\n        }\n\n        if should_generate_cards(&changes, nt, old_templates) {\n            let last_deck = self.get_last_deck_added_to_for_notetype(nt.id);\n            let ctx = CardGenContext::new(nt, last_deck, usn);\n            self.generate_cards_for_notetype(&ctx)?;\n        }\n\n        Ok(())\n    }\n}\n\nfn should_generate_cards(\n    changes: &TemplateOrdChanges,\n    nt: &Notetype,\n    old_templates: &[CardTemplate],\n) -> bool {\n    // must regenerate if any front side has changed, but also in the (unlikely)\n    // case that a template has been replaced by one with an identical front\n    !(changes.added.is_empty() && nt.template_fronts_are_identical(old_templates))\n}\n\nimpl Notetype {\n    fn template_fronts_are_identical(&self, other_templates: &[CardTemplate]) -> bool {\n        self.templates\n            .iter()\n            .map(|t| &t.config.q_format)\n            .eq(other_templates.iter().map(|t| &t.config.q_format))\n    }\n}\n\nimpl Note {\n    pub(crate) fn reorder_fields(&mut self, new_ords: &[Option<u32>]) {\n        *self.fields_mut() = new_ords\n            .iter()\n            .map(|ord| {\n                ord.and_then(|idx| self.fields_mut().get_mut(idx as usize))\n                    .map(mem::take)\n                    .unwrap_or_default()\n            })\n            .collect();\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::search::SortMode;\n\n    #[test]\n    fn ord_changes() {\n        assert!(!ords_changed(&[Some(0), Some(1)], 2));\n        assert!(ords_changed(&[Some(0), Some(1)], 1));\n        assert!(ords_changed(&[Some(1), Some(0)], 2));\n        assert!(ords_changed(&[None, Some(1)], 2));\n        assert!(ords_changed(&[Some(0), Some(1), None], 2));\n    }\n\n    #[test]\n    fn template_changes() {\n        assert_eq!(\n            TemplateOrdChanges::new(vec![Some(0), Some(1)], 2),\n            TemplateOrdChanges::default(),\n        );\n        assert_eq!(\n            TemplateOrdChanges::new(vec![Some(0), Some(1)], 3),\n            TemplateOrdChanges {\n                removed: vec![2],\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            TemplateOrdChanges::new(vec![Some(1)], 2),\n            TemplateOrdChanges {\n                removed: vec![0],\n                moved: vec![(1, 0)].into_iter().collect(),\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            TemplateOrdChanges::new(vec![Some(0), None], 1),\n            TemplateOrdChanges {\n                added: vec![1],\n                ..Default::default()\n            }\n        );\n        assert_eq!(\n            TemplateOrdChanges::new(vec![Some(2), None, Some(0)], 2),\n            TemplateOrdChanges {\n                added: vec![1],\n                moved: vec![(2, 0), (0, 2)].into_iter().collect(),\n                removed: vec![1],\n            }\n        );\n        assert_eq!(\n            TemplateOrdChanges::new(vec![None, Some(2), None, Some(4)], 5),\n            TemplateOrdChanges {\n                added: vec![0, 2],\n                moved: vec![(2, 1), (4, 3)].into_iter().collect(),\n                removed: vec![0, 1, 3],\n            }\n        );\n    }\n\n    #[test]\n    fn fields() -> Result<()> {\n        let mut col = Collection::new();\n        let mut nt = col\n            .storage\n            .get_notetype(col.get_current_notetype_id().unwrap())?\n            .unwrap();\n        let mut note = nt.new_note();\n        assert_eq!(note.fields().len(), 2);\n        note.set_field(0, \"one\")?;\n        note.set_field(1, \"two\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        nt.add_field(\"three\");\n        col.update_notetype(&mut nt, false)?;\n\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.fields(), &[\"one\".to_string(), \"two\".into(), \"\".into()]);\n\n        nt.fields.remove(1);\n        col.update_notetype(&mut nt, false)?;\n\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.fields(), &[\"one\".to_string(), \"\".into()]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn field_renaming_and_deleting() -> Result<()> {\n        let mut col = Collection::new();\n        let mut nt = col\n            .storage\n            .get_notetype(col.get_current_notetype_id().unwrap())?\n            .unwrap();\n        nt.templates[0].config.q_format += \"\\n{{#Front}}{{some:Front}}{{Back}}{{/Front}}\";\n        nt.fields[0].name = \"Test\".into();\n        col.update_notetype(&mut nt, false)?;\n        assert_eq!(\n            &nt.templates[0].config.q_format,\n            \"{{Test}}\\n{{#Test}}{{some:Test}}{{Back}}{{/Test}}\"\n        );\n        nt.fields.remove(0);\n        col.update_notetype(&mut nt, false)?;\n        assert_eq!(&nt.templates[0].config.q_format, \"\\n{{Back}}\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn cards() -> Result<()> {\n        let mut col = Collection::new();\n        let mut nt = col\n            .storage\n            .get_notetype(col.get_current_notetype_id().unwrap())?\n            .unwrap();\n        let mut note = nt.new_note();\n        assert_eq!(note.fields().len(), 2);\n        note.set_field(0, \"one\")?;\n        note.set_field(1, \"two\")?;\n        col.add_note(&mut note, DeckId(1))?;\n\n        assert_eq!(\n            col.search_cards(note.id, SortMode::NoOrder).unwrap().len(),\n            1\n        );\n\n        // add an extra card template\n        nt.add_template(\"card 2\", \"{{Front}}2\", \"\");\n        col.update_notetype(&mut nt, false)?;\n\n        assert_eq!(\n            col.search_cards(note.id, SortMode::NoOrder).unwrap().len(),\n            2\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::generic;\nuse anki_proto::notetypes::stock_notetype::Kind as StockKind;\n\nuse crate::collection::Collection;\nuse crate::config::get_aux_notetype_config_key;\nuse crate::error;\nuse crate::error::OrInvalid;\nuse crate::error::OrNotFound;\nuse crate::notes::NoteId;\nuse crate::notetype::stock::get_stock_notetype;\nuse crate::notetype::ChangeNotetypeInput;\nuse crate::notetype::Notetype;\nuse crate::notetype::NotetypeChangeInfo;\nuse crate::notetype::NotetypeId;\nuse crate::notetype::NotetypeSchema11;\nuse crate::prelude::IntoNewtypeVec;\n\nimpl crate::services::NotetypesService for Collection {\n    fn add_notetype(\n        &mut self,\n        input: anki_proto::notetypes::Notetype,\n    ) -> error::Result<anki_proto::collection::OpChangesWithId> {\n        let mut notetype: Notetype = input.into();\n\n        Ok(self\n            .add_notetype(&mut notetype, false)?\n            .map(|_| notetype.id.0)\n            .into())\n    }\n\n    fn update_notetype(\n        &mut self,\n        input: anki_proto::notetypes::Notetype,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let mut notetype: Notetype = input.into();\n        self.update_notetype(&mut notetype, false).map(Into::into)\n    }\n\n    fn add_notetype_legacy(\n        &mut self,\n        input: generic::Json,\n    ) -> error::Result<anki_proto::collection::OpChangesWithId> {\n        let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?;\n        let mut notetype: Notetype = legacy.into();\n\n        Ok(self\n            .add_notetype(&mut notetype, false)?\n            .map(|_| notetype.id.0)\n            .into())\n    }\n\n    fn update_notetype_legacy(\n        &mut self,\n        input: anki_proto::notetypes::UpdateNotetypeLegacyRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?;\n        let mut notetype: Notetype = legacy.into();\n        self.update_notetype(&mut notetype, input.skip_checks)\n            .map(Into::into)\n    }\n\n    fn add_or_update_notetype(\n        &mut self,\n        input: anki_proto::notetypes::AddOrUpdateNotetypeRequest,\n    ) -> error::Result<anki_proto::notetypes::NotetypeId> {\n        let legacy: NotetypeSchema11 = serde_json::from_slice(&input.json)?;\n        let mut nt: Notetype = legacy.into();\n        if !input.preserve_usn_and_mtime {\n            nt.set_modified(self.usn()?);\n        }\n        if nt.id.0 == 0 {\n            self.add_notetype(&mut nt, input.skip_checks)?;\n        } else if !input.preserve_usn_and_mtime {\n            self.update_notetype(&mut nt, input.skip_checks)?;\n        } else {\n            self.add_or_update_notetype_with_existing_id(&mut nt, input.skip_checks)?;\n        }\n        Ok(anki_proto::notetypes::NotetypeId { ntid: nt.id.0 })\n    }\n\n    fn get_stock_notetype_legacy(\n        &mut self,\n        input: anki_proto::notetypes::StockNotetype,\n    ) -> error::Result<generic::Json> {\n        let nt = get_stock_notetype(input.kind(), &self.tr);\n        let schema11: NotetypeSchema11 = nt.into();\n        serde_json::to_vec(&schema11)\n            .map_err(Into::into)\n            .map(Into::into)\n    }\n\n    fn get_notetype(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<anki_proto::notetypes::Notetype> {\n        let ntid = input.into();\n\n        self.storage\n            .get_notetype(ntid)?\n            .or_not_found(ntid)\n            .map(Into::into)\n    }\n\n    fn get_notetype_legacy(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<generic::Json> {\n        let ntid = input.into();\n\n        let schema11: NotetypeSchema11 =\n            self.storage.get_notetype(ntid)?.or_not_found(ntid)?.into();\n        Ok(serde_json::to_vec(&schema11)?.into())\n    }\n\n    fn get_notetype_names(&mut self) -> error::Result<anki_proto::notetypes::NotetypeNames> {\n        let entries: Vec<_> = self\n            .storage\n            .get_all_notetype_names()?\n            .into_iter()\n            .map(|(id, name)| anki_proto::notetypes::NotetypeNameId { id: id.0, name })\n            .collect();\n        Ok(anki_proto::notetypes::NotetypeNames { entries })\n    }\n\n    fn get_notetype_names_and_counts(\n        &mut self,\n    ) -> error::Result<anki_proto::notetypes::NotetypeUseCounts> {\n        let entries: Vec<_> = self\n            .storage\n            .get_notetype_use_counts()?\n            .into_iter()\n            .map(\n                |(id, name, use_count)| anki_proto::notetypes::NotetypeNameIdUseCount {\n                    id: id.0,\n                    name,\n                    use_count,\n                },\n            )\n            .collect();\n        Ok(anki_proto::notetypes::NotetypeUseCounts { entries })\n    }\n\n    fn get_notetype_id_by_name(\n        &mut self,\n        input: generic::String,\n    ) -> error::Result<anki_proto::notetypes::NotetypeId> {\n        self.storage\n            .get_notetype_id(&input.val)\n            .and_then(|nt| nt.or_not_found(input.val))\n            .map(|ntid| anki_proto::notetypes::NotetypeId { ntid: ntid.0 })\n    }\n\n    fn remove_notetype(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.remove_notetype(input.into()).map(Into::into)\n    }\n\n    fn get_aux_notetype_config_key(\n        &mut self,\n        input: anki_proto::notetypes::GetAuxConfigKeyRequest,\n    ) -> error::Result<generic::String> {\n        Ok(get_aux_notetype_config_key(input.id.into(), &input.key).into())\n    }\n\n    fn get_aux_template_config_key(\n        &mut self,\n        input: anki_proto::notetypes::GetAuxTemplateConfigKeyRequest,\n    ) -> error::Result<generic::String> {\n        self.get_aux_template_config_key(\n            input.notetype_id.into(),\n            input.card_ordinal as usize,\n            &input.key,\n        )\n        .map(Into::into)\n    }\n\n    fn get_change_notetype_info(\n        &mut self,\n        input: anki_proto::notetypes::GetChangeNotetypeInfoRequest,\n    ) -> error::Result<anki_proto::notetypes::ChangeNotetypeInfo> {\n        self.notetype_change_info(input.old_notetype_id.into(), input.new_notetype_id.into())\n            .map(Into::into)\n    }\n\n    fn change_notetype(\n        &mut self,\n        input: anki_proto::notetypes::ChangeNotetypeRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.change_notetype_of_notes(input.into()).map(Into::into)\n    }\n\n    fn get_field_names(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<generic::StringList> {\n        self.storage.get_field_names(input.into()).map(Into::into)\n    }\n\n    fn restore_notetype_to_stock(\n        &mut self,\n        input: anki_proto::notetypes::RestoreNotetypeToStockRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        let force_kind = input.force_kind.and_then(|s| StockKind::try_from(s).ok());\n\n        self.restore_notetype_to_stock(\n            input.notetype_id.or_invalid(\"missing notetype id\")?.into(),\n            force_kind,\n        )\n        .map(Into::into)\n    }\n\n    fn get_cloze_field_ords(\n        &mut self,\n        input: anki_proto::notetypes::NotetypeId,\n    ) -> error::Result<anki_proto::notetypes::GetClozeFieldOrdsResponse> {\n        Ok(anki_proto::notetypes::GetClozeFieldOrdsResponse {\n            ords: self\n                .get_notetype(input.into())?\n                .unwrap()\n                .cloze_fields()\n                .iter()\n                .map(|ord| (*ord) as u32)\n                .collect(),\n        })\n    }\n}\n\nimpl From<anki_proto::notetypes::Notetype> for Notetype {\n    fn from(n: anki_proto::notetypes::Notetype) -> Self {\n        Notetype {\n            id: n.id.into(),\n            name: n.name,\n            mtime_secs: n.mtime_secs.into(),\n            usn: n.usn.into(),\n            fields: n.fields.into_iter().map(Into::into).collect(),\n            templates: n.templates.into_iter().map(Into::into).collect(),\n            config: n.config.unwrap_or_default(),\n        }\n    }\n}\n\nimpl From<NotetypeChangeInfo> for anki_proto::notetypes::ChangeNotetypeInfo {\n    fn from(i: NotetypeChangeInfo) -> Self {\n        anki_proto::notetypes::ChangeNotetypeInfo {\n            old_notetype_name: i.old_notetype_name,\n            old_field_names: i.old_field_names,\n            old_template_names: i.old_template_names,\n            new_field_names: i.new_field_names,\n            new_template_names: i.new_template_names,\n            input: Some(i.input.into()),\n        }\n    }\n}\n\nimpl From<anki_proto::notetypes::ChangeNotetypeRequest> for ChangeNotetypeInput {\n    fn from(i: anki_proto::notetypes::ChangeNotetypeRequest) -> Self {\n        ChangeNotetypeInput {\n            current_schema: i.current_schema.into(),\n            note_ids: i.note_ids.into_newtype(NoteId),\n            old_notetype_name: i.old_notetype_name,\n            old_notetype_id: i.old_notetype_id.into(),\n            new_notetype_id: i.new_notetype_id.into(),\n            new_fields: i\n                .new_fields\n                .into_iter()\n                .map(|v| if v == -1 { None } else { Some(v as usize) })\n                .collect(),\n            new_templates: {\n                let v: Vec<_> = i\n                    .new_templates\n                    .into_iter()\n                    .map(|v| if v == -1 { None } else { Some(v as usize) })\n                    .collect();\n                if v.is_empty() {\n                    None\n                } else {\n                    Some(v)\n                }\n            },\n        }\n    }\n}\n\nimpl From<ChangeNotetypeInput> for anki_proto::notetypes::ChangeNotetypeRequest {\n    fn from(i: ChangeNotetypeInput) -> Self {\n        anki_proto::notetypes::ChangeNotetypeRequest {\n            current_schema: i.current_schema.into(),\n            note_ids: i.note_ids.into_iter().map(Into::into).collect(),\n            old_notetype_name: i.old_notetype_name,\n            old_notetype_id: i.old_notetype_id.into(),\n            new_notetype_id: i.new_notetype_id.into(),\n            new_fields: i\n                .new_fields\n                .into_iter()\n                .map(|idx| idx.map(|v| v as i32).unwrap_or(-1))\n                .collect(),\n            is_cloze: i.new_templates.is_none(),\n            new_templates: i\n                .new_templates\n                .unwrap_or_default()\n                .into_iter()\n                .map(|idx| idx.map(|v| v as i32).unwrap_or(-1))\n                .collect(),\n        }\n    }\n}\n\nimpl From<anki_proto::notetypes::NotetypeId> for NotetypeId {\n    fn from(ntid: anki_proto::notetypes::NotetypeId) -> Self {\n        NotetypeId(ntid.ntid)\n    }\n}\n\nimpl From<NotetypeId> for anki_proto::notetypes::NotetypeId {\n    fn from(ntid: NotetypeId) -> Self {\n        anki_proto::notetypes::NotetypeId { ntid: ntid.0 }\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/stock.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_i18n::I18n;\nuse anki_proto::notetypes::notetype::config::Kind as NotetypeKind;\nuse anki_proto::notetypes::stock_notetype::Kind;\npub(crate) use anki_proto::notetypes::stock_notetype::Kind as StockKind;\nuse anki_proto::notetypes::stock_notetype::OriginalStockKind;\nuse anki_proto::notetypes::ClozeField;\n\nuse super::NotetypeConfig;\nuse crate::config::ConfigEntry;\nuse crate::config::ConfigKey;\nuse crate::error::Result;\nuse crate::image_occlusion::notetype::image_occlusion_notetype;\nuse crate::invalid_input;\nuse crate::notetype::Notetype;\nuse crate::storage::SqliteStorage;\nuse crate::timestamp::TimestampSecs;\n\nimpl SqliteStorage {\n    pub(crate) fn add_stock_notetypes(&self, tr: &I18n) -> Result<()> {\n        for (idx, mut nt) in all_stock_notetypes(tr).into_iter().enumerate() {\n            nt.prepare_for_update(None, true)?;\n            self.add_notetype(&mut nt)?;\n            if idx == 0 {\n                self.set_config_entry(&ConfigEntry::boxed(\n                    ConfigKey::CurrentNotetypeId.into(),\n                    serde_json::to_vec(&nt.id)?,\n                    self.usn(false)?,\n                    TimestampSecs::now(),\n                ))?;\n            }\n        }\n        Ok(())\n    }\n}\n\n// If changing this, make sure to update StockNotetype enum. Other parts of the\n// code expect the order here to be the same as the enum.\npub fn all_stock_notetypes(tr: &I18n) -> Vec<Notetype> {\n    vec![\n        basic(tr),\n        basic_forward_reverse(tr),\n        basic_optional_reverse(tr),\n        basic_typing(tr),\n        cloze(tr),\n        image_occlusion_notetype(tr),\n    ]\n}\n\n/// returns {{name}}\nfn fieldref<S: AsRef<str>>(name: S) -> String {\n    format!(\"{{{{{}}}}}\", name.as_ref())\n}\n\n/// Create an empty notetype with a given name and stock kind.\npub(crate) fn empty_stock(\n    nt_kind: NotetypeKind,\n    original_stock_kind: OriginalStockKind,\n    name: impl Into<String>,\n) -> Notetype {\n    Notetype {\n        name: name.into(),\n        config: NotetypeConfig {\n            kind: nt_kind as i32,\n            original_stock_kind: original_stock_kind as i32,\n            ..if nt_kind == NotetypeKind::Cloze {\n                Notetype::new_cloze_config()\n            } else {\n                Notetype::new_config()\n            }\n        },\n        ..Default::default()\n    }\n}\n\npub(crate) fn get_stock_notetype(kind: StockKind, tr: &I18n) -> Notetype {\n    match kind {\n        Kind::Basic => basic(tr),\n        Kind::BasicAndReversed => basic_forward_reverse(tr),\n        Kind::BasicOptionalReversed => basic_optional_reverse(tr),\n        Kind::BasicTyping => basic_typing(tr),\n        Kind::Cloze => cloze(tr),\n        Kind::ImageOcclusion => image_occlusion_notetype(tr),\n    }\n}\n\npub(crate) fn get_original_stock_notetype(kind: OriginalStockKind, tr: &I18n) -> Result<Notetype> {\n    Ok(match kind {\n        OriginalStockKind::Unknown => invalid_input!(\"original stock kind not provided\"),\n        OriginalStockKind::Basic => basic(tr),\n        OriginalStockKind::BasicAndReversed => basic_forward_reverse(tr),\n        OriginalStockKind::BasicOptionalReversed => basic_optional_reverse(tr),\n        OriginalStockKind::BasicTyping => basic_typing(tr),\n        OriginalStockKind::Cloze => cloze(tr),\n        OriginalStockKind::ImageOcclusion => image_occlusion_notetype(tr),\n    })\n}\n\npub(crate) fn basic(tr: &I18n) -> Notetype {\n    let mut nt = empty_stock(\n        NotetypeKind::Normal,\n        OriginalStockKind::Basic,\n        tr.notetypes_basic_name(),\n    );\n    let front = tr.notetypes_front_field();\n    let back = tr.notetypes_back_field();\n    nt.add_field(front.as_ref());\n    nt.add_field(back.as_ref());\n    nt.add_template(\n        tr.notetypes_card_1_name(),\n        fieldref(front),\n        format!(\n            \"{}\\n\\n<hr id=answer>\\n\\n{}\",\n            fieldref(\"FrontSide\"),\n            fieldref(back),\n        ),\n    );\n    nt\n}\n\npub(crate) fn basic_typing(tr: &I18n) -> Notetype {\n    let mut nt = basic(tr);\n    nt.config.original_stock_kind = OriginalStockKind::BasicTyping as i32;\n    nt.name = tr.notetypes_basic_type_answer_name().into();\n    let front = tr.notetypes_front_field();\n    let back = tr.notetypes_back_field();\n    let tmpl = &mut nt.templates[0].config;\n    tmpl.q_format = format!(\"{}\\n\\n{{{{type:{}}}}}\", fieldref(front.as_ref()), back);\n    tmpl.a_format = format!(\n        \"{}\\n\\n<hr id=answer>\\n\\n{{{{type:{}}}}}\",\n        fieldref(front),\n        back\n    );\n    nt\n}\n\npub(crate) fn basic_forward_reverse(tr: &I18n) -> Notetype {\n    let mut nt = basic(tr);\n    nt.config.original_stock_kind = OriginalStockKind::BasicAndReversed as i32;\n    nt.name = tr.notetypes_basic_reversed_name().into();\n    let front = tr.notetypes_front_field();\n    let back = tr.notetypes_back_field();\n    nt.add_template(\n        tr.notetypes_card_2_name(),\n        fieldref(back),\n        format!(\n            \"{}\\n\\n<hr id=answer>\\n\\n{}\",\n            fieldref(\"FrontSide\"),\n            fieldref(front),\n        ),\n    );\n    nt\n}\n\npub(crate) fn basic_optional_reverse(tr: &I18n) -> Notetype {\n    let mut nt = basic_forward_reverse(tr);\n    nt.config.original_stock_kind = OriginalStockKind::BasicOptionalReversed as i32;\n    nt.name = tr.notetypes_basic_optional_reversed_name().into();\n    let addrev = tr.notetypes_add_reverse_field();\n    nt.add_field(addrev.as_ref());\n    let tmpl = &mut nt.templates[1].config;\n    tmpl.q_format = format!(\"{{{{#{}}}}}{}{{{{/{}}}}}\", addrev, tmpl.q_format, addrev);\n    nt\n}\n\npub(crate) fn cloze(tr: &I18n) -> Notetype {\n    let mut nt = empty_stock(\n        NotetypeKind::Cloze,\n        OriginalStockKind::Cloze,\n        tr.notetypes_cloze_name(),\n    );\n    let text = tr.notetypes_text_field();\n    let mut config = nt.add_field(text.as_ref());\n    config.tag = Some(ClozeField::Text as u32);\n    config.prevent_deletion = true;\n\n    let back_extra = tr.notetypes_back_extra_field();\n    config = nt.add_field(back_extra.as_ref());\n    config.tag = Some(ClozeField::BackExtra as u32);\n    let qfmt = format!(\"{{{{cloze:{text}}}}}\");\n    let afmt = format!(\"{qfmt}<br>\\n{{{{{back_extra}}}}}\");\n    nt.add_template(nt.name.clone(), qfmt, afmt);\n    nt\n}\n"
  },
  {
    "path": "rslib/src/notetype/styling.css",
    "content": ".card {\n    font-family: arial;\n    font-size: 20px;\n    line-height: 1.5;\n    text-align: center;\n    color: black;\n    background-color: white;\n}\n"
  },
  {
    "path": "rslib/src/notetype/templates.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardTemplateConfig;\nuse super::CardTemplateProto;\nuse crate::prelude::*;\nuse crate::template::ParsedTemplate;\n\n#[derive(Debug, PartialEq, Clone)]\npub struct CardTemplate {\n    pub ord: Option<u32>,\n    pub mtime_secs: TimestampSecs,\n    pub usn: Usn,\n    pub name: String,\n    pub config: CardTemplateConfig,\n}\n\nimpl CardTemplate {\n    pub(crate) fn parsed_question(&self) -> Option<ParsedTemplate> {\n        ParsedTemplate::from_text(&self.config.q_format).ok()\n    }\n\n    pub(crate) fn parsed_answer(&self) -> Option<ParsedTemplate> {\n        ParsedTemplate::from_text(&self.config.a_format).ok()\n    }\n\n    pub(crate) fn parsed_question_format_for_browser(&self) -> Option<ParsedTemplate> {\n        ParsedTemplate::from_text(&self.config.q_format_browser).ok()\n    }\n\n    pub(crate) fn parsed_answer_format_for_browser(&self) -> Option<ParsedTemplate> {\n        ParsedTemplate::from_text(&self.config.a_format_browser).ok()\n    }\n    pub(crate) fn question_format_for_browser(&self) -> &str {\n        if !self.config.q_format_browser.is_empty() {\n            &self.config.q_format_browser\n        } else {\n            &self.config.q_format\n        }\n    }\n\n    pub(crate) fn answer_format_for_browser(&self) -> &str {\n        if !self.config.a_format_browser.is_empty() {\n            &self.config.a_format_browser\n        } else {\n            &self.config.a_format\n        }\n    }\n\n    pub(crate) fn target_deck_id(&self) -> Option<DeckId> {\n        if self.config.target_deck_id > 0 {\n            Some(DeckId(self.config.target_deck_id))\n        } else {\n            None\n        }\n    }\n}\n\nimpl From<CardTemplate> for CardTemplateProto {\n    fn from(t: CardTemplate) -> Self {\n        CardTemplateProto {\n            ord: t.ord.map(Into::into),\n            mtime_secs: t.mtime_secs.0,\n            usn: t.usn.0,\n            name: t.name,\n            config: Some(t.config),\n        }\n    }\n}\n\nimpl From<CardTemplateProto> for CardTemplate {\n    fn from(t: CardTemplateProto) -> Self {\n        CardTemplate {\n            ord: t.ord.map(|n| n.val),\n            mtime_secs: t.mtime_secs.into(),\n            usn: t.usn.into(),\n            name: t.name,\n            config: t.config.unwrap_or_default(),\n        }\n    }\n}\n\nimpl CardTemplate {\n    pub fn new<S1, S2, S3>(name: S1, qfmt: S2, afmt: S3) -> Self\n    where\n        S1: Into<String>,\n        S2: Into<String>,\n        S3: Into<String>,\n    {\n        CardTemplate {\n            ord: None,\n            name: name.into(),\n            mtime_secs: TimestampSecs(0),\n            usn: Usn(0),\n            config: CardTemplateConfig {\n                id: Some(rand::random()),\n                q_format: qfmt.into(),\n                a_format: afmt.into(),\n                q_format_browser: \"\".into(),\n                a_format_browser: \"\".into(),\n                target_deck_id: 0,\n                browser_font_name: \"\".into(),\n                browser_font_size: 0,\n                other: vec![],\n            },\n        }\n    }\n\n    /// Return whether the name is valid. Remove quote characters if it leads to\n    /// a valid name.\n    pub(crate) fn fix_name(&mut self) -> Result<()> {\n        let bad_chars = |c| c == '\"';\n        require!(!self.name.is_empty(), \"Empty template name\");\n        let trimmed = self.name.replace(bad_chars, \"\");\n        require!(!trimmed.is_empty(), \"Template name contains only quotes\");\n        if self.name.len() != trimmed.len() {\n            self.name = trimmed;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/notetype/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\n\npub(crate) enum UndoableNotetypeChange {\n    Added(Box<Notetype>),\n    Updated(Box<Notetype>),\n    Removed(Box<Notetype>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_notetype_change(&mut self, change: UndoableNotetypeChange) -> Result<()> {\n        match change {\n            UndoableNotetypeChange::Added(nt) => self.remove_notetype_only_undoable(*nt),\n            UndoableNotetypeChange::Updated(nt) => {\n                let current = self\n                    .storage\n                    .get_notetype(nt.id)?\n                    .or_invalid(\"notetype disappeared\")?;\n                self.update_notetype_undoable(&nt, current)\n            }\n            UndoableNotetypeChange::Removed(nt) => self.restore_deleted_notetype(*nt),\n        }\n    }\n\n    pub(crate) fn remove_notetype_only_undoable(&mut self, notetype: Notetype) -> Result<()> {\n        self.state.notetype_cache.remove(&notetype.id);\n        self.storage.remove_notetype(notetype.id)?;\n        self.save_undo(UndoableNotetypeChange::Removed(Box::new(notetype)));\n        Ok(())\n    }\n\n    pub(super) fn add_notetype_undoable(\n        &mut self,\n        notetype: &mut Notetype,\n    ) -> Result<(), AnkiError> {\n        self.storage.add_notetype(notetype)?;\n        self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype.clone())));\n        Ok(())\n    }\n\n    /// Caller must ensure [NotetypeId] is unique.\n    pub(crate) fn add_notetype_with_unique_id_undoable(\n        &mut self,\n        notetype: &Notetype,\n    ) -> Result<()> {\n        self.storage\n            .add_or_update_notetype_with_existing_id(notetype)?;\n        self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype.clone())));\n        Ok(())\n    }\n\n    pub(super) fn update_notetype_undoable(\n        &mut self,\n        notetype: &Notetype,\n        original: Notetype,\n    ) -> Result<()> {\n        self.state.notetype_cache.remove(&notetype.id);\n        self.save_undo(UndoableNotetypeChange::Updated(Box::new(original)));\n        self.storage\n            .add_or_update_notetype_with_existing_id(notetype)\n    }\n\n    fn restore_deleted_notetype(&mut self, notetype: Notetype) -> Result<()> {\n        self.storage\n            .add_or_update_notetype_with_existing_id(&notetype)?;\n        self.save_undo(UndoableNotetypeChange::Added(Box::new(notetype)));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/ops.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum Op {\n    Custom(String),\n    AddDeck,\n    AddNote,\n    AddNotetype,\n    AnswerCard,\n    BuildFilteredDeck,\n    Bury,\n    ChangeNotetype,\n    ClearUnusedTags,\n    CreateCustomStudy,\n    EmptyCards,\n    EmptyFilteredDeck,\n    FindAndReplace,\n    ImageOcclusion,\n    Import,\n    RebuildFilteredDeck,\n    RemoveDeck,\n    RemoveNote,\n    RemoveNotetype,\n    RemoveTag,\n    RenameDeck,\n    ReparentDeck,\n    RenameTag,\n    ReparentTag,\n    ScheduleAsNew,\n    SetCardDeck,\n    SetDueDate,\n    GradeNow,\n    SetFlag,\n    SortCards,\n    Suspend,\n    ToggleLoadBalancer,\n    UnburyUnsuspend,\n    UpdateCard,\n    UpdateConfig,\n    UpdateDeck,\n    UpdateDeckConfig,\n    UpdateNote,\n    UpdatePreferences,\n    UpdateTag,\n    UpdateNotetype,\n    SetCurrentDeck,\n    /// Does not register changes in undo queue, but does not clear the current\n    /// queue either.\n    SkipUndo,\n}\n\nimpl Op {\n    pub fn describe(&self, tr: &I18n) -> String {\n        match self {\n            Op::AddDeck => tr.actions_add_deck(),\n            Op::AddNote => tr.actions_add_note(),\n            Op::AnswerCard => tr.actions_answer_card(),\n            Op::Bury => tr.studying_bury(),\n            Op::CreateCustomStudy => tr.actions_custom_study(),\n            Op::EmptyCards => tr.actions_empty_cards(),\n            Op::Import => tr.actions_import(),\n            Op::RemoveDeck => tr.decks_delete_deck(),\n            Op::RemoveNote => tr.studying_delete_note(),\n            Op::RenameDeck => tr.actions_rename_deck(),\n            Op::ScheduleAsNew => tr.actions_forget_card(),\n            Op::SetDueDate => tr.actions_set_due_date(),\n            Op::ToggleLoadBalancer => tr.actions_toggle_load_balancer(),\n            Op::GradeNow => tr.actions_grade_now(),\n            Op::Suspend => tr.studying_suspend(),\n            Op::UnburyUnsuspend => tr.actions_unbury_unsuspend(),\n            Op::UpdateCard => tr.actions_update_card(),\n            Op::UpdateDeck => tr.actions_update_deck(),\n            Op::UpdateNote => tr.actions_update_note(),\n            Op::UpdatePreferences => tr.preferences_preferences(),\n            Op::UpdateTag => tr.actions_update_tag(),\n            Op::SetCardDeck => tr.browsing_change_deck(),\n            Op::SetFlag => tr.actions_set_flag(),\n            Op::FindAndReplace => tr.browsing_find_and_replace(),\n            Op::ClearUnusedTags => tr.browsing_clear_unused_tags(),\n            Op::SortCards => tr.actions_reposition(),\n            Op::RenameTag => tr.actions_rename_tag(),\n            Op::RemoveTag => tr.actions_remove_tag(),\n            Op::ReparentTag => tr.actions_rename_tag(),\n            Op::ReparentDeck => tr.actions_rename_deck(),\n            Op::BuildFilteredDeck => tr.actions_build_filtered_deck(),\n            Op::RebuildFilteredDeck => tr.actions_build_filtered_deck(),\n            Op::EmptyFilteredDeck => tr.studying_empty(),\n            Op::SetCurrentDeck => tr.browsing_select_deck(),\n            Op::UpdateDeckConfig => tr.deck_config_title(),\n            Op::AddNotetype => tr.actions_add_notetype(),\n            Op::RemoveNotetype => tr.actions_remove_notetype(),\n            Op::UpdateNotetype => tr.actions_update_notetype(),\n            Op::UpdateConfig => tr.actions_update_config(),\n            Op::Custom(name) => name.into(),\n            Op::ChangeNotetype => tr.browsing_change_notetype(),\n            Op::SkipUndo => return \"\".to_string(),\n            Op::ImageOcclusion => tr.notetypes_image_occlusion_name(),\n        }\n        .into()\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]\npub struct StateChanges {\n    pub card: bool,\n    pub note: bool,\n    pub deck: bool,\n    pub tag: bool,\n    pub notetype: bool,\n    pub config: bool,\n    pub deck_config: bool,\n    pub mtime: bool,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub struct OpChanges {\n    pub op: Op,\n    pub changes: StateChanges,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct OpOutput<T> {\n    pub output: T,\n    pub changes: OpChanges,\n}\n\nimpl<T> OpOutput<T> {\n    pub(crate) fn map<F, N>(self, func: F) -> OpOutput<N>\n    where\n        F: FnOnce(T) -> N,\n    {\n        OpOutput {\n            output: func(self.output),\n            changes: self.changes,\n        }\n    }\n}\n\nimpl OpChanges {\n    #[cfg(test)]\n    pub fn had_change(&self) -> bool {\n        let c = &self.changes;\n        c.card || c.config || c.deck || c.deck_config || c.note || c.notetype || c.tag || c.mtime\n    }\n    // These routines should return true even if the GUI may have\n    // special handling for an action, since we need to do the right\n    // thing when undoing, and if multiple windows of the same type are\n    // open.\n\n    pub fn requires_browser_table_redraw(&self) -> bool {\n        let c = &self.changes;\n        c.card || c.notetype || c.config || (c.note && self.op != Op::AddNote) || c.deck\n    }\n\n    pub fn requires_browser_sidebar_redraw(&self) -> bool {\n        let c = &self.changes;\n        c.tag || c.deck || c.notetype || c.config\n    }\n\n    pub fn requires_note_text_redraw(&self) -> bool {\n        let c = &self.changes;\n        c.note || c.notetype\n    }\n\n    pub fn requires_study_queue_rebuild(&self) -> bool {\n        let c = &self.changes;\n        (c.card && self.op != Op::SetFlag)\n            || c.deck\n            || (c.config\n                && matches!(\n                    self.op,\n                    Op::SetCurrentDeck\n                        | Op::UpdatePreferences\n                        | Op::UpdateDeckConfig\n                        | Op::ToggleLoadBalancer\n                ))\n            || c.deck_config\n    }\n}\n"
  },
  {
    "path": "rslib/src/preferences.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::config::preferences::scheduling::NewReviewMix as NewRevMixPB;\nuse anki_proto::config::preferences::Editing;\nuse anki_proto::config::preferences::Reviewing;\nuse anki_proto::config::preferences::Scheduling;\nuse anki_proto::config::Preferences;\n\nuse crate::collection::Collection;\nuse crate::config::BoolKey;\nuse crate::config::StringKey;\nuse crate::error::Result;\nuse crate::prelude::*;\nuse crate::scheduler::timing::local_minutes_west_for_stamp;\n\nimpl Collection {\n    pub fn get_preferences(&self) -> Result<Preferences> {\n        Ok(Preferences {\n            scheduling: Some(self.get_scheduling_preferences()?),\n            reviewing: Some(self.get_reviewing_preferences()?),\n            editing: Some(self.get_editing_preferences()?),\n            backups: Some(self.get_backup_limits()),\n        })\n    }\n\n    pub fn set_preferences(&mut self, prefs: Preferences) -> Result<OpOutput<()>> {\n        self.transact(Op::UpdatePreferences, |col| {\n            col.set_preferences_inner(prefs)\n        })\n    }\n\n    fn set_preferences_inner(&mut self, prefs: Preferences) -> Result<()> {\n        if let Some(sched) = prefs.scheduling {\n            self.set_scheduling_preferences(sched)?;\n        }\n        if let Some(reviewing) = prefs.reviewing {\n            self.set_reviewing_preferences(reviewing)?;\n        }\n        if let Some(editing) = prefs.editing {\n            self.set_editing_preferences(editing)?;\n        }\n        if let Some(backups) = prefs.backups {\n            self.set_backup_limits(backups)?;\n        }\n        Ok(())\n    }\n\n    pub fn get_scheduling_preferences(&self) -> Result<Scheduling> {\n        Ok(Scheduling {\n            rollover: self.rollover_for_current_scheduler()? as u32,\n            learn_ahead_secs: self.learn_ahead_secs(),\n            new_review_mix: match self.get_new_review_mix() {\n                crate::config::NewReviewMix::Mix => NewRevMixPB::Distribute,\n                crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst,\n                crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst,\n            } as i32,\n            new_timezone: self.get_creation_utc_offset().is_some(),\n            day_learn_first: self.get_config_bool(BoolKey::ShowDayLearningCardsFirst),\n        })\n    }\n\n    pub(crate) fn set_scheduling_preferences(&mut self, settings: Scheduling) -> Result<()> {\n        let s = settings;\n\n        self.set_config_bool_inner(BoolKey::ShowDayLearningCardsFirst, s.day_learn_first)?;\n        self.set_learn_ahead_secs(s.learn_ahead_secs)?;\n\n        self.set_new_review_mix(match s.new_review_mix() {\n            NewRevMixPB::Distribute => crate::config::NewReviewMix::Mix,\n            NewRevMixPB::NewFirst => crate::config::NewReviewMix::NewFirst,\n            NewRevMixPB::ReviewsFirst => crate::config::NewReviewMix::ReviewsFirst,\n        })?;\n\n        let created = self.storage.creation_stamp()?;\n\n        if self.rollover_for_current_scheduler()? != s.rollover as u8 {\n            self.set_rollover_for_current_scheduler(s.rollover as u8)?;\n        }\n\n        if s.new_timezone {\n            if self.get_creation_utc_offset().is_none() {\n                self.set_creation_utc_offset(Some(local_minutes_west_for_stamp(created)?))?;\n            }\n        } else {\n            self.set_creation_utc_offset(None)?;\n        }\n\n        Ok(())\n    }\n\n    pub fn get_reviewing_preferences(&self) -> Result<Reviewing> {\n        Ok(Reviewing {\n            hide_audio_play_buttons: self.get_config_bool(BoolKey::HideAudioPlayButtons),\n            interrupt_audio_when_answering: self\n                .get_config_bool(BoolKey::InterruptAudioWhenAnswering),\n            show_remaining_due_counts: self.get_config_bool(BoolKey::ShowRemainingDueCountsInStudy),\n            show_intervals_on_buttons: self\n                .get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons),\n            time_limit_secs: self.get_answer_time_limit_secs(),\n            load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled),\n            fsrs_short_term_with_steps_enabled: self\n                .get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled),\n        })\n    }\n\n    pub(crate) fn set_reviewing_preferences(&mut self, settings: Reviewing) -> Result<()> {\n        let s = settings;\n        self.set_config_bool_inner(BoolKey::HideAudioPlayButtons, s.hide_audio_play_buttons)?;\n        self.set_config_bool_inner(\n            BoolKey::InterruptAudioWhenAnswering,\n            s.interrupt_audio_when_answering,\n        )?;\n        self.set_config_bool_inner(\n            BoolKey::ShowRemainingDueCountsInStudy,\n            s.show_remaining_due_counts,\n        )?;\n        self.set_config_bool_inner(\n            BoolKey::ShowIntervalsAboveAnswerButtons,\n            s.show_intervals_on_buttons,\n        )?;\n        self.set_answer_time_limit_secs(s.time_limit_secs)?;\n        self.set_config_bool_inner(BoolKey::LoadBalancerEnabled, s.load_balancer_enabled)?;\n        self.set_config_bool_inner(\n            BoolKey::FsrsShortTermWithStepsEnabled,\n            s.fsrs_short_term_with_steps_enabled,\n        )?;\n        Ok(())\n    }\n\n    pub fn get_editing_preferences(&self) -> Result<Editing> {\n        Ok(Editing {\n            adding_defaults_to_current_deck: self\n                .get_config_bool(BoolKey::AddingDefaultsToCurrentDeck),\n            paste_images_as_png: self.get_config_bool(BoolKey::PasteImagesAsPng),\n            paste_strips_formatting: self.get_config_bool(BoolKey::PasteStripsFormatting),\n            default_search_text: self.get_config_string(StringKey::DefaultSearchText),\n            ignore_accents_in_search: self.get_config_bool(BoolKey::IgnoreAccentsInSearch),\n            render_latex: self.get_config_bool(BoolKey::RenderLatex),\n        })\n    }\n\n    pub(crate) fn set_editing_preferences(&mut self, settings: Editing) -> Result<()> {\n        let s = settings;\n        self.set_config_bool_inner(\n            BoolKey::AddingDefaultsToCurrentDeck,\n            s.adding_defaults_to_current_deck,\n        )?;\n        self.set_config_bool_inner(BoolKey::PasteImagesAsPng, s.paste_images_as_png)?;\n        self.set_config_bool_inner(BoolKey::PasteStripsFormatting, s.paste_strips_formatting)?;\n        self.set_config_string_inner(StringKey::DefaultSearchText, &s.default_search_text)?;\n        self.set_config_bool_inner(BoolKey::IgnoreAccentsInSearch, s.ignore_accents_in_search)?;\n        self.set_config_bool_inner(BoolKey::RenderLatex, s.render_latex)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/prelude.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub use anki_i18n::I18n;\npub use snafu::ResultExt;\n\npub use crate::card::Card;\npub use crate::card::CardId;\npub use crate::collection::Collection;\npub use crate::config::BoolKey;\npub use crate::deckconfig::DeckConfig;\npub use crate::deckconfig::DeckConfigId;\npub use crate::decks::Deck;\npub use crate::decks::DeckId;\npub use crate::decks::DeckKind;\npub use crate::decks::NativeDeckName;\npub use crate::error::AnkiError;\npub use crate::error::OrInvalid;\npub use crate::error::OrNotFound;\npub use crate::error::Result;\npub use crate::invalid_input;\npub use crate::media::Sha1Hash;\npub use crate::notes::Note;\npub use crate::notes::NoteId;\npub use crate::notetype::Notetype;\npub use crate::notetype::NotetypeId;\npub use crate::ops::Op;\npub use crate::ops::OpChanges;\npub use crate::ops::OpOutput;\npub use crate::require;\npub use crate::revlog::RevlogId;\npub use crate::search::SearchBuilder;\npub use crate::search::TryIntoSearch;\n#[cfg(test)]\npub(crate) use crate::tests::*;\npub use crate::timestamp::TimestampMillis;\npub use crate::timestamp::TimestampSecs;\npub(crate) use crate::types::IntoNewtypeVec;\npub use crate::types::Usn;\n"
  },
  {
    "path": "rslib/src/progress.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::marker::PhantomData;\nuse std::sync::Arc;\nuse std::sync::Mutex;\n\nuse anki_i18n::I18n;\nuse anki_proto::collection::progress::Value;\n\nuse crate::dbcheck::DatabaseCheckProgress;\nuse crate::error::AnkiError;\nuse crate::error::Result;\nuse crate::import_export::ExportProgress;\nuse crate::import_export::ImportProgress;\nuse crate::prelude::Collection;\nuse crate::scheduler::fsrs::memory_state::ComputeMemoryProgress;\nuse crate::scheduler::fsrs::params::ComputeParamsProgress;\nuse crate::scheduler::fsrs::retention::ComputeRetentionProgress;\nuse crate::sync::collection::normal::NormalSyncProgress;\nuse crate::sync::collection::progress::FullSyncProgress;\nuse crate::sync::collection::progress::SyncStage;\nuse crate::sync::media::progress::MediaCheckProgress;\nuse crate::sync::media::progress::MediaSyncProgress;\n\n/// Stores progress state that can be updated cheaply, and will update a\n/// Mutex-protected copy that other threads can check, if more than 0.1\n/// secs has elapsed since the previous update.\n/// If another thread has set the `want_abort` flag on the shared state,\n/// then the next non-throttled update will fail with [AnkiError::Interrupted].\n/// Automatically updates the shared state on creation, with the default\n/// value for the type.\n#[derive(Debug, Default)]\npub struct ThrottlingProgressHandler<P: Into<Progress> + Default> {\n    pub(crate) state: P,\n    shared_state: Arc<Mutex<ProgressState>>,\n    last_shared_update: coarsetime::Instant,\n}\n\nimpl<P: Into<Progress> + Default + Clone> ThrottlingProgressHandler<P> {\n    pub(crate) fn new(shared_state: Arc<Mutex<ProgressState>>) -> Self {\n        let initial = P::default();\n        {\n            let mut guard = shared_state.lock().unwrap();\n            guard.last_progress = Some(initial.clone().into());\n            guard.want_abort = false;\n        }\n        Self {\n            shared_state,\n            state: initial,\n            ..Default::default()\n        }\n    }\n\n    /// Overwrite the currently-stored state. This does not throttle, and should\n    /// be used when you want to ensure the UI state gets updated, and\n    /// ensure that the abort flag is checked between expensive steps.\n    pub(crate) fn set(&mut self, progress: P) -> Result<()> {\n        self.update(false, |state| *state = progress)\n    }\n\n    /// Mutate the currently-stored state, and maybe update shared state.\n    pub(crate) fn update(&mut self, throttle: bool, mutator: impl FnOnce(&mut P)) -> Result<()> {\n        mutator(&mut self.state);\n\n        let now = coarsetime::Instant::now();\n        if throttle && now.duration_since(self.last_shared_update).as_f64() < 0.1 {\n            return Ok(());\n        }\n        self.last_shared_update = now;\n\n        let mut guard = self.shared_state.lock().unwrap();\n        guard.last_progress.replace(self.state.clone().into());\n\n        if std::mem::take(&mut guard.want_abort) {\n            Err(AnkiError::Interrupted)\n        } else {\n            Ok(())\n        }\n    }\n\n    /// Check the abort flag, and trigger a UI update if it was throttled.\n    pub(crate) fn check_cancelled(&mut self) -> Result<()> {\n        self.set(self.state.clone())\n    }\n\n    /// An alternative to incrementor() below, that can be used across function\n    /// calls easily, as it continues from the previous state.\n    pub(crate) fn increment(&mut self, accessor: impl Fn(&mut P) -> &mut usize) -> Result<()> {\n        let field = accessor(&mut self.state);\n        *field += 1;\n        if *field % 17 == 0 {\n            self.update(true, |_| ())?;\n        }\n        Ok(())\n    }\n\n    /// Returns an [Incrementor] with an `increment()` function for use in\n    /// loops.\n    pub(crate) fn incrementor<'inc, 'progress: 'inc, 'map: 'inc>(\n        &'progress mut self,\n        mut count_map: impl 'map + FnMut(usize) -> P,\n    ) -> Incrementor<'inc, impl FnMut(usize) -> Result<()> + 'inc> {\n        Incrementor::new(move |u| self.update(true, |p| *p = count_map(u)))\n    }\n\n    /// Stopgap for returning a progress fn compliant with the media code.\n    pub(crate) fn media_db_fn(\n        &mut self,\n        count_map: impl 'static + Fn(usize) -> P,\n    ) -> Result<impl FnMut(usize) -> bool + '_>\n    where\n        P: Into<Progress>,\n    {\n        Ok(move |count| self.update(true, |p| *p = count_map(count)).is_ok())\n    }\n}\n\n#[derive(Default, Debug)]\npub struct ProgressState {\n    pub want_abort: bool,\n    pub last_progress: Option<Progress>,\n}\n\nimpl ProgressState {\n    pub fn reset(&mut self) {\n        self.want_abort = false;\n        self.last_progress = None;\n    }\n}\n\n#[derive(Clone, Copy, Debug)]\npub enum Progress {\n    MediaSync(MediaSyncProgress),\n    MediaCheck(MediaCheckProgress),\n    FullSync(FullSyncProgress),\n    NormalSync(NormalSyncProgress),\n    DatabaseCheck(DatabaseCheckProgress),\n    Import(ImportProgress),\n    Export(ExportProgress),\n    ComputeParams(ComputeParamsProgress),\n    ComputeRetention(ComputeRetentionProgress),\n    ComputeMemory(ComputeMemoryProgress),\n}\n\npub(crate) fn progress_to_proto(\n    progress: Option<Progress>,\n    tr: &I18n,\n) -> anki_proto::collection::Progress {\n    let progress = if let Some(progress) = progress {\n        match progress {\n            Progress::MediaSync(p) => Value::MediaSync(media_sync_progress(p, tr)),\n            Progress::MediaCheck(n) => Value::MediaCheck(tr.media_check_checked(n.checked).into()),\n            Progress::FullSync(p) => Value::FullSync(anki_proto::collection::progress::FullSync {\n                transferred: p.transferred_bytes as u32,\n                total: p.total_bytes as u32,\n            }),\n            Progress::NormalSync(p) => {\n                let stage = match p.stage {\n                    SyncStage::Connecting => tr.sync_syncing(),\n                    SyncStage::Syncing => tr.sync_syncing(),\n                    SyncStage::Finalizing => tr.sync_checking(),\n                }\n                .to_string();\n                let added = tr\n                    .sync_added_updated_count(p.local_update, p.remote_update)\n                    .into();\n                let removed = tr\n                    .sync_media_removed_count(p.local_remove, p.remote_remove)\n                    .into();\n                Value::NormalSync(anki_proto::collection::progress::NormalSync {\n                    stage,\n                    added,\n                    removed,\n                })\n            }\n            Progress::DatabaseCheck(p) => {\n                let mut stage_total = 0;\n                let mut stage_current = 0;\n                let stage = match p {\n                    DatabaseCheckProgress::Integrity => tr.database_check_checking_integrity(),\n                    DatabaseCheckProgress::Optimize => tr.database_check_rebuilding(),\n                    DatabaseCheckProgress::Cards => tr.database_check_checking_cards(),\n                    DatabaseCheckProgress::Notes { current, total } => {\n                        stage_total = total;\n                        stage_current = current;\n                        tr.database_check_checking_notes()\n                    }\n                    DatabaseCheckProgress::History => tr.database_check_checking_history(),\n                }\n                .to_string();\n                Value::DatabaseCheck(anki_proto::collection::progress::DatabaseCheck {\n                    stage,\n                    stage_total: stage_total as u32,\n                    stage_current: stage_current as u32,\n                })\n            }\n            Progress::Import(progress) => Value::Importing(\n                match progress {\n                    ImportProgress::File => tr.importing_importing_file(),\n                    ImportProgress::Media(n) => tr.importing_processed_media_file(n),\n                    ImportProgress::MediaCheck(n) => tr.media_check_checked(n),\n                    ImportProgress::Notes(n) => tr.importing_processed_notes(n),\n                    ImportProgress::Extracting => tr.importing_extracting(),\n                    ImportProgress::Gathering => tr.importing_gathering(),\n                }\n                .into(),\n            ),\n            Progress::Export(progress) => Value::Exporting(\n                match progress {\n                    ExportProgress::File => tr.exporting_exporting_file(),\n                    ExportProgress::Media(n) => tr.exporting_processed_media_files(n),\n                    ExportProgress::Notes(n) => tr.importing_processed_notes(n),\n                    ExportProgress::Cards(n) => tr.importing_processed_cards(n),\n                    ExportProgress::Gathering => tr.importing_gathering(),\n                }\n                .into(),\n            ),\n            Progress::ComputeParams(progress) => {\n                Value::ComputeParams(anki_proto::collection::ComputeParamsProgress {\n                    current: progress.current_iteration,\n                    total: progress.total_iterations,\n                    reviews: progress.reviews,\n                    current_preset: progress.current_preset,\n                    total_presets: progress.total_presets,\n                })\n            }\n            Progress::ComputeRetention(progress) => {\n                Value::ComputeRetention(anki_proto::collection::ComputeRetentionProgress {\n                    current: progress.current,\n                    total: progress.total,\n                })\n            }\n            Progress::ComputeMemory(progress) => {\n                Value::ComputeMemory(anki_proto::collection::ComputeMemoryProgress {\n                    current_cards: progress.current_cards,\n                    total_cards: progress.total_cards,\n                    label: tr\n                        .deck_config_updating_cards(progress.current_cards, progress.total_cards)\n                        .into(),\n                })\n            }\n        }\n    } else {\n        Value::None(anki_proto::generic::Empty {})\n    };\n    anki_proto::collection::Progress {\n        value: Some(progress),\n    }\n}\n\nfn media_sync_progress(p: MediaSyncProgress, tr: &I18n) -> anki_proto::sync::MediaSyncProgress {\n    anki_proto::sync::MediaSyncProgress {\n        checked: tr.sync_media_checked_count(p.checked).into(),\n        added: tr\n            .sync_media_added_count(p.uploaded_files, p.downloaded_files)\n            .into(),\n        removed: tr\n            .sync_media_removed_count(p.uploaded_deletions, p.downloaded_deletions)\n            .into(),\n    }\n}\n\nimpl From<FullSyncProgress> for Progress {\n    fn from(p: FullSyncProgress) -> Self {\n        Progress::FullSync(p)\n    }\n}\n\nimpl From<MediaSyncProgress> for Progress {\n    fn from(p: MediaSyncProgress) -> Self {\n        Progress::MediaSync(p)\n    }\n}\n\nimpl From<MediaCheckProgress> for Progress {\n    fn from(p: MediaCheckProgress) -> Self {\n        Progress::MediaCheck(p)\n    }\n}\n\nimpl From<NormalSyncProgress> for Progress {\n    fn from(p: NormalSyncProgress) -> Self {\n        Progress::NormalSync(p)\n    }\n}\n\nimpl From<DatabaseCheckProgress> for Progress {\n    fn from(p: DatabaseCheckProgress) -> Self {\n        Progress::DatabaseCheck(p)\n    }\n}\n\nimpl From<ImportProgress> for Progress {\n    fn from(p: ImportProgress) -> Self {\n        Progress::Import(p)\n    }\n}\n\nimpl From<ExportProgress> for Progress {\n    fn from(p: ExportProgress) -> Self {\n        Progress::Export(p)\n    }\n}\n\nimpl From<ComputeParamsProgress> for Progress {\n    fn from(p: ComputeParamsProgress) -> Self {\n        Progress::ComputeParams(p)\n    }\n}\n\nimpl From<ComputeRetentionProgress> for Progress {\n    fn from(p: ComputeRetentionProgress) -> Self {\n        Progress::ComputeRetention(p)\n    }\n}\n\nimpl From<ComputeMemoryProgress> for Progress {\n    fn from(p: ComputeMemoryProgress) -> Self {\n        Progress::ComputeMemory(p)\n    }\n}\n\nimpl Collection {\n    pub fn new_progress_handler<P: Into<Progress> + Default + Clone>(\n        &self,\n    ) -> ThrottlingProgressHandler<P> {\n        ThrottlingProgressHandler::new(self.state.progress.clone())\n    }\n\n    pub(crate) fn clear_progress(&mut self) {\n        self.state.progress.lock().unwrap().reset();\n    }\n}\n\npub(crate) struct Incrementor<'f, F: 'f + FnMut(usize) -> Result<()>> {\n    update_fn: F,\n    count: usize,\n    update_interval: usize,\n    _phantom: PhantomData<&'f ()>,\n}\n\nimpl<'f, F: 'f + FnMut(usize) -> Result<()>> Incrementor<'f, F> {\n    fn new(update_fn: F) -> Self {\n        Self {\n            update_fn,\n            count: 0,\n            update_interval: 17,\n            _phantom: PhantomData,\n        }\n    }\n\n    /// Increments the progress counter, periodically triggering an update.\n    /// Returns [AnkiError::Interrupted] if the operation should be cancelled.\n    pub(crate) fn increment(&mut self) -> Result<()> {\n        self.count += 1;\n        if self.count % self.update_interval != 0 {\n            return Ok(());\n        }\n        (self.update_fn)(self.count)\n    }\n\n    pub(crate) fn count(&self) -> usize {\n        self.count\n    }\n}\n"
  },
  {
    "path": "rslib/src/revlog/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod undo;\n\nuse num_enum::TryFromPrimitive;\nuse serde::Deserialize;\nuse serde_repr::Deserialize_repr;\nuse serde_repr::Serialize_repr;\nuse serde_tuple::Serialize_tuple;\n\nuse crate::define_newtype;\nuse crate::prelude::*;\nuse crate::serde::default_on_invalid;\nuse crate::serde::deserialize_int_from_number;\n\ndefine_newtype!(RevlogId, i64);\n\nimpl RevlogId {\n    pub fn new() -> Self {\n        RevlogId(TimestampMillis::now().0)\n    }\n\n    pub fn as_secs(self) -> TimestampSecs {\n        TimestampSecs(self.0 / 1000)\n    }\n}\n\nimpl From<TimestampMillis> for RevlogId {\n    fn from(m: TimestampMillis) -> Self {\n        RevlogId(m.0)\n    }\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq, Clone)]\npub struct RevlogEntry {\n    pub id: RevlogId,\n    pub cid: CardId,\n    pub usn: Usn,\n    /// - In the V1 scheduler, 3 represents easy in the learning case.\n    /// - 0 represents manual rescheduling.\n    #[serde(rename = \"ease\")]\n    pub button_chosen: u8,\n    /// Positive values are in days, negative values in seconds.\n    #[serde(rename = \"ivl\", deserialize_with = \"deserialize_int_from_number\")]\n    pub interval: i32,\n    /// Positive values are in days, negative values in seconds.\n    #[serde(rename = \"lastIvl\", deserialize_with = \"deserialize_int_from_number\")]\n    pub last_interval: i32,\n    /// Card's ease after answering, stored as 10x the %, eg 2500 represents\n    /// 250%. When FSRS is active, difficulty is normalized to 100-1100 range,\n    /// so a 0 difficulty can be distinguished from SM-2 learning.\n    #[serde(rename = \"factor\", deserialize_with = \"deserialize_int_from_number\")]\n    pub ease_factor: u32,\n    /// Amount of milliseconds taken to answer the card.\n    #[serde(rename = \"time\", deserialize_with = \"deserialize_int_from_number\")]\n    pub taken_millis: u32,\n    #[serde(rename = \"type\", default, deserialize_with = \"default_on_invalid\")]\n    pub review_kind: RevlogReviewKind,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, TryFromPrimitive, Clone, Copy)]\n#[repr(u8)]\n#[derive(Default)]\npub enum RevlogReviewKind {\n    #[default]\n    Learning = 0,\n    Review = 1,\n    Relearning = 2,\n    /// Old Anki versions called this \"Cram\" or \"Early\". It's assigned when\n    /// reviewing cards before they're due, or when rescheduling is\n    /// disabled.\n    Filtered = 3,\n    Manual = 4,\n    Rescheduled = 5,\n}\n\nimpl RevlogEntry {\n    pub(crate) fn interval_secs(&self) -> u32 {\n        u32::try_from(if self.interval > 0 {\n            self.interval.saturating_mul(86_400)\n        } else {\n            self.interval.saturating_mul(-1)\n        })\n        .unwrap()\n    }\n\n    pub(crate) fn last_interval_secs(&self) -> u32 {\n        u32::try_from(if self.last_interval > 0 {\n            self.last_interval.saturating_mul(86_400)\n        } else {\n            self.last_interval.saturating_mul(-1)\n        })\n        .unwrap()\n    }\n\n    /// Returns true if this entry represents a reset operation.\n    /// These entries are created when a card is reset using\n    /// [`Collection::reschedule_cards_as_new`].\n    /// The 0 value of `ease_factor` differentiates it\n    /// from entry created by [`Collection::set_due_date`] that has\n    /// `RevlogReviewKind::Manual` but non-zero `ease_factor`.\n    pub(crate) fn is_reset(&self) -> bool {\n        self.review_kind == RevlogReviewKind::Manual && self.ease_factor == 0\n    }\n\n    /// Returns true if this entry represents a cramming operation.\n    /// These entries are created when a card is reviewed in a\n    /// filtered deck with \"Reschedule cards based on my answers\n    /// in this deck\" disabled.\n    /// [`crate::scheduler::answering::CardStateUpdater::apply_preview_state`].\n    /// The 0 value of `ease_factor` distinguishes it from the entry\n    /// created when a card is reviewed before its due date in a\n    /// filtered deck with reschedule enabled or using Grade Now.\n    pub(crate) fn is_cramming(&self) -> bool {\n        self.review_kind == RevlogReviewKind::Filtered && self.ease_factor == 0\n    }\n\n    pub(crate) fn has_rating(&self) -> bool {\n        self.button_chosen > 0\n    }\n\n    /// Returns true if the review entry is not manually rescheduled and not\n    /// cramming. Used to filter out entries that shouldn't be considered\n    /// for statistics and scheduling.\n    pub(crate) fn has_rating_and_affects_scheduling(&self) -> bool {\n        // not rescheduled/set due date/reset\n        self.has_rating()\n            // not cramming\n            && !self.is_cramming()\n    }\n}\n\nimpl Collection {\n    // set due date or reset\n    pub(crate) fn log_manually_scheduled_review(\n        &mut self,\n        card: &Card,\n        original_interval: u32,\n        usn: Usn,\n    ) -> Result<()> {\n        self.log_scheduled_review(card, original_interval, usn, RevlogReviewKind::Manual)\n    }\n\n    // reschedule cards on change\n    pub(crate) fn log_rescheduled_review(\n        &mut self,\n        card: &Card,\n        original_interval: u32,\n        usn: Usn,\n    ) -> Result<()> {\n        self.log_scheduled_review(card, original_interval, usn, RevlogReviewKind::Rescheduled)\n    }\n\n    fn log_scheduled_review(\n        &mut self,\n        card: &Card,\n        original_interval: u32,\n        usn: Usn,\n        review_kind: RevlogReviewKind,\n    ) -> Result<()> {\n        let ease_factor = u32::from(\n            card.memory_state\n                .map(|s| (s.difficulty_shifted() * 1000.) as u16)\n                .unwrap_or(card.ease_factor),\n        );\n        let entry = RevlogEntry {\n            id: RevlogId::new(),\n            cid: card.id,\n            usn,\n            button_chosen: 0,\n            interval: i32::try_from(card.interval).unwrap_or(i32::MAX),\n            last_interval: i32::try_from(original_interval).unwrap_or(i32::MAX),\n            ease_factor,\n            taken_millis: 0,\n            review_kind,\n        };\n        self.add_revlog_entry_undoable(entry)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/revlog/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::RevlogEntry;\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableRevlogChange {\n    Added(Box<RevlogEntry>),\n    Removed(Box<RevlogEntry>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_revlog_change(&mut self, change: UndoableRevlogChange) -> Result<()> {\n        match change {\n            UndoableRevlogChange::Added(revlog) => {\n                self.storage.remove_revlog_entry(revlog.id)?;\n                self.save_undo(UndoableRevlogChange::Removed(revlog));\n                Ok(())\n            }\n            UndoableRevlogChange::Removed(revlog) => {\n                self.storage.add_revlog_entry(&revlog, false)?;\n                self.save_undo(UndoableRevlogChange::Added(revlog));\n                Ok(())\n            }\n        }\n    }\n\n    /// Add the provided revlog entry, modifying the ID if it is not unique.\n    pub(crate) fn add_revlog_entry_undoable(&mut self, mut entry: RevlogEntry) -> Result<RevlogId> {\n        entry.id = self.storage.add_revlog_entry(&entry, true)?.unwrap();\n        let id = entry.id;\n        self.save_undo(UndoableRevlogChange::Added(Box::new(entry)));\n        Ok(id)\n    }\n\n    /// Add the provided revlog entry, if its ID is unique.\n    pub(crate) fn add_revlog_entry_if_unique_undoable(&mut self, entry: RevlogEntry) -> Result<()> {\n        if self.storage.add_revlog_entry(&entry, false)?.is_some() {\n            self.save_undo(UndoableRevlogChange::Added(Box::new(entry)));\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/current.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::get_fuzz_seed_for_id_and_reps;\nuse super::CardStateUpdater;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::decks::DeckKind;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::LearnState;\nuse crate::scheduler::states::NewState;\nuse crate::scheduler::states::NormalState;\nuse crate::scheduler::states::PreviewState;\nuse crate::scheduler::states::RelearnState;\nuse crate::scheduler::states::ReschedulingFilterState;\nuse crate::scheduler::states::ReviewState;\n\nimpl CardStateUpdater {\n    pub(crate) fn current_card_state(&self) -> CardState {\n        let due = match &self.deck.kind {\n            DeckKind::Normal(_) => {\n                // if not in a filtered deck, ensure due time is not before today,\n                // which avoids tripping up test_nextIvl() in the Python tests\n                if matches!(self.card.ctype, CardType::Review) {\n                    self.card.due.min(self.timing.days_elapsed as i32)\n                } else {\n                    self.card.due\n                }\n            }\n            DeckKind::Filtered(_) => {\n                if self.card.original_due != 0 {\n                    self.card.original_due\n                } else {\n                    self.card.due\n                }\n            }\n        };\n\n        let normal_state = self.normal_study_state(due);\n\n        match &self.deck.kind {\n            // normal decks have normal state\n            DeckKind::Normal(_) => normal_state.into(),\n            // filtered decks wrap the normal state\n            DeckKind::Filtered(filtered) => {\n                if filtered.reschedule {\n                    ReschedulingFilterState {\n                        original_state: normal_state,\n                    }\n                    .into()\n                } else {\n                    PreviewState {\n                        scheduled_secs: filtered.preview_again_secs,\n                        finished: false,\n                    }\n                    .into()\n                }\n            }\n        }\n    }\n\n    fn normal_study_state(&self, due: i32) -> NormalState {\n        let interval = self.card.interval;\n        let lapses = self.card.lapses;\n        let ease_factor = self.card.ease_factor();\n        let remaining_steps = self.card.remaining_steps();\n        let memory_state = self.card.memory_state;\n        let elapsed_secs = |last_ivl: u32| {\n            match self.card.queue {\n                CardQueue::Learn => {\n                    // Decrease reps by 1 to get correct seed for fuzz.\n                    // If the fuzz calculation changes, this will break.\n                    let last_ivl_with_fuzz = self.learning_ivl_with_fuzz(\n                        get_fuzz_seed_for_id_and_reps(self.card.id, self.card.reps.wrapping_sub(1)),\n                        last_ivl,\n                    );\n                    let last_answered_time = due as i64 - last_ivl_with_fuzz as i64;\n                    (self.now.0 - last_answered_time) as u32\n                }\n                CardQueue::DayLearn => {\n                    let days_since_col_creation = self.timing.days_elapsed as i32;\n                    // Need .max(1) for same day learning cards pushed to the next day.\n                    // 86_400 is the number of seconds in a day.\n                    let last_ivl_as_days = (last_ivl / 86_400).max(1) as i32;\n                    let elapsed_days = days_since_col_creation - due + last_ivl_as_days;\n                    (elapsed_days * 86_400) as u32\n                }\n                _ => 0, // Not used for other card queues.\n            }\n        };\n\n        match self.card.ctype {\n            CardType::New => NormalState::New(NewState {\n                position: due.max(0) as u32,\n            }),\n            CardType::Learn => {\n                let last_ivl = self.learn_steps().current_delay_secs(remaining_steps);\n                LearnState {\n                    scheduled_secs: last_ivl,\n                    remaining_steps,\n                    elapsed_secs: elapsed_secs(last_ivl),\n                    memory_state,\n                }\n            }\n            .into(),\n            CardType::Review => ReviewState {\n                scheduled_days: interval,\n                elapsed_days: ((interval as i32) - (due - self.timing.days_elapsed as i32)).max(0)\n                    as u32,\n                ease_factor,\n                lapses,\n                leeched: false,\n                memory_state,\n            }\n            .into(),\n            CardType::Relearn => {\n                let last_ivl = self.relearn_steps().current_delay_secs(remaining_steps);\n                RelearnState {\n                    learning: LearnState {\n                        scheduled_secs: last_ivl,\n                        elapsed_secs: elapsed_secs(last_ivl),\n                        remaining_steps,\n                        memory_state,\n                    },\n                    review: ReviewState {\n                        scheduled_days: interval,\n                        elapsed_days: interval,\n                        ease_factor,\n                        lapses,\n                        leeched: false,\n                        memory_state,\n                    },\n                }\n            }\n            .into(),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/learning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rand::prelude::*;\nuse rand::rngs::StdRng;\n\nuse super::CardStateUpdater;\nuse super::RevlogEntryPartial;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::prelude::*;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::IntervalKind;\nuse crate::scheduler::states::LearnState;\nuse crate::scheduler::states::NewState;\n\nimpl CardStateUpdater {\n    pub(super) fn apply_new_state(\n        &mut self,\n        current: CardState,\n        next: NewState,\n    ) -> RevlogEntryPartial {\n        self.card.ctype = CardType::New;\n        self.card.queue = CardQueue::New;\n        self.card.due = next.position as i32;\n        self.card.original_position = None;\n        self.card.memory_state = None;\n\n        RevlogEntryPartial::new(\n            current,\n            next.into(),\n            self.card\n                .memory_state\n                .map(|d| d.difficulty_shifted())\n                .unwrap_or_default(),\n            self.secs_until_rollover(),\n        )\n    }\n\n    pub(super) fn apply_learning_state(\n        &mut self,\n        current: CardState,\n        next: LearnState,\n    ) -> RevlogEntryPartial {\n        self.card.remaining_steps = next.remaining_steps;\n        self.card.ctype = CardType::Learn;\n        if let Some(position) = current.new_position() {\n            self.card.original_position = Some(position)\n        }\n        self.card.memory_state = next.memory_state;\n\n        let interval = next\n            .interval_kind()\n            .maybe_as_days(self.secs_until_rollover());\n        match interval {\n            IntervalKind::InSecs(secs) => {\n                self.card.queue = CardQueue::Learn;\n                self.card.due = self.fuzzed_next_learning_timestamp(secs);\n            }\n            IntervalKind::InDays(days) => {\n                self.card.queue = CardQueue::DayLearn;\n                self.card.due = (self.timing.days_elapsed + days) as i32;\n            }\n        }\n\n        RevlogEntryPartial::new(\n            current,\n            next.into(),\n            self.card\n                .memory_state\n                .map(|d| d.difficulty_shifted())\n                .unwrap_or_default(),\n            self.secs_until_rollover(),\n        )\n    }\n\n    /// Adds secs + fuzz to current time\n    pub(super) fn fuzzed_next_learning_timestamp(&self, secs: u32) -> i32 {\n        TimestampSecs::now().0 as i32 + self.learning_ivl_with_fuzz(self.fuzz_seed, secs) as i32\n    }\n\n    /// Add up to 25% increase to seconds, but no more than 5 minutes.\n    pub(super) fn learning_ivl_with_fuzz(&self, input_seed: Option<u64>, secs: u32) -> u32 {\n        if let Some(seed) = input_seed {\n            let mut rng = StdRng::seed_from_u64(seed);\n            let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32;\n            if secs >= upper_exclusive {\n                secs\n            } else {\n                rng.random_range(secs..upper_exclusive)\n            }\n        } else {\n            secs\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod current;\nmod learning;\nmod preview;\nmod relearning;\nmod review;\nmod revlog;\n\nuse fsrs::NextStates;\nuse fsrs::FSRS;\nuse rand::prelude::*;\nuse rand::rngs::StdRng;\nuse revlog::RevlogEntryPartial;\n\nuse super::fsrs::params::ignore_revlogs_before_ms_from_config;\nuse super::queue::BuryMode;\nuse super::states::load_balancer::LoadBalancerContext;\nuse super::states::steps::LearningSteps;\nuse super::states::CardState;\nuse super::states::FilteredState;\nuse super::states::NormalState;\nuse super::states::SchedulingStates;\nuse super::states::StateContext;\nuse super::timespan::answer_button_time_collapsible;\nuse super::timing::SchedTimingToday;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::config::BoolKey;\nuse crate::deckconfig::DeckConfig;\nuse crate::deckconfig::LeechAction;\nuse crate::decks::Deck;\nuse crate::prelude::*;\nuse crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state;\nuse crate::scheduler::fsrs::memory_state::get_decay_from_params;\nuse crate::scheduler::states::PreviewState;\nuse crate::search::SearchNode;\n\n#[derive(Copy, Clone)]\npub enum Rating {\n    Again,\n    Hard,\n    Good,\n    Easy,\n}\n\npub struct CardAnswer {\n    pub card_id: CardId,\n    pub current_state: CardState,\n    pub new_state: CardState,\n    pub rating: Rating,\n    pub answered_at: TimestampMillis,\n    pub milliseconds_taken: u32,\n    pub custom_data: Option<String>,\n    pub from_queue: bool,\n}\n\nimpl CardAnswer {\n    fn cap_answer_secs(&mut self, max_secs: u32) {\n        self.milliseconds_taken = self.milliseconds_taken.min(max_secs * 1000);\n    }\n}\n\n/// Holds the information required to determine a given card's\n/// current state, and to apply a state change to it.\nstruct CardStateUpdater {\n    card: Card,\n    deck: Deck,\n    config: DeckConfig,\n    timing: SchedTimingToday,\n    now: TimestampSecs,\n    fuzz_seed: Option<u64>,\n    /// Set if FSRS is enabled.\n    fsrs_next_states: Option<NextStates>,\n    /// Set if FSRS is enabled.\n    desired_retention: Option<f32>,\n    fsrs_short_term_with_steps: bool,\n    fsrs_allow_short_term: bool,\n}\n\nimpl CardStateUpdater {\n    /// Returns information required when transitioning from one card state to\n    /// another with `next_states()`. This separate structure decouples the\n    /// state handling code from the rest of the Anki codebase.\n    pub(crate) fn state_context<'a>(\n        &'a self,\n        load_balancer_ctx: Option<LoadBalancerContext<'a>>,\n    ) -> StateContext<'a> {\n        StateContext {\n            fuzz_factor: get_fuzz_factor(self.fuzz_seed),\n            steps: self.learn_steps(),\n            graduating_interval_good: self.config.inner.graduating_interval_good,\n            graduating_interval_easy: self.config.inner.graduating_interval_easy,\n            initial_ease_factor: self.config.inner.initial_ease,\n            hard_multiplier: self.config.inner.hard_multiplier,\n            easy_multiplier: self.config.inner.easy_multiplier,\n            interval_multiplier: self.config.inner.interval_multiplier,\n            maximum_review_interval: self.config.inner.maximum_review_interval,\n            leech_threshold: self.config.inner.leech_threshold,\n            load_balancer_ctx: load_balancer_ctx\n                .map(|load_balancer_ctx| load_balancer_ctx.set_fuzz_seed(self.fuzz_seed)),\n            relearn_steps: self.relearn_steps(),\n            lapse_multiplier: self.config.inner.lapse_multiplier,\n            minimum_lapse_interval: self.config.inner.minimum_lapse_interval,\n            in_filtered_deck: self.deck.is_filtered(),\n            preview_delays: if let DeckKind::Filtered(deck) = &self.deck.kind {\n                PreviewDelays {\n                    again: deck.preview_again_secs,\n                    hard: deck.preview_hard_secs,\n                    good: deck.preview_good_secs,\n                }\n            } else {\n                Default::default()\n            },\n            fsrs_next_states: self.fsrs_next_states.clone(),\n            fsrs_short_term_with_steps_enabled: self.fsrs_short_term_with_steps,\n            fsrs_allow_short_term: self.fsrs_allow_short_term,\n        }\n    }\n\n    fn learn_steps(&self) -> LearningSteps<'_> {\n        LearningSteps::new(&self.config.inner.learn_steps)\n    }\n\n    fn relearn_steps(&self) -> LearningSteps<'_> {\n        LearningSteps::new(&self.config.inner.relearn_steps)\n    }\n\n    fn secs_until_rollover(&self) -> u32 {\n        self.timing.next_day_at.elapsed_secs_since(self.now) as u32\n    }\n\n    fn into_card(self) -> Card {\n        self.card\n    }\n\n    fn apply_study_state(\n        &mut self,\n        current: CardState,\n        next: CardState,\n    ) -> Result<RevlogEntryPartial> {\n        let revlog = match next {\n            CardState::Normal(normal) => {\n                // transitioning from filtered state?\n                if let CardState::Filtered(filtered) = &current {\n                    match filtered {\n                        FilteredState::Preview(_) => {\n                            invalid_input!(\"should set finished=true, not return different state\")\n                        }\n                        FilteredState::Rescheduling(_) => {\n                            // card needs to be removed from normal filtered deck, then scheduled\n                            // normally\n                            self.card.remove_from_filtered_deck_before_reschedule();\n                        }\n                    }\n                }\n                // apply normal scheduling\n                self.apply_normal_study_state(current, normal)\n            }\n            CardState::Filtered(filtered) => {\n                self.ensure_filtered()?;\n                match filtered {\n                    FilteredState::Preview(next) => self.apply_preview_state(current, next),\n                    FilteredState::Rescheduling(next) => {\n                        let revlog = self.apply_normal_study_state(current, next.original_state);\n                        self.card.original_due = self.card.due;\n\n                        revlog\n                    }\n                }\n            }\n        };\n\n        Ok(revlog)\n    }\n\n    fn apply_normal_study_state(\n        &mut self,\n        current: CardState,\n        next: NormalState,\n    ) -> RevlogEntryPartial {\n        self.card.reps += 1;\n        self.card.desired_retention = self.desired_retention;\n\n        let revlog = match next {\n            NormalState::New(next) => self.apply_new_state(current, next),\n            NormalState::Learning(next) => self.apply_learning_state(current, next),\n            NormalState::Review(next) => self.apply_review_state(current, next),\n            NormalState::Relearning(next) => self.apply_relearning_state(current, next),\n        };\n\n        if next.leeched() && self.config.inner.leech_action() == LeechAction::Suspend {\n            self.card.queue = CardQueue::Suspended;\n        }\n\n        revlog\n    }\n\n    fn ensure_filtered(&self) -> Result<()> {\n        require!(\n            self.card.original_deck_id.0 != 0,\n            \"card answering can't transition into filtered state\",\n        );\n        Ok(())\n    }\n}\n\n#[derive(Debug, Default)]\npub(crate) struct PreviewDelays {\n    pub again: u32,\n    pub hard: u32,\n    pub good: u32,\n}\n\nimpl Rating {\n    fn as_number(self) -> u8 {\n        match self {\n            Rating::Again => 1,\n            Rating::Hard => 2,\n            Rating::Good => 3,\n            Rating::Easy => 4,\n        }\n    }\n}\n\nimpl Collection {\n    /// Return the next states that will be applied for each answer button.\n    pub fn get_scheduling_states(&mut self, cid: CardId) -> Result<SchedulingStates> {\n        let card = self.storage.get_card(cid)?.or_not_found(cid)?;\n        let note_id = card.note_id;\n\n        let ctx = self.card_state_updater(card)?;\n        let current = ctx.current_card_state();\n\n        let load_balancer_ctx = if let Some(load_balancer) = self\n            .state\n            .card_queues\n            .as_ref()\n            .and_then(|card_queues| card_queues.load_balancer.as_ref())\n        {\n            // Only get_deck_config when load balancer is enabled\n            if let Some(deck_config_id) = ctx.deck.config_id() {\n                let note_id = self\n                    .get_deck_config(deck_config_id, false)?\n                    .map(|deck_config| deck_config.inner.bury_reviews)\n                    .unwrap_or(false)\n                    .then_some(note_id);\n                Some(load_balancer.review_context(note_id, deck_config_id))\n            } else {\n                None\n            }\n        } else {\n            None\n        };\n\n        let state_ctx = ctx.state_context(load_balancer_ctx);\n        Ok(current.next_states(&state_ctx))\n    }\n\n    /// Describe the next intervals, to display on the answer buttons.\n    pub fn describe_next_states(&mut self, choices: &SchedulingStates) -> Result<Vec<String>> {\n        let collapse_time = self.learn_ahead_secs();\n        let now = TimestampSecs::now();\n        let timing = self.timing_for_timestamp(now)?;\n        let secs_until_rollover = timing.next_day_at.elapsed_secs_since(now).max(0) as u32;\n\n        Ok(vec![\n            answer_button_time_collapsible(\n                choices\n                    .again\n                    .interval_kind()\n                    .maybe_as_days(secs_until_rollover)\n                    .as_seconds(),\n                collapse_time,\n                &self.tr,\n            ),\n            answer_button_time_collapsible(\n                choices\n                    .hard\n                    .interval_kind()\n                    .maybe_as_days(secs_until_rollover)\n                    .as_seconds(),\n                collapse_time,\n                &self.tr,\n            ),\n            answer_button_time_collapsible(\n                choices\n                    .good\n                    .interval_kind()\n                    .maybe_as_days(secs_until_rollover)\n                    .as_seconds(),\n                collapse_time,\n                &self.tr,\n            ),\n            answer_button_time_collapsible(\n                choices\n                    .easy\n                    .interval_kind()\n                    .maybe_as_days(secs_until_rollover)\n                    .as_seconds(),\n                collapse_time,\n                &self.tr,\n            ),\n        ])\n    }\n\n    /// Answer card, writing its new state to the database.\n    /// Provided [CardAnswer] has its answer time capped to deck preset.\n    pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {\n        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))\n    }\n\n    pub(crate) fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> {\n        let card = self\n            .storage\n            .get_card(answer.card_id)?\n            .or_not_found(answer.card_id)?;\n        let original = card.clone();\n        let usn = self.usn()?;\n\n        let mut updater = self.card_state_updater(card)?;\n        answer.cap_answer_secs(updater.config.inner.cap_answer_time_to_secs);\n        let current_state = updater.current_card_state();\n        // If the states aren't equal, it's probably because some time has passed.\n        // Try to fix this by setting elapsed_secs equal.\n        self.set_elapsed_secs_equal(&current_state, &mut answer.current_state);\n        require!(\n            current_state == answer.current_state,\n            \"card was modified: {current_state:#?} {:#?}\",\n            answer.current_state,\n        );\n\n        let revlog_partial = updater.apply_study_state(current_state, answer.new_state)?;\n        self.add_partial_revlog(revlog_partial, usn, answer)?;\n\n        self.update_deck_stats_from_answer(usn, answer, &updater, original.queue)?;\n        self.maybe_bury_siblings(&original, &updater.config)?;\n        let timing = updater.timing;\n        let deckconfig_id = updater.deck.config_id();\n        let mut card = updater.into_card();\n        if !matches!(\n            answer.current_state,\n            CardState::Filtered(FilteredState::Preview(_))\n        ) {\n            card.last_review_time = Some(answer.answered_at.as_secs());\n        }\n        if let Some(data) = answer.custom_data.take() {\n            card.custom_data = data;\n            card.validate_custom_data()?;\n        }\n\n        self.update_card_inner(&mut card, original, usn)?;\n        if answer.new_state.leeched() {\n            self.add_leech_tag(card.note_id)?;\n        }\n\n        if card.queue == CardQueue::Review {\n            if let Some(load_balancer) = self\n                .state\n                .card_queues\n                .as_mut()\n                .and_then(|card_queues| card_queues.load_balancer.as_mut())\n            {\n                if let Some(deckconfig_id) = deckconfig_id {\n                    load_balancer.add_card(card.id, card.note_id, deckconfig_id, card.interval)\n                }\n            }\n        }\n\n        // Handle queue updates based on from_queue flag\n        if answer.from_queue {\n            self.update_queues_after_answering_card(\n                &card,\n                timing,\n                matches!(\n                    answer.new_state,\n                    CardState::Filtered(FilteredState::Preview(PreviewState {\n                        finished: true,\n                        ..\n                    }))\n                ),\n            )?;\n        }\n\n        Ok(())\n    }\n\n    fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConfig) -> Result<()> {\n        let bury_mode = BuryMode::from_deck_config(config);\n        if bury_mode.any_burying() {\n            self.bury_siblings(card, card.note_id, bury_mode)?;\n        }\n        Ok(())\n    }\n\n    fn add_partial_revlog(\n        &mut self,\n        partial: RevlogEntryPartial,\n        usn: Usn,\n        answer: &CardAnswer,\n    ) -> Result<()> {\n        let revlog = partial.into_revlog_entry(\n            usn,\n            answer.card_id,\n            answer.rating.as_number(),\n            answer.answered_at,\n            answer.milliseconds_taken,\n        );\n        self.add_revlog_entry_undoable(revlog)?;\n        Ok(())\n    }\n\n    fn update_deck_stats_from_answer(\n        &mut self,\n        usn: Usn,\n        answer: &CardAnswer,\n        updater: &CardStateUpdater,\n        from_queue: CardQueue,\n    ) -> Result<()> {\n        let mut new_delta = 0;\n        let mut review_delta = 0;\n        match from_queue {\n            CardQueue::New => new_delta += 1,\n            CardQueue::Review | CardQueue::DayLearn => review_delta += 1,\n            _ => {}\n        }\n        self.update_deck_stats(\n            updater.timing.days_elapsed,\n            usn,\n            anki_proto::scheduler::UpdateStatsRequest {\n                deck_id: updater.deck.id.0,\n                new_delta,\n                review_delta,\n                millisecond_delta: answer.milliseconds_taken as i32,\n            },\n        )\n    }\n\n    fn card_state_updater(&mut self, mut card: Card) -> Result<CardStateUpdater> {\n        let timing = self.timing_today()?;\n        let deck = self\n            .storage\n            .get_deck(card.deck_id)?\n            .or_not_found(card.deck_id)?;\n        let home_deck = if card.original_deck_id.0 == 0 {\n            &deck\n        } else {\n            &self\n                .storage\n                .get_deck(card.original_deck_id)?\n                .or_not_found(card.original_deck_id)?\n        };\n        let config = self\n            .storage\n            .get_deck_config(home_deck.config_id().or_invalid(\"home deck is filtered\")?)?\n            .unwrap_or_default();\n\n        let desired_retention = home_deck.effective_desired_retention(&config);\n        let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs);\n        let fsrs_next_states = if fsrs_enabled {\n            let params = config.fsrs_params();\n            let fsrs = FSRS::new(Some(params))?;\n            card.decay = Some(get_decay_from_params(params));\n            if card.memory_state.is_none() && card.ctype != CardType::New {\n                // Card has been moved or imported into an FSRS deck after params were set,\n                // and will need its initial memory state to be calculated based on review\n                // history.\n                let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?;\n                let item = fsrs_item_for_memory_state(\n                    &fsrs,\n                    revlog,\n                    timing.next_day_at,\n                    config.inner.historical_retention,\n                    ignore_revlogs_before_ms_from_config(&config)?,\n                )?;\n                card.set_memory_state(&fsrs, item, config.inner.historical_retention)?;\n            }\n            let days_elapsed = if let Some(last_review_time) = card.last_review_time {\n                timing.next_day_at.elapsed_days_since(last_review_time) as u32\n            } else {\n                self.storage\n                    .time_of_last_review(card.id)?\n                    .map(|ts| timing.next_day_at.elapsed_days_since(ts))\n                    .unwrap_or_default() as u32\n            };\n            Some(fsrs.next_states(\n                card.memory_state.map(Into::into),\n                desired_retention,\n                days_elapsed,\n            )?)\n        } else {\n            None\n        };\n        let desired_retention = fsrs_enabled.then_some(desired_retention);\n        let fsrs_short_term_with_steps =\n            self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled);\n        let fsrs_allow_short_term = if fsrs_enabled {\n            let params = config.fsrs_params();\n            if params.len() >= 19 {\n                params[17] > 0.0 && params[18] > 0.0\n            } else if params.is_empty() {\n                // fallback to true when using default params\n                true\n            } else {\n                false\n            }\n        } else {\n            false\n        };\n        Ok(CardStateUpdater {\n            fuzz_seed: get_fuzz_seed(&card, false),\n            card,\n            deck,\n            config,\n            timing,\n            now: TimestampSecs::now(),\n            fsrs_next_states,\n            desired_retention,\n            fsrs_short_term_with_steps,\n            fsrs_allow_short_term,\n        })\n    }\n\n    pub(crate) fn home_deck_config(\n        &self,\n        config_id: Option<DeckConfigId>,\n        home_deck_id: DeckId,\n    ) -> Result<DeckConfig> {\n        let config_id = if let Some(config_id) = config_id {\n            config_id\n        } else {\n            let home_deck = self\n                .storage\n                .get_deck(home_deck_id)?\n                .or_not_found(home_deck_id)?;\n            home_deck.config_id().or_invalid(\"home deck is filtered\")?\n        };\n\n        Ok(self.storage.get_deck_config(config_id)?.unwrap_or_default())\n    }\n\n    fn add_leech_tag(&mut self, nid: NoteId) -> Result<()> {\n        self.add_tags_to_notes_inner(&[nid], \"leech\")?;\n        Ok(())\n    }\n\n    /// Update the elapsed time of the answer state to match the current state.\n    ///\n    /// Since the state calculation takes the current time into account, the\n    /// elapsed_secs will probably be different for the two states. This is fine\n    /// for elapsed_secs, but we set the two values equal to easily compare\n    /// the other values of the two states.\n    fn set_elapsed_secs_equal(&self, current_state: &CardState, answer_state: &mut CardState) {\n        if let (Some(current_state), Some(answer_state)) = (\n            match current_state {\n                CardState::Normal(normal_state) => Some(normal_state),\n                CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => {\n                    Some(&resched_filter_state.original_state)\n                }\n                _ => None,\n            },\n            match answer_state {\n                CardState::Normal(normal_state) => Some(normal_state),\n                CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => {\n                    Some(&mut resched_filter_state.original_state)\n                }\n                _ => None,\n            },\n        ) {\n            match (current_state, answer_state) {\n                (NormalState::Learning(answer), NormalState::Learning(current)) => {\n                    current.elapsed_secs = answer.elapsed_secs;\n                }\n                (NormalState::Relearning(answer), NormalState::Relearning(current)) => {\n                    current.learning.elapsed_secs = answer.learning.elapsed_secs;\n                }\n                _ => {} // Other states don't use elapsed_secs.\n            }\n        }\n    }\n}\n\n#[cfg(test)]\npub mod test_helpers {\n    use super::*;\n\n    pub struct PostAnswerState {\n        pub card_id: CardId,\n        pub new_state: CardState,\n    }\n\n    impl Collection {\n        pub(crate) fn answer_again(&mut self) -> PostAnswerState {\n            self.answer(|states| states.again, Rating::Again).unwrap()\n        }\n\n        #[allow(dead_code)]\n        pub(crate) fn answer_hard(&mut self) -> PostAnswerState {\n            self.answer(|states| states.hard, Rating::Hard).unwrap()\n        }\n\n        pub(crate) fn answer_good(&mut self) -> PostAnswerState {\n            self.answer(|states| states.good, Rating::Good).unwrap()\n        }\n\n        pub(crate) fn answer_easy(&mut self) -> PostAnswerState {\n            self.answer(|states| states.easy, Rating::Easy).unwrap()\n        }\n\n        fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>\n        where\n            F: FnOnce(&SchedulingStates) -> CardState,\n        {\n            let queued = self.get_next_card()?.unwrap();\n            let new_state = get_state(&queued.states);\n            self.answer_card(&mut CardAnswer {\n                card_id: queued.card.id,\n                current_state: queued.states.current,\n                new_state,\n                rating,\n                answered_at: TimestampMillis::now(),\n                milliseconds_taken: 0,\n                custom_data: None,\n                from_queue: true,\n            })?;\n            Ok(PostAnswerState {\n                card_id: queued.card.id,\n                new_state,\n            })\n        }\n    }\n}\n\nimpl Card {\n    /// If for_reschedule is true, we use card.reps - 1 to match the previous\n    /// review.\n    pub(crate) fn get_fuzz_factor(&self, for_reschedule: bool) -> Option<f32> {\n        get_fuzz_factor(get_fuzz_seed(self, for_reschedule))\n    }\n}\n\n/// Return a consistent seed for a given card at a given number of reps.\n/// If for_reschedule is true, we use card.reps - 1 to match the previous\n/// review.\npub(crate) fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option<u64> {\n    let reps = if for_reschedule {\n        card.reps.saturating_sub(1)\n    } else {\n        card.reps\n    };\n    get_fuzz_seed_for_id_and_reps(card.id, reps)\n}\n\n/// If in test environment, disable fuzzing.\nfn get_fuzz_seed_for_id_and_reps(card_id: CardId, card_reps: u32) -> Option<u64> {\n    if *crate::PYTHON_UNIT_TESTS || cfg!(test) {\n        None\n    } else {\n        Some((card_id.0 as u64).wrapping_add(card_reps as u64))\n    }\n}\n\n/// Return a fuzz factor from the range `0.0..1.0`, using the provided seed.\n/// None if seed is None.\nfn get_fuzz_factor(seed: Option<u64>) -> Option<f32> {\n    seed.map(|s| StdRng::seed_from_u64(s).random_range(0.0..1.0))\n}\n\n#[cfg(test)]\npub(crate) mod test {\n    use super::*;\n    use crate::card::CardType;\n    use crate::deckconfig::ReviewMix;\n    use crate::search::SortMode;\n\n    fn current_state(col: &mut Collection, card_id: CardId) -> CardState {\n        col.get_scheduling_states(card_id).unwrap().current\n    }\n\n    // Test that deck-specific desired retention is used when available\n    #[test]\n    fn deck_specific_desired_retention() -> Result<()> {\n        let mut col = Collection::new();\n\n        // Enable FSRS\n        col.set_config_bool(BoolKey::Fsrs, true, false)?;\n\n        // Create a deck with specific desired retention\n        let deck_id = DeckId(1);\n        let deck = col.get_deck(deck_id)?.unwrap();\n        let mut deck_clone = (*deck).clone();\n        deck_clone.normal_mut().unwrap().desired_retention = Some(0.85);\n        col.update_deck(&mut deck_clone)?;\n\n        // Create a card in this deck\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, deck_id)?;\n\n        // Get the card using search_cards\n        let cards = col.search_cards(note.id, SortMode::NoOrder)?;\n        let card = col.storage.get_card(cards[0])?.unwrap();\n\n        // Test that the card state updater uses deck-specific desired retention\n        let updater = col.card_state_updater(card)?;\n\n        // Print debug information\n        println!(\"FSRS enabled: {}\", col.get_config_bool(BoolKey::Fsrs));\n        println!(\"Desired retention: {:?}\", updater.desired_retention);\n\n        // Verify that the desired retention is from the deck, not the config\n        assert_eq!(updater.desired_retention, Some(0.85));\n\n        Ok(())\n    }\n\n    // make sure the 'current' state for a card matches the\n    // state we applied to it\n    #[test]\n    fn state_application() -> Result<()> {\n        let mut col = Collection::new();\n        if col.timing_today()?.near_cutoff() {\n            return Ok(());\n        }\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        // new->learning\n        let post_answer = col.answer_again();\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Learn);\n        assert_eq!(card.remaining_steps, 2);\n\n        // learning step\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let post_answer = col.answer_good();\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Learn);\n        assert_eq!(card.remaining_steps, 1);\n\n        // graduation\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let mut post_answer = col.answer_good();\n        // compensate for shifting the due date\n        if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {\n            state.elapsed_days = 1;\n        };\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Review);\n        assert_eq!(card.interval, 1);\n        assert_eq!(card.remaining_steps, 0);\n\n        // answering a review card again; easy boost\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let mut post_answer = col.answer_easy();\n        if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {\n            state.elapsed_days = 4;\n        };\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Review);\n        assert_eq!(card.interval, 4);\n        assert_eq!(card.ease_factor, 2650);\n\n        // lapsing it\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let mut post_answer = col.answer_again();\n        if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {\n            state.review.elapsed_days = 1;\n        };\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Learn);\n        assert_eq!(card.ctype, CardType::Relearn);\n        assert_eq!(card.interval, 1);\n        assert_eq!(card.ease_factor, 2450);\n        assert_eq!(card.lapses, 1);\n\n        // failed in relearning\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let mut post_answer = col.answer_again();\n        if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {\n            state.review.elapsed_days = 1;\n        };\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Learn);\n        assert_eq!(card.lapses, 1);\n\n        // re-graduating\n        col.storage.db.execute_batch(\"update cards set due=0\")?;\n        col.clear_study_queues();\n        let mut post_answer = col.answer_good();\n        if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {\n            state.elapsed_days = 1;\n        };\n        let mut current = current_state(&mut col, post_answer.card_id);\n        col.set_elapsed_secs_equal(&post_answer.new_state, &mut current);\n        assert_eq!(post_answer.new_state, current);\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        assert_eq!(card.queue, CardQueue::Review);\n        assert_eq!(card.interval, 1);\n\n        Ok(())\n    }\n\n    pub(crate) fn v3_test_collection(cards: usize) -> Result<(Collection, Vec<CardId>)> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        for _ in 0..cards {\n            let mut note = Note::new(&nt);\n            col.add_note(&mut note, DeckId(1))?;\n        }\n        let cids = col.search_cards(\"\", SortMode::NoOrder)?;\n        Ok((col, cids))\n    }\n\n    macro_rules! assert_counts {\n        ($col:ident, $new:expr, $learn:expr, $review:expr) => {{\n            let tree = $col.deck_tree(Some(TimestampSecs::now())).unwrap();\n            assert_eq!(tree.new_count, $new);\n            assert_eq!(tree.learn_count, $learn);\n            assert_eq!(tree.review_count, $review);\n            let queued = $col.get_queued_cards(1, false).unwrap();\n            assert_eq!(queued.new_count, $new);\n            assert_eq!(queued.learning_count, $learn);\n            assert_eq!(queued.review_count, $review);\n        }};\n    }\n\n    // FIXME: This fails between 3:50-4:00 GMT\n    #[test]\n    fn new_limited_by_reviews() -> Result<()> {\n        let (mut col, cids) = v3_test_collection(4)?;\n        col.set_due_date(&cids[0..2], \"0\", None)?;\n        // set a limit of 3 reviews, which should give us 2 reviews and 1 new card\n        let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap();\n        conf.inner.reviews_per_day = 3;\n        conf.inner.set_new_mix(ReviewMix::BeforeReviews);\n        col.storage.update_deck_conf(&conf)?;\n\n        assert_counts!(col, 1, 0, 2);\n        // first card is the new card\n        col.answer_good();\n        assert_counts!(col, 0, 1, 2);\n        // then the two reviews\n        col.answer_good();\n        assert_counts!(col, 0, 1, 1);\n        col.answer_good();\n        assert_counts!(col, 0, 1, 0);\n        // after the final 10 minute step, the queues should be empty\n        col.answer_good();\n        assert_counts!(col, 0, 0, 0);\n\n        Ok(())\n    }\n\n    #[test]\n    fn elapsed_secs() -> Result<()> {\n        let mut col = Collection::new();\n        let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        // Need to set col age for interday learning test, arbitrary\n        col.storage\n            .db\n            .execute_batch(\"update col set crt=1686045847\")?;\n        // Fails when near cutoff since it assumes inter- and intraday learning\n        if col.timing_today()?.near_cutoff() {\n            return Ok(());\n        }\n        col.add_note(&mut note, DeckId(1))?;\n        // 5942.7 minutes for just over four days\n        conf.inner.learn_steps = vec![1.0, 10.5, 15.0, 20.0, 5942.7];\n        col.storage.update_deck_conf(&conf)?;\n\n        // Intraday learning, review same day\n        let expected_elapsed_secs = 662;\n        let post_answer = col.answer_good();\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        let shift_due_time = card.due - expected_elapsed_secs;\n        assert_elapsed_secs_approx_equal(\n            &mut col,\n            shift_due_time,\n            post_answer,\n            expected_elapsed_secs,\n        )?;\n\n        // Intraday learning, learn ahead\n        let expected_elapsed_secs = 212;\n        let post_answer = col.answer_good();\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        let shift_due_time = card.due - expected_elapsed_secs;\n        assert_elapsed_secs_approx_equal(\n            &mut col,\n            shift_due_time,\n            post_answer,\n            expected_elapsed_secs,\n        )?;\n\n        // Intraday learning, review two (and some) days later\n        let expected_elapsed_secs = 184092;\n        let post_answer = col.answer_good();\n        let card = col.storage.get_card(post_answer.card_id)?.unwrap();\n        let shift_due_time = card.due - expected_elapsed_secs;\n        assert_elapsed_secs_approx_equal(\n            &mut col,\n            shift_due_time,\n            post_answer,\n            expected_elapsed_secs,\n        )?;\n\n        // Interday learning four (and some) days, review three days late\n        let expected_elapsed_secs = 7 * 86_400;\n        let post_answer = col.answer_good();\n        let now = TimestampSecs::now();\n        let timing = col.timing_for_timestamp(now)?;\n        let col_age = timing.days_elapsed as i32;\n        let shift_due_time = col_age - 3; // Three days late\n        assert_elapsed_secs_approx_equal(\n            &mut col,\n            shift_due_time,\n            post_answer,\n            expected_elapsed_secs,\n        )?;\n\n        Ok(())\n    }\n\n    fn assert_elapsed_secs_approx_equal(\n        col: &mut Collection,\n        shift_due_time: i32,\n        post_answer: test_helpers::PostAnswerState,\n        expected_elapsed_secs: i32,\n    ) -> Result<()> {\n        // Change due time to fake card answer_time,\n        // works since answer_time is calculated as due - last_ivl\n        let update_due_string = format!(\"update cards set due={shift_due_time}\");\n        col.storage.db.execute_batch(&update_due_string)?;\n        col.clear_study_queues();\n        let current_card_state = current_state(col, post_answer.card_id);\n        let state = match current_card_state {\n            CardState::Normal(NormalState::Learning(state)) => state,\n            _ => panic!(\"State is not Normal: {current_card_state:?}\"),\n        };\n        let elapsed_secs = state.elapsed_secs as i32;\n        // Give a 1 second leeway when the test runs on the off chance\n        // that the test runs as a second rolls over.\n        assert!(\n            (elapsed_secs - expected_elapsed_secs).abs() <= 1,\n            \"elapsed_secs: {elapsed_secs} != expected_elapsed_secs: {expected_elapsed_secs}\"\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/preview.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardStateUpdater;\nuse super::RevlogEntryPartial;\nuse crate::card::CardQueue;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::IntervalKind;\nuse crate::scheduler::states::PreviewState;\n\nimpl CardStateUpdater {\n    pub(super) fn apply_preview_state(\n        &mut self,\n        current: CardState,\n        next: PreviewState,\n    ) -> RevlogEntryPartial {\n        let revlog = RevlogEntryPartial::new(current, next.into(), 0.0, self.secs_until_rollover());\n        if next.finished {\n            self.card.remove_from_filtered_deck_restoring_queue();\n            return revlog;\n        }\n\n        self.card.queue = CardQueue::PreviewRepeat;\n\n        let interval = next.interval_kind();\n        match interval {\n            IntervalKind::InSecs(secs) => {\n                self.card.due = self.fuzzed_next_learning_timestamp(secs);\n            }\n            IntervalKind::InDays(_days) => {\n                // unsupported\n            }\n        }\n\n        revlog\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::card::CardType;\n    use crate::prelude::*;\n    use crate::scheduler::answering::CardAnswer;\n    use crate::scheduler::answering::Rating;\n    use crate::scheduler::states::CardState;\n    use crate::scheduler::states::FilteredState;\n    use crate::timestamp::TimestampMillis;\n\n    #[test]\n    fn preview() -> Result<()> {\n        let mut col = Collection::new();\n        let mut c = Card {\n            deck_id: DeckId(1),\n            ctype: CardType::Learn,\n            queue: CardQueue::DayLearn,\n            remaining_steps: 2,\n            due: 123,\n            ..Default::default()\n        };\n        col.add_card(&mut c)?;\n\n        // pull the card into a preview deck\n        let mut filtered_deck = Deck::new_filtered();\n        filtered_deck.filtered_mut()?.reschedule = false;\n        col.add_or_update_deck(&mut filtered_deck)?;\n        assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?.output, 1);\n\n        let next = col.get_scheduling_states(c.id)?;\n        assert!(matches!(\n            next.current,\n            CardState::Filtered(FilteredState::Preview(_))\n        ));\n        // the exit state should have a 0 second interval, which will show up as (end)\n        assert!(matches!(\n            next.easy,\n            CardState::Filtered(FilteredState::Preview(PreviewState {\n                scheduled_secs: 0,\n                finished: true\n            }))\n        ));\n        assert!(matches!(\n            next.good,\n            CardState::Filtered(FilteredState::Preview(PreviewState {\n                scheduled_secs: 0,\n                finished: true\n            }))\n        ));\n\n        // use Again on the preview\n        col.answer_card(&mut CardAnswer {\n            card_id: c.id,\n            current_state: next.current,\n            new_state: next.again,\n            rating: Rating::Again,\n            answered_at: TimestampMillis::now(),\n            milliseconds_taken: 0,\n            custom_data: None,\n            from_queue: true,\n        })?;\n\n        c = col.storage.get_card(c.id)?.unwrap();\n        assert_eq!(c.queue, CardQueue::PreviewRepeat);\n\n        // hard\n        let next = col.get_scheduling_states(c.id)?;\n        col.answer_card(&mut CardAnswer {\n            card_id: c.id,\n            current_state: next.current,\n            new_state: next.hard,\n            rating: Rating::Hard,\n            answered_at: TimestampMillis::now(),\n            milliseconds_taken: 0,\n            custom_data: None,\n            from_queue: true,\n        })?;\n        c = col.storage.get_card(c.id)?.unwrap();\n        assert_eq!(c.queue, CardQueue::PreviewRepeat);\n\n        // and then it should return to its old state once good or easy selected,\n        // with the default filtered config\n        let next = col.get_scheduling_states(c.id)?;\n        col.answer_card(&mut CardAnswer {\n            card_id: c.id,\n            current_state: next.current,\n            new_state: next.good,\n            rating: Rating::Good,\n            answered_at: TimestampMillis::now(),\n            milliseconds_taken: 0,\n            custom_data: None,\n            from_queue: true,\n        })?;\n        c = col.storage.get_card(c.id)?.unwrap();\n        assert_eq!(c.queue, CardQueue::DayLearn);\n        assert_eq!(c.due, 123);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/relearning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardStateUpdater;\nuse super::RevlogEntryPartial;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::IntervalKind;\nuse crate::scheduler::states::RelearnState;\n\nimpl CardStateUpdater {\n    pub(super) fn apply_relearning_state(\n        &mut self,\n        current: CardState,\n        next: RelearnState,\n    ) -> RevlogEntryPartial {\n        self.card.interval = next.review.scheduled_days;\n        self.card.remaining_steps = next.learning.remaining_steps;\n        self.card.ctype = CardType::Relearn;\n        self.card.lapses = next.review.lapses;\n        self.card.ease_factor = (next.review.ease_factor * 1000.0).round() as u16;\n        if let Some(position) = current.new_position() {\n            self.card.original_position = Some(position)\n        }\n        self.card.memory_state = next.learning.memory_state;\n\n        let interval = next\n            .interval_kind()\n            .maybe_as_days(self.secs_until_rollover());\n        match interval {\n            IntervalKind::InSecs(secs) => {\n                self.card.queue = CardQueue::Learn;\n                self.card.due = self.fuzzed_next_learning_timestamp(secs);\n            }\n            IntervalKind::InDays(days) => {\n                self.card.queue = CardQueue::DayLearn;\n                self.card.due = (self.timing.days_elapsed + days) as i32;\n            }\n        }\n\n        RevlogEntryPartial::new(\n            current,\n            next.into(),\n            self.card\n                .memory_state\n                .map(|d| d.difficulty_shifted())\n                .unwrap_or(next.review.ease_factor),\n            self.secs_until_rollover(),\n        )\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/review.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardStateUpdater;\nuse super::RevlogEntryPartial;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::ReviewState;\n\nimpl CardStateUpdater {\n    pub(super) fn apply_review_state(\n        &mut self,\n        current: CardState,\n        next: ReviewState,\n    ) -> RevlogEntryPartial {\n        self.card.queue = CardQueue::Review;\n        self.card.ctype = CardType::Review;\n        self.card.interval = next.scheduled_days;\n        self.card.due = (self.timing.days_elapsed + next.scheduled_days) as i32;\n        self.card.ease_factor = (next.ease_factor * 1000.0).round() as u16;\n        self.card.lapses = next.lapses;\n        self.card.remaining_steps = 0;\n        if let Some(position) = current.new_position() {\n            self.card.original_position = Some(position)\n        }\n        self.card.memory_state = next.memory_state;\n\n        RevlogEntryPartial::new(\n            current,\n            next.into(),\n            self.card\n                .memory_state\n                .map(|d| d.difficulty_shifted())\n                .unwrap_or(next.ease_factor),\n            self.secs_until_rollover(),\n        )\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/answering/revlog.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::revlog::RevlogReviewKind;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::IntervalKind;\n\npub struct RevlogEntryPartial {\n    interval: IntervalKind,\n    last_interval: IntervalKind,\n    ease_factor: f32,\n    review_kind: RevlogReviewKind,\n}\n\nimpl RevlogEntryPartial {\n    pub(super) fn new(\n        current: CardState,\n        next: CardState,\n        ease_factor: f32,\n        secs_until_rollover: u32,\n    ) -> Self {\n        let next_interval = next.interval_kind().maybe_as_days(secs_until_rollover);\n        let current_interval = current.interval_kind().maybe_as_days(secs_until_rollover);\n\n        RevlogEntryPartial {\n            interval: next_interval,\n            last_interval: current_interval,\n            ease_factor,\n            review_kind: current.revlog_kind(),\n        }\n    }\n\n    pub(super) fn into_revlog_entry(\n        self,\n        usn: Usn,\n        cid: CardId,\n        button_chosen: u8,\n        answered_at: TimestampMillis,\n        taken_millis: u32,\n    ) -> RevlogEntry {\n        RevlogEntry {\n            id: answered_at.into(),\n            cid,\n            usn,\n            button_chosen,\n            interval: self.interval.as_revlog_interval(),\n            last_interval: self.last_interval.as_revlog_interval(),\n            ease_factor: (self.ease_factor * 1000.0).round() as u32,\n            taken_millis,\n            review_kind: self.review_kind,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/bury_and_suspend.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::scheduler::bury_or_suspend_cards_request::Mode as BuryOrSuspendMode;\nuse anki_proto::scheduler::unbury_deck_request::Mode as UnburyDeckMode;\n\nuse super::queue::BuryMode;\nuse super::timing::SchedTimingToday;\nuse crate::card::CardQueue;\nuse crate::config::SchedulerVersion;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::SearchNode;\nuse crate::search::StateKind;\n\nimpl Card {\n    /// True if card was buried/suspended prior to the call.\n    pub(crate) fn restore_queue_after_bury_or_suspend(&mut self) -> bool {\n        if !matches!(\n            self.queue,\n            CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried\n        ) {\n            false\n        } else {\n            self.restore_queue_from_type();\n            true\n        }\n    }\n}\n\nimpl Collection {\n    pub(crate) fn unbury_if_day_rolled_over(&mut self, timing: SchedTimingToday) -> Result<()> {\n        let last_unburied = self.get_last_unburied_day();\n        let today = timing.days_elapsed;\n        if last_unburied < today || (today + 7) < last_unburied {\n            self.unbury_on_day_rollover(today)?;\n        }\n\n        Ok(())\n    }\n\n    /// Unbury cards from the previous day.\n    /// Done automatically, and does not mark the cards as modified.\n    pub(crate) fn unbury_on_day_rollover(&mut self, today: u32) -> Result<()> {\n        self.for_each_card_in_search(StateKind::Buried, |col, mut card| {\n            card.restore_queue_after_bury_or_suspend();\n            col.storage.update_card(&card)\n        })?;\n        self.set_last_unburied_day(today)\n    }\n\n    /// Unsuspend/unbury cards. Marks the cards as modified.\n    fn unsuspend_or_unbury_searched_cards(&mut self, cards: Vec<Card>) -> Result<()> {\n        let usn = self.usn()?;\n        for original in cards {\n            let mut card = original.clone();\n            if card.restore_queue_after_bury_or_suspend() {\n                self.update_card_inner(&mut card, original, usn)?;\n            }\n        }\n        Ok(())\n    }\n\n    pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardId]) -> Result<OpOutput<()>> {\n        self.transact(Op::UnburyUnsuspend, |col| {\n            let cards = col.all_cards_for_ids(cids, false)?;\n            col.unsuspend_or_unbury_searched_cards(cards)\n        })\n    }\n\n    pub fn unbury_deck(&mut self, deck_id: DeckId, mode: UnburyDeckMode) -> Result<OpOutput<()>> {\n        let state = match mode {\n            UnburyDeckMode::All => StateKind::Buried,\n            UnburyDeckMode::UserOnly => StateKind::UserBuried,\n            UnburyDeckMode::SchedOnly => StateKind::SchedBuried,\n        };\n        self.transact(Op::UnburyUnsuspend, |col| {\n            let cards =\n                col.all_cards_for_search(SearchNode::DeckIdWithChildren(deck_id).and(state))?;\n            col.unsuspend_or_unbury_searched_cards(cards)\n        })\n    }\n\n    /// Marks the cards as modified.\n    fn bury_or_suspend_cards_inner(\n        &mut self,\n        cards: Vec<Card>,\n        mode: BuryOrSuspendMode,\n    ) -> Result<usize> {\n        let mut count = 0;\n        let usn = self.usn()?;\n        let sched = self.scheduler_version();\n        if sched == SchedulerVersion::V1 {\n            return Err(AnkiError::SchedulerUpgradeRequired);\n        }\n        let desired_queue = match mode {\n            BuryOrSuspendMode::Suspend => CardQueue::Suspended,\n            BuryOrSuspendMode::BurySched => CardQueue::SchedBuried,\n            BuryOrSuspendMode::BuryUser => CardQueue::UserBuried,\n        };\n\n        for original in cards {\n            let mut card = original.clone();\n            if card.queue != desired_queue {\n                // do not bury suspended cards as that would unsuspend them\n                if card.queue != CardQueue::Suspended {\n                    card.queue = desired_queue;\n                    count += 1;\n                    self.update_card_inner(&mut card, original, usn)?;\n                }\n            }\n        }\n\n        Ok(count)\n    }\n\n    pub fn bury_or_suspend_cards(\n        &mut self,\n        cids: &[CardId],\n        mode: BuryOrSuspendMode,\n    ) -> Result<OpOutput<usize>> {\n        let op = match mode {\n            BuryOrSuspendMode::Suspend => Op::Suspend,\n            BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury,\n        };\n        self.transact(op, |col| {\n            let cards = col.all_cards_for_ids(cids, false)?;\n            col.bury_or_suspend_cards_inner(cards, mode)\n        })\n    }\n\n    pub(crate) fn bury_siblings(\n        &mut self,\n        card: &Card,\n        nid: NoteId,\n        mut bury_mode: BuryMode,\n    ) -> Result<usize> {\n        bury_mode.exclude_earlier_gathered_queues(card.queue);\n        let cards = self\n            .storage\n            .all_siblings_for_bury(card.id, nid, bury_mode)?;\n        self.bury_or_suspend_cards_inner(cards, BuryOrSuspendMode::BurySched)\n    }\n}\n\nimpl BuryMode {\n    /// Disables burying for queues gathered before `queue`.\n    fn exclude_earlier_gathered_queues(&mut self, queue: CardQueue) {\n        self.bury_interday_learning &= queue.gather_ord() <= CardQueue::DayLearn.gather_ord();\n        self.bury_reviews &= queue.gather_ord() <= CardQueue::Review.gather_ord();\n    }\n}\n\nimpl CardQueue {\n    fn gather_ord(self) -> u8 {\n        match self {\n            CardQueue::Learn | CardQueue::PreviewRepeat => 0,\n            CardQueue::DayLearn => 1,\n            CardQueue::Review => 2,\n            CardQueue::New => 3,\n            // not gathered\n            CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => u8::MAX,\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::card::Card;\n    use crate::card::CardQueue;\n    use crate::collection::Collection;\n    use crate::search::SortMode;\n    use crate::search::StateKind;\n\n    #[test]\n    fn unbury() {\n        let mut col = Collection::new();\n        let mut card = Card {\n            queue: CardQueue::UserBuried,\n            ..Default::default()\n        };\n        col.add_card(&mut card).unwrap();\n        let assert_count = |col: &mut Collection, cnt| {\n            assert_eq!(\n                col.search_cards(StateKind::Buried, SortMode::NoOrder)\n                    .unwrap()\n                    .len(),\n                cnt\n            );\n        };\n        assert_count(&mut col, 1);\n        // day 0, last unburied 0, so no change\n        let timing = col.timing_today().unwrap();\n        col.unbury_if_day_rolled_over(timing).unwrap();\n        assert_count(&mut col, 1);\n        // move creation time back and it should succeed\n        let mut stamp = col.storage.creation_stamp().unwrap();\n        stamp.0 -= 86_400;\n        col.set_creation_stamp(stamp).unwrap();\n        let timing = col.timing_today().unwrap();\n        col.unbury_if_day_rolled_over(timing).unwrap();\n        assert_count(&mut col, 0);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/congrats.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) struct CongratsInfo {\n    pub learn_count: u32,\n    pub next_learn_due: u32,\n    pub review_remaining: bool,\n    pub new_remaining: bool,\n    pub have_sched_buried: bool,\n    pub have_user_buried: bool,\n}\n\nimpl Collection {\n    pub fn congrats_info(&mut self) -> Result<anki_proto::scheduler::CongratsInfoResponse> {\n        let deck = self.get_current_deck()?;\n        let today = self.timing_today()?.days_elapsed;\n        let info = self.storage.congrats_info(&deck, today)?;\n        let is_filtered_deck = deck.is_filtered();\n        let deck_description = deck.rendered_description();\n        let secs_until_next_learn = if info.next_learn_due == 0 {\n            // signal to the frontend that no learning cards are due later\n            86_400\n        } else {\n            ((info.next_learn_due as i64) - self.learn_ahead_secs() as i64 - TimestampSecs::now().0)\n                .max(60) as u32\n        };\n        Ok(anki_proto::scheduler::CongratsInfoResponse {\n            learn_remaining: info.learn_count,\n            review_remaining: info.review_remaining,\n            new_remaining: info.new_remaining,\n            have_sched_buried: info.have_sched_buried,\n            have_user_buried: info.have_user_buried,\n            is_filtered_deck,\n            secs_until_next_learn,\n            bridge_commands_supported: true,\n            deck_description,\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn empty() {\n        let mut col = Collection::new();\n        let info = col.congrats_info().unwrap();\n        assert_eq!(\n            info,\n            anki_proto::scheduler::CongratsInfoResponse {\n                learn_remaining: 0,\n                review_remaining: false,\n                new_remaining: false,\n                have_sched_buried: false,\n                have_user_buried: false,\n                is_filtered_deck: false,\n                secs_until_next_learn: 86_400,\n                bridge_commands_supported: true,\n                deck_description: \"\".to_string()\n            }\n        )\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/filtered/card.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::DeckFilterContext;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::prelude::*;\nuse crate::scheduler::timing::is_unix_epoch_timestamp;\n\nimpl Card {\n    pub(crate) fn restore_queue_from_type(&mut self) {\n        self.queue = match self.ctype {\n            CardType::Learn | CardType::Relearn => {\n                if is_unix_epoch_timestamp(self.due) {\n                    // unix timestamp\n                    CardQueue::Learn\n                } else {\n                    // day number\n                    CardQueue::DayLearn\n                }\n            }\n            CardType::New => CardQueue::New,\n            CardType::Review => CardQueue::Review,\n        }\n    }\n\n    pub(crate) fn move_into_filtered_deck(&mut self, ctx: &DeckFilterContext, position: i32) {\n        // filtered and v1 learning cards are excluded, so odue should be guaranteed to\n        // be zero\n        if self.original_due != 0 {\n            println!(\"bug: odue was set\");\n            return;\n        }\n\n        self.original_deck_id = self.deck_id;\n        self.deck_id = ctx.target_deck;\n\n        self.original_due = self.due;\n\n        // if rescheduling is disabled, all cards go in the review queue\n        if !ctx.config.reschedule {\n            self.queue = CardQueue::Review;\n        }\n        if self.due > 0 {\n            self.due = position;\n        }\n    }\n\n    /// Restores to the original deck and clears original_due.\n    /// This does not update the queue or type, so should only be used as\n    /// part of an operation that adjusts those separately.\n    pub(crate) fn remove_from_filtered_deck_before_reschedule(&mut self) {\n        if self.original_deck_id.0 != 0 {\n            self.deck_id = self.original_deck_id;\n            self.original_deck_id.0 = 0;\n            self.original_due = 0;\n        }\n    }\n\n    pub(crate) fn original_or_current_deck_id(&self) -> DeckId {\n        self.original_deck_id.or(self.deck_id)\n    }\n\n    pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self) {\n        if self.original_deck_id.0 == 0 {\n            // not in a filtered deck\n            return;\n        }\n\n        self.deck_id = self.original_deck_id;\n        self.original_deck_id.0 = 0;\n\n        if self.original_due != 0 {\n            self.due = self.original_due;\n        }\n\n        if (self.queue as i8) >= 0 {\n            self.restore_queue_from_type();\n        }\n\n        self.original_due = 0;\n    }\n\n    pub(crate) fn is_filtered(&self) -> bool {\n        self.original_deck_id.0 > 0\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/filtered/custom_study.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\n\nuse anki_proto::scheduler::custom_study_request::cram::CramKind;\nuse anki_proto::scheduler::custom_study_request::Cram;\nuse anki_proto::scheduler::custom_study_request::Value as CustomStudyValue;\n\nuse super::FilteredDeckForUpdate;\nuse crate::config::DeckConfigKey;\nuse crate::decks::tree::get_deck_in_tree;\nuse crate::decks::tree::sum_deck_tree_node;\nuse crate::decks::FilteredDeck;\nuse crate::decks::FilteredSearchOrder;\nuse crate::decks::FilteredSearchTerm;\nuse crate::error::CustomStudyError;\nuse crate::error::FilteredDeckError;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::Negated;\nuse crate::search::PropertyKind;\nuse crate::search::RatingKind;\nuse crate::search::SearchNode;\nuse crate::search::StateKind;\n\nimpl Collection {\n    pub fn custom_study(\n        &mut self,\n        input: anki_proto::scheduler::CustomStudyRequest,\n    ) -> Result<OpOutput<()>> {\n        self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input))\n    }\n\n    pub fn custom_study_defaults(\n        &mut self,\n        deck_id: DeckId,\n    ) -> Result<anki_proto::scheduler::CustomStudyDefaultsResponse> {\n        // daily counts\n        let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?;\n        let normal = deck.normal()?;\n        let extend_new = normal.extend_new;\n        let extend_review = normal.extend_review;\n\n        let subtree = get_deck_in_tree(self.deck_tree(Some(TimestampSecs::now()))?, deck_id)\n            .or_not_found(deck_id)?;\n        let available_new_including_children =\n            sum_deck_tree_node(&subtree, |node| node.new_uncapped);\n        let available_review_including_children =\n            sum_deck_tree_node(&subtree, |node| node.review_uncapped);\n        let (\n            available_new,\n            available_new_in_children,\n            available_review,\n            available_review_in_children,\n        ) = (\n            subtree.new_uncapped,\n            available_new_including_children - subtree.new_uncapped,\n            subtree.review_uncapped,\n            available_review_including_children - subtree.review_uncapped,\n        );\n        // tags\n        let include_tags: HashSet<String> = self.get_config_default(\n            DeckConfigKey::CustomStudyIncludeTags\n                .for_deck(deck_id)\n                .as_str(),\n        );\n        let exclude_tags: HashSet<String> = self.get_config_default(\n            DeckConfigKey::CustomStudyExcludeTags\n                .for_deck(deck_id)\n                .as_str(),\n        );\n        let mut all_tags: Vec<_> = self.all_tags_in_deck(deck_id)?.into_iter().collect();\n        all_tags.sort_unstable();\n        let tags: Vec<anki_proto::scheduler::custom_study_defaults_response::Tag> = all_tags\n            .into_iter()\n            .map(|tag| {\n                let tag = tag.into_inner();\n                anki_proto::scheduler::custom_study_defaults_response::Tag {\n                    include: include_tags.contains(&tag),\n                    exclude: exclude_tags.contains(&tag),\n                    name: tag,\n                }\n            })\n            .collect();\n\n        Ok(anki_proto::scheduler::CustomStudyDefaultsResponse {\n            tags,\n            extend_new,\n            extend_review,\n            available_new,\n            available_review,\n            available_new_in_children,\n            available_review_in_children,\n        })\n    }\n}\n\nimpl Collection {\n    fn custom_study_inner(\n        &mut self,\n        input: anki_proto::scheduler::CustomStudyRequest,\n    ) -> Result<()> {\n        let mut deck = self\n            .storage\n            .get_deck(input.deck_id.into())?\n            .or_not_found(input.deck_id)?;\n\n        match input.value.or_invalid(\"missing oneof value\")? {\n            CustomStudyValue::NewLimitDelta(delta) => {\n                let today = self.current_due_day(0)?;\n                self.extend_limits(today, self.usn()?, deck.id, delta, 0)?;\n                if delta > 0 {\n                    deck = self.storage.get_deck(deck.id)?.or_not_found(deck.id)?;\n                    let original = deck.clone();\n                    deck.normal_mut()?.extend_new = delta as u32;\n                    self.update_deck_inner(&mut deck, original, self.usn()?)?;\n                }\n                Ok(())\n            }\n            CustomStudyValue::ReviewLimitDelta(delta) => {\n                let today = self.current_due_day(0)?;\n                self.extend_limits(today, self.usn()?, deck.id, 0, delta)?;\n                if delta > 0 {\n                    deck = self.storage.get_deck(deck.id)?.or_not_found(deck.id)?;\n                    let original = deck.clone();\n                    deck.normal_mut()?.extend_review = delta as u32;\n                    self.update_deck_inner(&mut deck, original, self.usn()?)?;\n                }\n                Ok(())\n            }\n            CustomStudyValue::ForgotDays(days) => {\n                self.create_custom_study_deck(forgot_config(deck.human_name(), days))\n            }\n            CustomStudyValue::ReviewAheadDays(days) => {\n                self.create_custom_study_deck(ahead_config(deck.human_name(), days))\n            }\n            CustomStudyValue::PreviewDays(days) => {\n                self.create_custom_study_deck(preview_config(deck.human_name(), days))\n            }\n            CustomStudyValue::Cram(cram) => {\n                self.create_custom_study_deck(cram_config(deck.human_name(), &cram)?)?;\n                self.set_config(\n                    DeckConfigKey::CustomStudyIncludeTags\n                        .for_deck(deck.id)\n                        .as_str(),\n                    &cram.tags_to_include,\n                )?;\n                self.set_config(\n                    DeckConfigKey::CustomStudyExcludeTags\n                        .for_deck(deck.id)\n                        .as_str(),\n                    &cram.tags_to_exclude,\n                )?;\n                Ok(())\n            }\n        }\n    }\n\n    /// Reuse existing one or create new one if missing.\n    /// Guaranteed to be a filtered deck.\n    fn create_custom_study_deck(&mut self, config: FilteredDeck) -> Result<()> {\n        let mut id = DeckId(0);\n        let human_name = self.tr.custom_study_custom_study_session().to_string();\n\n        if let Some(did) = self.get_deck_id(&human_name)? {\n            if !self.get_deck(did)?.or_not_found(did)?.is_filtered() {\n                return Err(CustomStudyError::ExistingDeck.into());\n            }\n            id = did;\n        }\n\n        let deck = FilteredDeckForUpdate {\n            id,\n            human_name,\n            config,\n            allow_empty: false,\n        };\n\n        self.add_or_update_filtered_deck_inner(deck)\n            .map(|_| ())\n            .map_err(|err| {\n                if matches!(\n                    err,\n                    AnkiError::FilteredDeckError {\n                        source: FilteredDeckError::SearchReturnedNoCards\n                    }\n                ) {\n                    CustomStudyError::NoMatchingCards.into()\n                } else {\n                    err\n                }\n            })\n    }\n}\n\nfn custom_study_config(\n    reschedule: bool,\n    search: String,\n    order: FilteredSearchOrder,\n    limit: Option<u32>,\n) -> FilteredDeck {\n    FilteredDeck {\n        reschedule,\n        search_terms: vec![FilteredSearchTerm {\n            search,\n            limit: limit.unwrap_or(99_999),\n            order: order as i32,\n        }],\n        delays: vec![],\n        preview_delay: 10,\n        preview_again_secs: 60,\n        preview_hard_secs: 600,\n        preview_good_secs: 0,\n    }\n}\n\nfn forgot_config(deck_name: String, days: u32) -> FilteredDeck {\n    let search = SearchNode::Rated {\n        days,\n        ease: RatingKind::AnswerButton(1),\n    }\n    .and(SearchNode::from_deck_name(&deck_name))\n    .write();\n    custom_study_config(false, search, FilteredSearchOrder::Random, None)\n}\n\nfn ahead_config(deck_name: String, days: u32) -> FilteredDeck {\n    let search = SearchNode::Property {\n        operator: \"<=\".to_string(),\n        kind: PropertyKind::Due(days as i32),\n    }\n    .and(SearchNode::from_deck_name(&deck_name))\n    .write();\n    custom_study_config(true, search, FilteredSearchOrder::Due, None)\n}\n\nfn preview_config(deck_name: String, days: u32) -> FilteredDeck {\n    let search = StateKind::New\n        .and_flat(SearchNode::AddedInDays(days))\n        .and_flat(SearchNode::from_deck_name(&deck_name))\n        .write();\n    custom_study_config(false, search, FilteredSearchOrder::Added, None)\n}\n\nfn cram_config(deck_name: String, cram: &Cram) -> Result<FilteredDeck> {\n    let (reschedule, nodes, order) = match cram.kind() {\n        CramKind::New => (\n            true,\n            SearchBuilder::from(StateKind::New),\n            FilteredSearchOrder::Added,\n        ),\n        CramKind::Due => (\n            true,\n            SearchBuilder::from(StateKind::Due),\n            FilteredSearchOrder::Due,\n        ),\n        CramKind::Review => (\n            true,\n            SearchBuilder::from(StateKind::New.negated()),\n            FilteredSearchOrder::Random,\n        ),\n        CramKind::All => (false, SearchBuilder::new(), FilteredSearchOrder::Random),\n    };\n\n    let search = nodes\n        .and(SearchNode::from_deck_name(&deck_name))\n        .and_flat(tags_to_nodes(&cram.tags_to_include, &cram.tags_to_exclude))\n        .write();\n\n    Ok(custom_study_config(\n        reschedule,\n        search,\n        order,\n        Some(cram.card_limit),\n    ))\n}\n\nfn tags_to_nodes(tags_to_include: &[String], tags_to_exclude: &[String]) -> SearchBuilder {\n    let include_nodes = SearchBuilder::any(\n        tags_to_include\n            .iter()\n            .map(|tag| SearchNode::from_tag_name(tag)),\n    );\n    let exclude_nodes = SearchBuilder::all(\n        tags_to_exclude\n            .iter()\n            .map(|tag| SearchNode::from_tag_name(tag).negated()),\n    );\n\n    include_nodes.and(exclude_nodes)\n}\n\n#[cfg(test)]\nmod test {\n    use anki_proto::scheduler::custom_study_request::cram::CramKind;\n    use anki_proto::scheduler::custom_study_request::Cram;\n    use anki_proto::scheduler::custom_study_request::Value;\n    use anki_proto::scheduler::CustomStudyRequest;\n\n    use super::*;\n\n    #[test]\n    fn tag_remembering() -> Result<()> {\n        let mut col = Collection::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.tags\n            .extend_from_slice(&[\"3\".to_string(), \"1\".to_string(), \"2::two\".to_string()]);\n        col.add_note(&mut note, DeckId(1))?;\n        let mut note = nt.new_note();\n        note.tags\n            .extend_from_slice(&[\"1\".to_string(), \"2::two\".to_string()]);\n        col.add_note(&mut note, DeckId(1))?;\n\n        fn get_defaults(col: &mut Collection) -> Result<Vec<(&'static str, bool, bool)>> {\n            Ok(col\n                .custom_study_defaults(DeckId(1))?\n                .tags\n                .into_iter()\n                .map(|tag| {\n                    (\n                        // cheekily leak the string so we have a static ref for comparison\n                        &*Box::leak(tag.name.into_boxed_str()),\n                        tag.include,\n                        tag.exclude,\n                    )\n                })\n                .collect())\n        }\n\n        // nothing should be included/excluded by default\n        assert_eq!(\n            &get_defaults(&mut col)?,\n            &[\n                (\"1\", false, false),\n                (\"2::two\", false, false),\n                (\"3\", false, false)\n            ]\n        );\n\n        // if filtered deck creation fails, inclusions/exclusions don't change\n        let mut cram = Cram {\n            kind: CramKind::All as i32,\n            card_limit: 0,\n            tags_to_include: vec![\"2::two\".to_string()],\n            tags_to_exclude: vec![\"3\".to_string()],\n        };\n        assert_eq!(\n            col.custom_study(CustomStudyRequest {\n                deck_id: 1,\n                value: Some(Value::Cram(cram.clone())),\n            }),\n            Err(AnkiError::CustomStudyError {\n                source: CustomStudyError::NoMatchingCards\n            })\n        );\n        assert_eq!(\n            &get_defaults(&mut col)?,\n            &[\n                (\"1\", false, false),\n                (\"2::two\", false, false),\n                (\"3\", false, false)\n            ]\n        );\n\n        // a successful build should update tags\n        cram.card_limit = 100;\n        col.custom_study(CustomStudyRequest {\n            deck_id: 1,\n            value: Some(Value::Cram(cram)),\n        })?;\n        assert_eq!(\n            &get_defaults(&mut col)?,\n            &[\n                (\"1\", false, false),\n                (\"2::two\", true, false),\n                (\"3\", false, true)\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn sql_grouping() -> Result<()> {\n        let mut deck = preview_config(\"d\".into(), 1);\n        assert_eq!(&deck.search_terms[0].search, \"is:new added:1 deck:d\");\n\n        let cram = Cram {\n            tags_to_include: vec![\"1\".into(), \"2\".into()],\n            tags_to_exclude: vec![\"3\".into(), \"4\".into()],\n            ..Default::default()\n        };\n        deck = cram_config(\"d\".into(), &cram)?;\n        assert_eq!(\n            &deck.search_terms[0].search,\n            \"is:due deck:d (tag:1 OR tag:2) (-tag:3 -tag:4)\"\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/filtered/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod card;\nmod custom_study;\n\nuse crate::config::ConfigKey;\nuse crate::config::SchedulerVersion;\nuse crate::decks::FilteredDeck;\nuse crate::decks::FilteredSearchTerm;\nuse crate::error::FilteredDeckError;\nuse crate::prelude::*;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::search::writer::deck_search;\nuse crate::search::writer::normalize_search;\nuse crate::search::SortMode;\nuse crate::storage::card::filtered::order_and_limit_for_search;\n\n/// Contains the parts of a filtered deck required for modifying its settings in\n/// the UI.\npub struct FilteredDeckForUpdate {\n    pub id: DeckId,\n    pub human_name: String,\n    pub config: FilteredDeck,\n    pub allow_empty: bool,\n}\n\npub(crate) struct DeckFilterContext<'a> {\n    pub target_deck: DeckId,\n    pub config: &'a FilteredDeck,\n    pub usn: Usn,\n    pub timing: SchedTimingToday,\n}\n\nimpl Collection {\n    /// Get an existing filtered deck, or create a new one if `deck_id` is 0.\n    /// The new deck will not be added to the DB.\n    pub fn get_or_create_filtered_deck(\n        &mut self,\n        deck_id: DeckId,\n    ) -> Result<FilteredDeckForUpdate> {\n        let deck = if deck_id.0 == 0 {\n            self.new_filtered_deck_for_adding()?\n        } else {\n            self.storage.get_deck(deck_id)?.or_not_found(deck_id)?\n        };\n\n        deck.try_into()\n    }\n\n    /// If the provided `deck_id` is 0, add provided deck to the DB, and rebuild\n    /// it. If the searches are invalid or do not match anything, adding is\n    /// aborted. If an existing deck is provided, it will be updated.\n    /// Invalid searches or an empty match will abort the update.\n    /// Returns the deck_id, which will have changed if the id was 0.\n    pub fn add_or_update_filtered_deck(\n        &mut self,\n        deck: FilteredDeckForUpdate,\n    ) -> Result<OpOutput<DeckId>> {\n        self.transact(Op::BuildFilteredDeck, |col| {\n            col.add_or_update_filtered_deck_inner(deck)\n        })\n    }\n\n    pub fn empty_filtered_deck(&mut self, did: DeckId) -> Result<OpOutput<()>> {\n        self.transact(Op::EmptyFilteredDeck, |col| {\n            let deck = col.get_deck(did)?.or_not_found(did)?;\n            col.return_all_cards_in_filtered_deck(&deck)\n        })\n    }\n\n    // Unlike the old Python code, this also marks the cards as modified.\n    pub fn rebuild_filtered_deck(&mut self, did: DeckId) -> Result<OpOutput<usize>> {\n        self.transact(Op::RebuildFilteredDeck, |col| {\n            let deck = col.get_deck(did)?.or_not_found(did)?;\n            col.rebuild_filtered_deck_inner(&deck, col.usn()?)\n        })\n    }\n}\n\nimpl Collection {\n    pub(crate) fn return_all_cards_in_filtered_deck(&mut self, deck: &Deck) -> Result<()> {\n        if !deck.is_filtered() {\n            return Err(FilteredDeckError::FilteredDeckRequired.into());\n        }\n        let cids = self.storage.all_cards_in_single_deck(deck.id)?;\n        self.return_cards_to_home_deck(&cids)\n    }\n\n    // Unlike the old Python code, this also marks the cards as modified.\n    fn return_cards_to_home_deck(&mut self, cids: &[CardId]) -> Result<()> {\n        let usn = self.usn()?;\n        for cid in cids {\n            if let Some(mut card) = self.storage.get_card(*cid)? {\n                let original = card.clone();\n                card.remove_from_filtered_deck_restoring_queue();\n                self.update_card_inner(&mut card, original, usn)?;\n            }\n        }\n        Ok(())\n    }\n\n    fn build_filtered_deck(&mut self, ctx: DeckFilterContext) -> Result<usize> {\n        let start = -100_000;\n        let mut position = start;\n        let fsrs = self.get_config_bool(BoolKey::Fsrs);\n        for term in ctx.config.search_terms.iter().take(2) {\n            position = self.move_cards_matching_term(&ctx, term, position, fsrs)?;\n        }\n\n        Ok((position - start) as usize)\n    }\n\n    /// Move matching cards into filtered deck.\n    /// Returns the new starting position.\n    fn move_cards_matching_term(\n        &mut self,\n        ctx: &DeckFilterContext,\n        term: &FilteredSearchTerm,\n        mut position: i32,\n        fsrs: bool,\n    ) -> Result<i32> {\n        let search = format!(\n            \"{} -is:suspended -is:buried -deck:filtered\",\n            if term.search.trim().is_empty() {\n                \"\".to_string()\n            } else {\n                format!(\"({})\", term.search)\n            }\n        );\n        let order = order_and_limit_for_search(term, ctx.timing, fsrs);\n\n        for mut card in self.all_cards_for_search_in_order(&search, SortMode::Custom(order))? {\n            let original = card.clone();\n            card.move_into_filtered_deck(ctx, position);\n            self.update_card_inner(&mut card, original, ctx.usn)?;\n            position += 1;\n        }\n\n        Ok(position)\n    }\n\n    fn get_next_filtered_deck_name(&self) -> NativeDeckName {\n        NativeDeckName::from_native_str(format!(\n            \"Filtered Deck {}\",\n            TimestampSecs::now().time_string()\n        ))\n    }\n\n    fn add_or_update_filtered_deck_inner(\n        &mut self,\n        mut update: FilteredDeckForUpdate,\n    ) -> Result<DeckId> {\n        let usn = self.usn()?;\n        let allow_empty = update.allow_empty;\n\n        // check the searches are valid, and normalize them\n        for term in &mut update.config.search_terms {\n            term.search = normalize_search(&term.search)?\n        }\n\n        // add or update the deck\n        let mut deck: Deck;\n        if update.id.0 == 0 {\n            deck = Deck::new_filtered();\n            apply_update_to_filtered_deck(&mut deck, update);\n            self.add_deck_inner(&mut deck, usn)?;\n        } else {\n            let original = self.storage.get_deck(update.id)?.or_not_found(update.id)?;\n            deck = original.clone();\n            apply_update_to_filtered_deck(&mut deck, update);\n            self.update_deck_inner(&mut deck, original, usn)?;\n        }\n\n        // rebuild it\n        let count = self.rebuild_filtered_deck_inner(&deck, usn)?;\n\n        // if it failed to match any cards, we revert the changes\n        if count == 0 && !allow_empty {\n            Err(FilteredDeckError::SearchReturnedNoCards.into())\n        } else {\n            // update current deck and return id\n            self.set_config(ConfigKey::CurrentDeckId, &deck.id)?;\n            Ok(deck.id)\n        }\n    }\n\n    fn rebuild_filtered_deck_inner(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {\n        if self.scheduler_version() == SchedulerVersion::V1 {\n            return Err(AnkiError::SchedulerUpgradeRequired);\n        }\n\n        let config = deck.filtered()?;\n        let timing = self.timing_today()?;\n        let ctx = DeckFilterContext {\n            target_deck: deck.id,\n            config,\n            usn,\n            timing,\n        };\n\n        self.return_all_cards_in_filtered_deck(deck)?;\n        self.build_filtered_deck(ctx)\n    }\n\n    fn new_filtered_deck_for_adding(&mut self) -> Result<Deck> {\n        let mut deck = Deck {\n            name: self.get_next_filtered_deck_name(),\n            ..Deck::new_filtered()\n        };\n        if let Some(current) = self.get_deck(self.get_current_deck_id())? {\n            if !current.is_filtered() && current.id.0 != 0 {\n                // start with a search based on the selected deck name\n                let search = deck_search(&current.human_name());\n                let term1 = deck\n                    .filtered_mut()\n                    .unwrap()\n                    .search_terms\n                    .get_mut(0)\n                    .unwrap();\n                term1.search = format!(\"{search} is:due\");\n                let term2 = deck\n                    .filtered_mut()\n                    .unwrap()\n                    .search_terms\n                    .get_mut(1)\n                    .unwrap();\n                term2.search = format!(\"{search} is:new\");\n            }\n        }\n\n        Ok(deck)\n    }\n}\n\nimpl TryFrom<Deck> for FilteredDeckForUpdate {\n    type Error = AnkiError;\n\n    fn try_from(value: Deck) -> Result<Self, Self::Error> {\n        let human_name = value.human_name();\n        match value.kind {\n            DeckKind::Filtered(filtered) => Ok(FilteredDeckForUpdate {\n                id: value.id,\n                human_name,\n                config: filtered,\n                allow_empty: false,\n            }),\n            _ => invalid_input!(\"not filtered\"),\n        }\n    }\n}\n\nfn apply_update_to_filtered_deck(deck: &mut Deck, update: FilteredDeckForUpdate) {\n    deck.id = update.id;\n    deck.name = NativeDeckName::from_human_name(&update.human_name);\n    deck.kind = DeckKind::Filtered(update.config);\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/error.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse fsrs::FSRSError;\n\nuse crate::error::AnkiError;\nuse crate::error::InvalidInputError;\n\nimpl From<FSRSError> for AnkiError {\n    fn from(err: FSRSError) -> Self {\n        match err {\n            FSRSError::NotEnoughData => AnkiError::FsrsInsufficientData,\n            FSRSError::OptimalNotFound => AnkiError::FsrsUnableToDetermineDesiredRetention,\n            FSRSError::Interrupted => AnkiError::Interrupted,\n            FSRSError::InvalidParameters => AnkiError::FsrsParamsInvalid,\n            FSRSError::InvalidInput => AnkiError::FsrsParamsInvalid,\n            FSRSError::InvalidDeckSize => AnkiError::InvalidInput {\n                source: InvalidInputError {\n                    message: \"no cards to simulate\".to_string(),\n                    source: None,\n                    backtrace: None,\n                },\n            },\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/memory_state.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse anki_proto::scheduler::ComputeMemoryStateResponse;\nuse fsrs::FSRSItem;\nuse fsrs::MemoryState;\nuse fsrs::FSRS;\nuse fsrs::FSRS5_DEFAULT_DECAY;\nuse fsrs::FSRS6_DEFAULT_DECAY;\nuse itertools::Either;\nuse itertools::Itertools;\n\nuse super::params::ignore_revlogs_before_ms_from_config;\nuse super::rescheduler::Rescheduler;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::scheduler::answering::get_fuzz_seed;\nuse crate::scheduler::fsrs::params::reviews_for_fsrs;\nuse crate::scheduler::fsrs::params::Params;\nuse crate::scheduler::states::fuzz::with_review_fuzz;\nuse crate::search::Negated;\nuse crate::search::SearchNode;\nuse crate::search::StateKind;\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct ComputeMemoryProgress {\n    pub current_cards: u32,\n    pub total_cards: u32,\n}\n\n/// Helper function to determine the appropriate decay value based on FSRS\n/// parameters\npub(crate) fn get_decay_from_params(params: &[f32]) -> f32 {\n    if params.is_empty() {\n        FSRS6_DEFAULT_DECAY // default decay for FSRS-6\n    } else if params.len() < 21 {\n        FSRS5_DEFAULT_DECAY // default decay for FSRS-4.5 and FSRS-5\n    } else {\n        params[20]\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct UpdateMemoryStateRequest {\n    pub params: Params,\n    pub preset_desired_retention: f32,\n    pub historical_retention: f32,\n    pub max_interval: u32,\n    pub reschedule: bool,\n    pub deck_desired_retention: HashMap<DeckId, f32>,\n}\n\npub(crate) struct UpdateMemoryStateEntry {\n    pub req: Option<UpdateMemoryStateRequest>,\n    pub search: SearchNode,\n    pub ignore_before: TimestampMillis,\n}\n\ntrait ChunkIntoVecs<T> {\n    fn chunk_into_vecs(&mut self, chunk_size: usize) -> impl Iterator<Item = Vec<T>>;\n}\n\nimpl<T> ChunkIntoVecs<T> for Vec<T> {\n    fn chunk_into_vecs(&mut self, chunk_size: usize) -> impl Iterator<Item = Vec<T>> {\n        std::iter::from_fn(move || {\n            (!self.is_empty()).then(|| self.drain(..chunk_size.min(self.len())).collect())\n        })\n    }\n}\n\nimpl Collection {\n    /// For each provided set of params, locate cards with the provided search,\n    /// and update their memory state.\n    /// Should be called inside a transaction.\n    /// If Params are None, it means the user disabled FSRS, and the existing\n    /// memory state should be removed.\n    pub(crate) fn update_memory_state(\n        &mut self,\n        entries: Vec<UpdateMemoryStateEntry>,\n    ) -> Result<()> {\n        let timing = self.timing_today()?;\n        let usn = self.usn()?;\n        for UpdateMemoryStateEntry {\n            req,\n            search,\n            ignore_before,\n        } in entries\n        {\n            let search =\n                SearchBuilder::all([search.into(), SearchNode::State(StateKind::New).negated()]);\n            let revlog = self.revlog_for_srs(search)?;\n\n            let Some(req) = &req else {\n                let items = fsrs_items_for_memory_states(\n                    &FSRS::new(Some(&[]))?,\n                    revlog,\n                    timing.next_day_at,\n                    0.9,\n                    ignore_before,\n                )?;\n\n                let on_updated_card = self.create_progress_closure(items.len())?;\n\n                // clear FSRS data if FSRS is disabled\n                self.clear_fsrs_data_for_cards(\n                    items.into_iter().map(|(card_id, _)| card_id),\n                    usn,\n                    on_updated_card,\n                )?;\n                continue;\n            };\n\n            let fsrs = FSRS::new(Some(&req.params[..]))?;\n            let last_revlog_info = req.reschedule.then(|| get_last_revlog_info(&revlog));\n\n            let items = fsrs_items_for_memory_states(\n                &fsrs,\n                revlog,\n                timing.next_day_at,\n                req.historical_retention,\n                ignore_before,\n            )?;\n\n            let mut on_updated_card = self.create_progress_closure(items.len())?;\n\n            let (items, cards_without_items): (Vec<(CardId, FsrsItemForMemoryState)>, Vec<CardId>) =\n                items.into_iter().partition_map(|(card_id, item)| {\n                    if let Some(item) = item {\n                        Either::Left((card_id, item))\n                    } else {\n                        Either::Right(card_id)\n                    }\n                });\n\n            let decay = get_decay_from_params(&req.params);\n\n            // Store decay and desired retention in the card so that add-ons, card info,\n            // stats and browser search/sorts don't need to access the deck config.\n            // Unlike memory states, scheduler doesn't use decay and dr stored in the card.\n            let set_decay_and_desired_retention = move |card: &mut Card| {\n                let deck_id = card.original_or_current_deck_id();\n\n                let desired_retention = *req\n                    .deck_desired_retention\n                    .get(&deck_id)\n                    .unwrap_or(&req.preset_desired_retention);\n\n                card.desired_retention = Some(desired_retention);\n                card.decay = Some(decay);\n            };\n\n            self.update_memory_state_for_itemless_cards(\n                cards_without_items,\n                set_decay_and_desired_retention,\n                usn,\n                &mut on_updated_card,\n            )?;\n\n            let mut rescheduler =\n                if req.reschedule && self.get_config_bool(BoolKey::LoadBalancerEnabled) {\n                    Some(Rescheduler::new(self)?)\n                } else {\n                    None\n                };\n\n            let reschedule =\n                move |card: &mut Card, collection: &mut Self, fsrs: &FSRS| -> Result<()> {\n                    // we are rescheduling\n                    let Some(last_revlog_info) = &last_revlog_info else {\n                        return Ok(());\n                    };\n\n                    // we have a last review time for the card\n                    let Some(last_info) = last_revlog_info.get(&card.id) else {\n                        return Ok(());\n                    };\n                    let Some(last_review) = &last_info.last_reviewed_at else {\n                        return Ok(());\n                    };\n                    // the card isn't in (re)learning or suspended\n                    if !(card.ctype == CardType::Review && card.queue != CardQueue::Suspended) {\n                        return Ok(());\n                    };\n\n                    let deck = collection\n                        .get_deck(card.original_or_current_deck_id())?\n                        .or_not_found(card.original_or_current_deck_id())?;\n                    let deckconfig_id = deck.config_id().unwrap();\n                    // reschedule it\n                    let days_elapsed = timing.next_day_at.elapsed_days_since(*last_review) as i32;\n                    let original_interval = card.interval;\n                    let min_interval = |interval: u32| {\n                        let previous_interval = last_info.previous_interval.unwrap_or(0);\n                        if interval > previous_interval {\n                            // interval grew; don't allow fuzzed interval to\n                            // be less than previous+1\n                            previous_interval + 1\n                        } else {\n                            // interval shrunk; don't restrict negative fuzz\n                            0\n                        }\n                        .max(1)\n                    };\n                    let interval = fsrs.next_interval(\n                        Some(\n                            card.memory_state\n                                .expect(\"We set it before this function is called\")\n                                .stability,\n                        ),\n                        card.desired_retention\n                            .expect(\"We set it before this function is called\"),\n                        0,\n                    );\n                    card.interval = rescheduler\n                        .as_mut()\n                        .and_then(|r| {\n                            r.find_interval(\n                                interval,\n                                min_interval(interval as u32),\n                                req.max_interval,\n                                days_elapsed as u32,\n                                deckconfig_id,\n                                get_fuzz_seed(card, true),\n                            )\n                        })\n                        .unwrap_or_else(|| {\n                            with_review_fuzz(\n                                card.get_fuzz_factor(true),\n                                interval,\n                                min_interval(interval as u32),\n                                req.max_interval,\n                            )\n                        });\n                    let due = if card.original_due != 0 {\n                        &mut card.original_due\n                    } else {\n                        &mut card.due\n                    };\n                    let new_due =\n                        (timing.days_elapsed as i32) - days_elapsed + card.interval as i32;\n                    if let Some(rescheduler) = &mut rescheduler {\n                        rescheduler.update_due_cnt_per_day(*due, new_due, deckconfig_id);\n                    }\n                    *due = new_due;\n                    // Add a rescheduled revlog entry\n                    collection.log_rescheduled_review(card, original_interval, usn)?;\n\n                    Ok(())\n                };\n\n            self.update_memory_state_for_cards_with_items(\n                items,\n                &fsrs,\n                set_decay_and_desired_retention,\n                reschedule,\n                usn,\n                on_updated_card,\n            )?;\n        }\n        Ok(())\n    }\n\n    fn create_progress_closure(&self, item_count: usize) -> Result<impl FnMut() -> Result<()>> {\n        let mut progress = self.new_progress_handler::<ComputeMemoryProgress>();\n        progress.update(false, |s| {\n            s.total_cards = item_count as u32;\n            s.current_cards = 1;\n        })?;\n        let on_updated_card = move || progress.update(true, |p| p.current_cards += 1);\n        Ok(on_updated_card)\n    }\n\n    fn clear_fsrs_data_for_cards(\n        &mut self,\n        cards: impl Iterator<Item = CardId>,\n        usn: Usn,\n        mut on_updated_card: impl FnMut() -> Result<()>,\n    ) -> Result<()> {\n        for card_id in cards {\n            let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;\n            let original = card.clone();\n            card.clear_fsrs_data();\n            self.update_card_inner(&mut card, original, usn)?;\n            on_updated_card()?\n        }\n        Ok(())\n    }\n\n    fn update_memory_state_for_itemless_cards(\n        &mut self,\n        cards: Vec<CardId>,\n        mut set_decay_and_desired_retention: impl FnMut(&mut Card),\n        usn: Usn,\n        mut on_updated_card: impl FnMut() -> Result<()>,\n    ) -> Result<()> {\n        for card_id in cards {\n            let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;\n            let original = card.clone();\n            set_decay_and_desired_retention(&mut card);\n            card.memory_state = None;\n            self.update_card_inner(&mut card, original, usn)?;\n            on_updated_card()?;\n        }\n        Ok(())\n    }\n\n    fn update_memory_state_for_cards_with_items(\n        &mut self,\n        items: Vec<(CardId, FsrsItemForMemoryState)>,\n        fsrs: &FSRS,\n        mut set_decay_and_desired_retention: impl FnMut(&mut Card),\n        mut maybe_reschedule_card: impl FnMut(&mut Card, &mut Self, &FSRS) -> Result<()>,\n        usn: Usn,\n        mut on_updated_card: impl FnMut() -> Result<()>,\n    ) -> Result<()> {\n        const FSRS_BATCH_SIZE: usize = 1000;\n\n        let mut to_update = Vec::new();\n        let mut fsrs_items = Vec::new();\n        let mut starting_states = Vec::new();\n\n        for (card_id, item) in items.into_iter() {\n            to_update.push(card_id);\n            fsrs_items.push(item.item);\n            starting_states.push(item.starting_state);\n        }\n\n        // fsrs.memory_state_batch is O(nm) where n is the number of cards and m is the\n        // max review count between all items. Therefore we want to pass batches\n        // to fsrs.memory_state_batch where the review count is relatively even.\n        let mut p = permutation::sort_unstable_by_key(&fsrs_items, |item| item.reviews.len());\n        p.apply_slice_in_place(&mut to_update);\n        p.apply_slice_in_place(&mut fsrs_items);\n        p.apply_slice_in_place(&mut starting_states);\n\n        for ((to_update, fsrs_items), starting_states) in to_update\n            .chunk_into_vecs(FSRS_BATCH_SIZE)\n            .zip_eq(fsrs_items.chunk_into_vecs(FSRS_BATCH_SIZE))\n            .zip_eq(starting_states.chunk_into_vecs(FSRS_BATCH_SIZE))\n        {\n            let memory_states = fsrs.memory_state_batch(fsrs_items, starting_states)?;\n\n            for (card_id, memory_state) in to_update.into_iter().zip_eq(memory_states) {\n                let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;\n                let original = card.clone();\n                set_decay_and_desired_retention(&mut card);\n                card.memory_state = Some(memory_state.into());\n                maybe_reschedule_card(&mut card, self, fsrs)?;\n                self.update_card_inner(&mut card, original, usn)?;\n                on_updated_card()?;\n            }\n        }\n        Ok(())\n    }\n\n    pub fn compute_memory_state(&mut self, card_id: CardId) -> Result<ComputeMemoryStateResponse> {\n        let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;\n        let deck_id = card.original_deck_id.or(card.deck_id);\n        let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?;\n        let conf_id = DeckConfigId(deck.normal()?.config_id);\n        let config = self\n            .storage\n            .get_deck_config(conf_id)?\n            .or_not_found(conf_id)?;\n\n        // Get deck-specific desired retention if available, otherwise use config\n        // default\n        let desired_retention = deck.effective_desired_retention(&config);\n\n        let historical_retention = config.inner.historical_retention;\n        let params = config.fsrs_params();\n        let decay = get_decay_from_params(params);\n        let fsrs = FSRS::new(Some(params))?;\n        let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?;\n        let item = fsrs_item_for_memory_state(\n            &fsrs,\n            revlog,\n            self.timing_today()?.next_day_at,\n            historical_retention,\n            ignore_revlogs_before_ms_from_config(&config)?,\n        )?;\n        if item.is_some() {\n            card.set_memory_state(&fsrs, item, historical_retention)?;\n            Ok(ComputeMemoryStateResponse {\n                state: card.memory_state.map(Into::into),\n                desired_retention,\n                decay,\n            })\n        } else {\n            Ok(ComputeMemoryStateResponse {\n                state: None,\n                desired_retention,\n                decay,\n            })\n        }\n    }\n}\n\nimpl Card {\n    pub(crate) fn set_memory_state(\n        &mut self,\n        fsrs: &FSRS,\n        item: Option<FsrsItemForMemoryState>,\n        historical_retention: f32,\n    ) -> Result<()> {\n        let memory_state = if let Some(i) = item {\n            Some(fsrs.memory_state(i.item, i.starting_state)?)\n        } else if self.ctype == CardType::New || self.interval == 0 {\n            None\n        } else {\n            // no valid revlog entries; infer state from current card state\n            Some(fsrs.memory_state_from_sm2(\n                self.ease_factor(),\n                self.interval as f32,\n                historical_retention,\n            )?)\n        };\n        self.memory_state = memory_state.map(Into::into);\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct FsrsItemForMemoryState {\n    pub item: FSRSItem,\n    /// When revlogs have been truncated, this stores the initial state at first\n    /// review\n    pub starting_state: Option<MemoryState>,\n    pub filtered_revlogs: Vec<RevlogEntry>,\n}\n\n/// Like [fsrs_item_for_memory_state], but for updating multiple cards at once.\npub(crate) fn fsrs_items_for_memory_states(\n    fsrs: &FSRS,\n    revlogs: Vec<RevlogEntry>,\n    next_day_at: TimestampSecs,\n    historical_retention: f32,\n    ignore_revlogs_before: TimestampMillis,\n) -> Result<Vec<(CardId, Option<FsrsItemForMemoryState>)>> {\n    revlogs\n        .into_iter()\n        .chunk_by(|r| r.cid)\n        .into_iter()\n        .map(|(card_id, group)| {\n            Ok((\n                card_id,\n                fsrs_item_for_memory_state(\n                    fsrs,\n                    group.collect(),\n                    next_day_at,\n                    historical_retention,\n                    ignore_revlogs_before,\n                )?,\n            ))\n        })\n        .collect()\n}\n\npub(crate) struct LastRevlogInfo {\n    /// Used to determine the actual elapsed time between the last time the user\n    /// reviewed the card and now, so that we can determine an accurate period\n    /// when the card has subsequently been rescheduled to a different day.\n    pub(crate) last_reviewed_at: Option<TimestampSecs>,\n    /// The interval before the latest review. Used to prevent fuzz from going\n    /// backwards when rescheduling the card\n    pub(crate) previous_interval: Option<u32>,\n}\n\n/// Return a map of cards to info about last review.\npub(crate) fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogInfo> {\n    let mut out = HashMap::new();\n    revlogs\n        .iter()\n        .chunk_by(|r| r.cid)\n        .into_iter()\n        .for_each(|(card_id, group)| {\n            let mut last_reviewed_at = None;\n            let mut previous_interval = None;\n            for e in group.into_iter() {\n                if e.has_rating_and_affects_scheduling() {\n                    last_reviewed_at = Some(e.id.as_secs());\n                    previous_interval = if e.last_interval >= 0 && e.button_chosen > 1 {\n                        Some(e.last_interval as u32)\n                    } else {\n                        None\n                    };\n                } else if e.is_reset() {\n                    last_reviewed_at = None;\n                    previous_interval = None;\n                }\n            }\n            out.insert(\n                card_id,\n                LastRevlogInfo {\n                    last_reviewed_at,\n                    previous_interval,\n                },\n            );\n        });\n    out\n}\n\n/// When calculating memory state, only the last FSRSItem is required. If the\n/// revlog is non-empty and no learning steps have been detected (indicative of\n/// a truncated revlog), we return the starting state inferred from the first\n/// revlog entry, so that the first review is not treated as if started from\n/// scratch.\npub(crate) fn fsrs_item_for_memory_state(\n    fsrs: &FSRS,\n    entries: Vec<RevlogEntry>,\n    next_day_at: TimestampSecs,\n    historical_retention: f32,\n    ignore_revlogs_before: TimestampMillis,\n) -> Result<Option<FsrsItemForMemoryState>> {\n    struct FirstReview {\n        interval: f32,\n        ease_factor: f32,\n    }\n    if let Some(mut output) = reviews_for_fsrs(entries, next_day_at, false, ignore_revlogs_before) {\n        let mut item = output.fsrs_items.pop().unwrap().1;\n        if output.revlogs_complete {\n            Ok(Some(FsrsItemForMemoryState {\n                item,\n                starting_state: None,\n                filtered_revlogs: output.filtered_revlogs,\n            }))\n        } else if let Some(first_user_grade) = output.filtered_revlogs.first() {\n            // the revlog has been truncated, but not fully\n            let first_review = FirstReview {\n                interval: first_user_grade.interval.max(1) as f32,\n                ease_factor: if first_user_grade.ease_factor == 0 {\n                    2500\n                } else {\n                    first_user_grade.ease_factor\n                } as f32\n                    / 1000.0,\n            };\n            let mut starting_state = fsrs.memory_state_from_sm2(\n                first_review.ease_factor,\n                first_review.interval,\n                historical_retention,\n            )?;\n            // if the ease factor is less than 1.1, the revlog entry is generated by FSRS\n            if first_review.ease_factor <= 1.1 {\n                starting_state.difficulty = (first_review.ease_factor - 0.1) * 9.0 + 1.0;\n            }\n            // remove the first review because it has been converted to the starting state\n            item.reviews.remove(0);\n            Ok(Some(FsrsItemForMemoryState {\n                item,\n                starting_state: Some(starting_state),\n                filtered_revlogs: output.filtered_revlogs,\n            }))\n        } else {\n            // only manual and rescheduled revlogs; treat like empty\n            Ok(None)\n        }\n    } else {\n        // no revlogs (new card or caused by ignore_revlogs_before or deleted revlogs)\n        Ok(None)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use fsrs::MemoryState;\n\n    use super::*;\n    use crate::card::FsrsMemoryState;\n    use crate::revlog::RevlogReviewKind;\n    use crate::scheduler::fsrs::params::tests::convert;\n    use crate::scheduler::fsrs::params::tests::revlog;\n\n    /// Floating point precision can vary between platforms, and each FSRS\n    /// update tends to result in small changes to these numbers, so we\n    /// round them.\n    fn assert_int_eq(actual: Option<FsrsMemoryState>, expected: Option<FsrsMemoryState>) {\n        let actual = actual.unwrap();\n        let expected = expected.unwrap();\n        assert_eq!(actual.stability.round(), expected.stability.round());\n        assert_eq!(actual.difficulty.round(), expected.difficulty.round());\n    }\n\n    #[test]\n    fn bypassed_learning_is_handled() -> Result<()> {\n        // cards without any learning steps due to truncated history still have memory\n        // state calculated\n        let fsrs = FSRS::new(Some(&[])).unwrap();\n        let item = fsrs_item_for_memory_state(\n            &fsrs,\n            vec![\n                RevlogEntry {\n                    ease_factor: 2500,\n                    interval: 100,\n                    ..revlog(RevlogReviewKind::Review, 99)\n                },\n                revlog(RevlogReviewKind::Review, 0),\n            ],\n            TimestampSecs::now(),\n            0.9,\n            0.into(),\n        )?\n        .unwrap();\n        assert_int_eq(\n            item.starting_state.map(Into::into),\n            Some(FsrsMemoryState {\n                stability: 100.0,\n                difficulty: 5.003576,\n            }),\n        );\n        let mut card = Card {\n            reps: 1,\n            ..Default::default()\n        };\n        card.set_memory_state(&fsrs, Some(item), 0.9)?;\n        assert_int_eq(\n            card.memory_state,\n            Some(FsrsMemoryState {\n                stability: 248.9251,\n                difficulty: 4.9938006,\n            }),\n        );\n        // cards with a single review-type entry also get memory states from revlog\n        // rather than card states\n        let item = fsrs_item_for_memory_state(\n            &fsrs,\n            vec![RevlogEntry {\n                ease_factor: 2500,\n                interval: 100,\n                ..revlog(RevlogReviewKind::Review, 100)\n            }],\n            TimestampSecs::now(),\n            0.9,\n            0.into(),\n        )?\n        .unwrap();\n        assert!(item.item.reviews.is_empty());\n        card.set_memory_state(&fsrs, Some(item), 0.9)?;\n        assert_int_eq(\n            card.memory_state,\n            Some(FsrsMemoryState {\n                stability: 100.0,\n                difficulty: 5.003576,\n            }),\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn zero_history_is_handled() -> Result<()> {\n        // when the history is empty, no items are produced\n        assert_eq!(convert(&[], false), None);\n        // but memory state should still be inferred, by using the card's current state\n        let mut card = Card {\n            ctype: CardType::Review,\n            interval: 100,\n            ease_factor: 1300,\n            reps: 1,\n            ..Default::default()\n        };\n        card.set_memory_state(&FSRS::new(Some(&[])).unwrap(), None, 0.9)?;\n        assert_int_eq(\n            card.memory_state,\n            Some(\n                MemoryState {\n                    stability: 99.999954,\n                    difficulty: 9.979899,\n                }\n                .into(),\n            ),\n        );\n        Ok(())\n    }\n\n    mod update_memory_state {\n        use super::*;\n\n        #[test]\n        fn no_req_clears_fsrs_data() -> Result<()> {\n            let mut col = Collection::new();\n            let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n            let mut note1 = nt.new_note();\n            col.add_note(&mut note1, DeckId(1))?;\n            let mut card = col\n                .storage\n                .all_cards_of_note(note1.id)?\n                .into_iter()\n                .next()\n                .unwrap();\n            let card_id = card.id;\n            // Make the card not new\n            card.ctype = CardType::Review;\n            card.interval = 1;\n            // Set FSRS parameters\n            card.memory_state = Some(FsrsMemoryState {\n                stability: 1.0,\n                difficulty: 1.0,\n            });\n            card.desired_retention = Some(0.123);\n            card.decay = Some(0.456);\n\n            col.storage.update_card(&card)?;\n\n            // Add a revlog entry so the card is found within update_memory_state\n            let mut rev = revlog(RevlogReviewKind::Review, 1);\n            rev.cid = card_id;\n            col.storage.add_revlog_entry(&rev, false)?;\n\n            let entry = UpdateMemoryStateEntry {\n                req: None,\n                search: SearchNode::WholeCollection,\n                ignore_before: TimestampMillis(0),\n            };\n            col.transact(Op::UpdateDeckConfig, |col| {\n                col.update_memory_state(vec![entry]).unwrap();\n                Ok(())\n            })\n            .unwrap();\n\n            let card = col.storage.get_card(card_id)?.unwrap();\n            assert_eq!(card.memory_state, None);\n            assert_eq!(card.desired_retention, None);\n            assert_eq!(card.decay, None);\n\n            Ok(())\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod error;\npub mod memory_state;\npub mod params;\npub mod rescheduler;\npub mod retention;\npub mod simulator;\npub mod try_collect;\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/params.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\nuse std::iter;\nuse std::path::Path;\nuse std::thread;\nuse std::time::Duration;\n\nuse anki_io::write_file;\nuse anki_proto::scheduler::ComputeFsrsParamsResponse;\nuse anki_proto::stats::revlog_entry;\nuse anki_proto::stats::Dataset;\nuse anki_proto::stats::DeckEntry;\nuse chrono::NaiveDate;\nuse chrono::NaiveTime;\nuse fsrs::CombinedProgressState;\nuse fsrs::ComputeParametersInput;\nuse fsrs::FSRSItem;\nuse fsrs::FSRSReview;\nuse fsrs::MemoryState;\nuse fsrs::ModelEvaluation;\nuse fsrs::FSRS;\nuse itertools::Itertools;\nuse prost::Message;\n\nuse crate::decks::immediate_parent_name;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::revlog::RevlogReviewKind;\nuse crate::search::Node;\nuse crate::search::SearchNode;\nuse crate::search::SortMode;\n\npub(crate) type Params = Vec<f32>;\n\npub(crate) fn ignore_revlogs_before_date_to_ms(\n    ignore_revlogs_before_date: &String,\n) -> Result<TimestampMillis> {\n    Ok(match ignore_revlogs_before_date {\n        s if s.is_empty() => 0,\n        s => NaiveDate::parse_from_str(s.as_str(), \"%Y-%m-%d\")\n            .or_else(|err| invalid_input!(err, \"Error parsing date: {s}\"))?\n            .and_time(NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap())\n            .and_utc()\n            .timestamp_millis(),\n    }\n    .into())\n}\n\npub(crate) fn ignore_revlogs_before_ms_from_config(config: &DeckConfig) -> Result<TimestampMillis> {\n    ignore_revlogs_before_date_to_ms(&config.inner.ignore_revlogs_before_date)\n}\n\npub struct ComputeParamsRequest<'t> {\n    pub search: &'t str,\n    pub ignore_revlogs_before_ms: TimestampMillis,\n    pub current_preset: u32,\n    pub total_presets: u32,\n    pub current_params: &'t Params,\n    pub num_of_relearning_steps: usize,\n    pub health_check: bool,\n}\n\n/// r: retention\nfn log_loss_adjustment(r: f32) -> f32 {\n    0.623 * (4. * r * (1. - r)).powf(0.738)\n}\n\n/// r: retention\n///\n/// c: review count\nfn rmse_adjustment(r: f32, c: u32) -> f32 {\n    0.0135 / (r.powf(0.504) - 1.14) + 0.176 / ((c as f32 / 1000.).powf(0.825) + 2.22) + 0.101\n}\n\nimpl Collection {\n    /// Note this does not return an error if there are less than 400 items -\n    /// the caller should instead check the fsrs_items count in the return\n    /// value.\n    pub fn compute_params(\n        &mut self,\n        request: ComputeParamsRequest,\n    ) -> Result<ComputeFsrsParamsResponse> {\n        let ComputeParamsRequest {\n            search,\n            ignore_revlogs_before_ms: ignore_revlogs_before,\n            current_preset,\n            total_presets,\n            current_params,\n            num_of_relearning_steps,\n            health_check,\n        } = request;\n\n        self.clear_progress();\n        let timing = self.timing_today()?;\n        let revlogs = self.revlog_for_srs(search)?;\n        let (items, review_count) =\n            fsrs_items_for_training(revlogs.clone(), timing.next_day_at, ignore_revlogs_before);\n\n        let fsrs_items = items.len() as u32;\n        if fsrs_items == 0 {\n            return Ok(ComputeFsrsParamsResponse {\n                params: current_params.to_vec(),\n                fsrs_items,\n                health_check_passed: None,\n            });\n        }\n        // adapt the progress handler to our built-in progress handling\n\n        let create_progress_thread = || -> Result<_> {\n            let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>();\n            anki_progress.update(false, |p| {\n                p.current_preset = current_preset;\n                p.total_presets = total_presets;\n            })?;\n            let progress = CombinedProgressState::new_shared();\n            let progress2 = progress.clone();\n            let progress_thread = thread::spawn(move || {\n                let mut finished = false;\n                while !finished {\n                    thread::sleep(Duration::from_millis(100));\n                    let mut guard = progress.lock().unwrap();\n                    if let Err(_err) = anki_progress.update(false, |s| {\n                        s.total_iterations = guard.total() as u32;\n                        s.current_iteration = guard.current() as u32;\n                        s.reviews = review_count as u32;\n                        finished = guard.finished();\n                    }) {\n                        guard.want_abort = true;\n                        return;\n                    }\n                }\n            });\n            Ok((progress2, progress_thread))\n        };\n\n        let (progress, progress_thread) = create_progress_thread()?;\n        let fsrs = FSRS::new(None)?;\n        let input = ComputeParametersInput {\n            train_set: items.clone(),\n            progress: Some(progress.clone()),\n            enable_short_term: true,\n            num_relearning_steps: Some(num_of_relearning_steps),\n        };\n        let mut params = fsrs.compute_parameters(input.clone())?;\n        progress_thread.join().ok();\n        if let Ok(current_fsrs) = FSRS::new(Some(current_params)) {\n            let current_log_loss = current_fsrs.evaluate(items.clone(), |_| true)?.log_loss;\n            let optimized_fsrs = FSRS::new(Some(&params))?;\n            let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss;\n            if current_log_loss <= optimized_log_loss {\n                if num_of_relearning_steps <= 1 {\n                    params = current_params.to_vec();\n                } else {\n                    let memory_state = MemoryState {\n                        stability: 1.0,\n                        difficulty: 1.0,\n                    };\n\n                    let s_fail = current_fsrs.next_states(Some(memory_state), 0.9, 2)?.again;\n                    let mut s_short_term = s_fail.memory;\n\n                    for _ in 0..num_of_relearning_steps {\n                        s_short_term = current_fsrs\n                            .next_states(Some(s_short_term), 0.9, 0)?\n                            .good\n                            .memory;\n                    }\n\n                    if s_short_term.stability < memory_state.stability {\n                        params = current_params.to_vec();\n                    }\n                }\n            }\n        }\n\n        let health_check_passed = if health_check && input.train_set.len() > 300 {\n            let fsrs = FSRS::new(None)?;\n            fsrs.evaluate_with_time_series_splits(input, |_| true)\n                .ok()\n                .map(|eval| {\n                    let r = items.iter().fold(0, |p, item| {\n                        p + (item\n                            .reviews\n                            .last()\n                            .map(|reviews| reviews.rating)\n                            .unwrap_or(0)\n                            > 1) as u32\n                    }) as f32\n                        / fsrs_items as f32;\n                    let adjusted_log_loss = eval.log_loss / log_loss_adjustment(r);\n                    let adjusted_rmse = eval.rmse_bins / rmse_adjustment(r, fsrs_items);\n\n                    adjusted_log_loss <= 1.11 || adjusted_rmse <= 1.53\n                })\n        } else {\n            None\n        };\n\n        Ok(ComputeFsrsParamsResponse {\n            params,\n            fsrs_items,\n            health_check_passed,\n        })\n    }\n\n    pub(crate) fn revlog_for_srs(\n        &mut self,\n        search: impl TryIntoSearch,\n    ) -> Result<Vec<RevlogEntry>> {\n        let search = search.try_into_search()?;\n        // a whole-collection search can match revlog entries of deleted cards, too\n        if let Node::Group(nodes) = &search {\n            if let &[Node::Search(SearchNode::WholeCollection)] = &nodes[..] {\n                return self.storage.get_all_revlog_entries_in_card_order();\n            }\n        }\n        self.search_cards_into_table(search, SortMode::NoOrder)?\n            .col\n            .storage\n            .get_revlog_entries_for_searched_cards_in_card_order()\n    }\n\n    /// Used for exporting revlogs for algorithm research.\n    pub fn export_dataset(&mut self, min_entries: usize, target_path: &Path) -> Result<()> {\n        let revlog_entries = self.storage.get_revlog_entries_for_export_dataset()?;\n        if revlog_entries.len() < min_entries {\n            return Err(AnkiError::FsrsInsufficientData);\n        }\n        let revlogs = revlog_entries\n            .into_iter()\n            .map(revlog_entry_to_proto)\n            .collect_vec();\n        let cards = self.storage.get_all_card_entries()?;\n\n        let decks_map = self.storage.get_decks_map()?;\n        let deck_name_to_id: HashMap<String, DeckId> = decks_map\n            .into_iter()\n            .map(|(id, deck)| (deck.name.to_string(), id))\n            .collect();\n\n        let decks = self\n            .storage\n            .get_all_decks()?\n            .into_iter()\n            .filter_map(|deck| {\n                if let Some(preset_id) = deck.config_id().map(|id| id.0) {\n                    let parent_id = immediate_parent_name(&deck.name.to_string())\n                        .and_then(|parent_name| deck_name_to_id.get(parent_name))\n                        .map(|id| id.0)\n                        .unwrap_or(0);\n                    Some(DeckEntry {\n                        id: deck.id.0,\n                        parent_id,\n                        preset_id,\n                    })\n                } else {\n                    None\n                }\n            })\n            .collect_vec();\n        let next_day_at = self.timing_today()?.next_day_at.0;\n        let dataset = Dataset {\n            revlogs,\n            cards,\n            decks,\n            next_day_at,\n        };\n        let data = dataset.encode_to_vec();\n        write_file(target_path, data)?;\n        Ok(())\n    }\n\n    pub fn evaluate_params(\n        &mut self,\n        search: &str,\n        ignore_revlogs_before: TimestampMillis,\n        num_of_relearning_steps: usize,\n    ) -> Result<ModelEvaluation> {\n        let timing = self.timing_today()?;\n        let revlogs = self.revlog_for_srs(search)?;\n        let (items, review_count) =\n            fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before);\n        let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>();\n        anki_progress.state.reviews = review_count as u32;\n        let fsrs = FSRS::new(None)?;\n        let input = ComputeParametersInput {\n            train_set: items.clone(),\n            progress: None,\n            enable_short_term: true,\n            num_relearning_steps: Some(num_of_relearning_steps),\n        };\n        Ok(fsrs.evaluate_with_time_series_splits(input, |ip| {\n            anki_progress\n                .update(false, |p| {\n                    p.total_iterations = ip.total as u32;\n                    p.current_iteration = ip.current as u32;\n                })\n                .is_ok()\n        })?)\n    }\n\n    pub fn evaluate_params_legacy(\n        &mut self,\n        params: &Params,\n        search: &str,\n        ignore_revlogs_before: TimestampMillis,\n    ) -> Result<ModelEvaluation> {\n        let timing = self.timing_today()?;\n        let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>();\n        let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;\n        let revlogs: Vec<RevlogEntry> = guard\n            .col\n            .storage\n            .get_revlog_entries_for_searched_cards_in_card_order()?;\n        let (items, review_count) =\n            fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before);\n        anki_progress.state.reviews = review_count as u32;\n        let fsrs = FSRS::new(Some(params))?;\n        Ok(fsrs.evaluate(items, |ip| {\n            anki_progress\n                .update(false, |p| {\n                    p.total_iterations = ip.total as u32;\n                    p.current_iteration = ip.current as u32;\n                })\n                .is_ok()\n        })?)\n    }\n}\n\n#[derive(Default, Clone, Copy, Debug)]\npub struct ComputeParamsProgress {\n    pub current_iteration: u32,\n    pub total_iterations: u32,\n    pub reviews: u32,\n    /// Only used in 'compute all params' case\n    pub current_preset: u32,\n    /// Only used in 'compute all params' case\n    pub total_presets: u32,\n}\n\n/// Convert a series of revlog entries sorted by card id into FSRS items.\nfn fsrs_items_for_training(\n    revlogs: Vec<RevlogEntry>,\n    next_day_at: TimestampSecs,\n    review_revlogs_before: TimestampMillis,\n) -> (Vec<FSRSItem>, usize) {\n    let mut review_count: usize = 0;\n    let mut revlogs = revlogs\n        .into_iter()\n        .chunk_by(|r| r.cid)\n        .into_iter()\n        .filter_map(|(_cid, entries)| {\n            reviews_for_fsrs(entries.collect(), next_day_at, true, review_revlogs_before)\n        })\n        .flat_map(|i| {\n            review_count += i.filtered_revlogs.len();\n\n            i.fsrs_items\n        })\n        .collect_vec();\n    // Sort by RevlogId\n    revlogs.sort_by_key(|(revlog_id, _)| revlog_id.0);\n    // Extract only the FSRSItems after sorting\n    let revlogs = revlogs.into_iter().map(|(_, item)| item).collect_vec();\n    (revlogs, review_count)\n}\n\npub(crate) struct ReviewsForFsrs {\n    /// The revlog entries that remain after filtering (e.g. excluding\n    /// review entries prior to a card being reset).\n    pub filtered_revlogs: Vec<RevlogEntry>,\n    /// FSRS items derived from the filtered revlogs.\n    pub fsrs_items: Vec<(RevlogId, FSRSItem)>,\n    /// True if there is enough history to derive memory state from history\n    /// alone. If false, memory state will be derived from SM2.\n    pub revlogs_complete: bool,\n}\n\n/// Filter out unwanted revlog entries, then create a series of FSRS items for\n/// training/memory state calculation.\n///\n/// Filtering consists of removing revlog entries before the supplied timestamp,\n/// and removing items such as reviews that happened prior to a card being reset\n/// to new.\npub(crate) fn reviews_for_fsrs(\n    mut entries: Vec<RevlogEntry>,\n    next_day_at: TimestampSecs,\n    training: bool,\n    ignore_revlogs_before: TimestampMillis,\n) -> Option<ReviewsForFsrs> {\n    let mut first_of_last_learn_entries = None;\n    let mut first_user_grade_idx = None;\n    let mut revlogs_complete = false;\n    // Working backwards from the latest review...\n    for (index, entry) in entries.iter().enumerate().rev() {\n        if entry.is_cramming() {\n            continue;\n        }\n        // For incomplete review histories, initial memory state is based on the first\n        // user-graded review after the cutoff date with interval >= 1d.\n        let within_cutoff = entry.id.0 > ignore_revlogs_before.0;\n        let user_graded = entry.has_rating();\n        let interday = entry.interval >= 1 || entry.interval <= -86400;\n        if user_graded && within_cutoff && interday {\n            first_user_grade_idx = Some(index);\n        }\n\n        if user_graded && entry.review_kind == RevlogReviewKind::Learning {\n            first_of_last_learn_entries = Some(index);\n            revlogs_complete = true;\n        } else if entry.is_reset() {\n            // Ignore entries prior to a `Reset` if a learning step has come after,\n            // but consider revlogs complete.\n            if first_of_last_learn_entries.is_some() {\n                revlogs_complete = true;\n                break;\n            // Ignore entries prior to a `Reset` if the user has graded a card\n            // after the reset.\n            } else if first_user_grade_idx.is_some() {\n                revlogs_complete = false;\n                break;\n            // User has not graded the card since it was reset, so all history\n            // filtered out.\n            } else {\n                return None;\n            }\n        // Previous versions of Anki didn't add a revlog entry when the card was\n        // reset.\n        } else if first_of_last_learn_entries.is_some() {\n            break;\n        }\n    }\n    if training {\n        // While training, ignore the entire card if the first learning step of the last\n        // group of learning steps is before the ignore_revlogs_before date\n        if let Some(idx) = first_of_last_learn_entries {\n            if entries[idx].id.0 < ignore_revlogs_before.0 {\n                return None;\n            }\n        }\n    } else {\n        // While reviewing, if the first learning step is before the ignore date,\n        // we ignore it, and will fall back on SM2 info and the last user grade below.\n        if let Some(idx) = first_of_last_learn_entries {\n            if entries[idx].id.0 < ignore_revlogs_before.0 && idx < entries.len() - 1 {\n                revlogs_complete = false;\n                first_of_last_learn_entries = None;\n            }\n        }\n    }\n    if let Some(idx) = first_of_last_learn_entries {\n        // start from the learning step\n        if idx > 0 {\n            entries.drain(..idx);\n        }\n    } else if training {\n        // when training, we ignore cards that don't have any learning steps\n        return None;\n    } else if let Some(idx) = first_user_grade_idx {\n        // if there are no learning entries, but the user has reviewed the card,\n        // we ignore all entries before the first grade\n        if idx > 0 {\n            entries.drain(..idx);\n        }\n    } else {\n        // if no valid user grades were found, ignore the card.\n        return None;\n    }\n\n    // Filter out unwanted entries\n    entries.retain(|entry| entry.has_rating_and_affects_scheduling());\n\n    // Compute delta_t for each entry\n    let delta_ts = iter::once(0)\n        .chain(entries.iter().tuple_windows().map(|(previous, current)| {\n            previous.days_elapsed(next_day_at) - current.days_elapsed(next_day_at)\n        }))\n        .collect_vec();\n\n    let items = if training {\n        // Convert the remaining entries into separate FSRSItems, where each item\n        // contains all reviews done until then.\n        let mut items = Vec::with_capacity(entries.len());\n        let mut current_reviews = Vec::with_capacity(entries.len());\n        for (idx, (entry, &delta_t)) in entries.iter().zip(delta_ts.iter()).enumerate() {\n            current_reviews.push(FSRSReview {\n                rating: entry.button_chosen as u32,\n                delta_t,\n            });\n            if idx >= 1 && delta_t > 0 {\n                items.push((\n                    entry.id,\n                    FSRSItem {\n                        reviews: current_reviews.clone(),\n                    },\n                ));\n            }\n        }\n        items\n    } else {\n        // When not training, we only need the final FSRS item, which represents\n        // the complete history of the card. This avoids expensive clones in a loop.\n        let reviews = entries\n            .iter()\n            .zip(delta_ts.iter())\n            .map(|(entry, &delta_t)| FSRSReview {\n                rating: entry.button_chosen as u32,\n                delta_t,\n            })\n            .collect();\n        let last_entry = entries.last().unwrap();\n\n        vec![(last_entry.id, FSRSItem { reviews })]\n    };\n\n    if items.is_empty() {\n        None\n    } else {\n        Some(ReviewsForFsrs {\n            fsrs_items: items,\n            revlogs_complete,\n            filtered_revlogs: entries,\n        })\n    }\n}\n\nimpl RevlogEntry {\n    fn days_elapsed(&self, next_day_at: TimestampSecs) -> u32 {\n        (next_day_at.elapsed_secs_since(self.id.as_secs()) / 86_400).max(0) as u32\n    }\n}\n\nfn revlog_entry_to_proto(e: RevlogEntry) -> anki_proto::stats::RevlogEntry {\n    anki_proto::stats::RevlogEntry {\n        id: e.id.0,\n        cid: e.cid.0,\n        usn: 0,\n        button_chosen: e.button_chosen as u32,\n        interval: e.interval,\n        last_interval: e.last_interval,\n        ease_factor: e.ease_factor,\n        taken_millis: e.taken_millis,\n        review_kind: match e.review_kind {\n            RevlogReviewKind::Learning => revlog_entry::ReviewKind::Learning,\n            RevlogReviewKind::Review => revlog_entry::ReviewKind::Review,\n            RevlogReviewKind::Relearning => revlog_entry::ReviewKind::Relearning,\n            RevlogReviewKind::Filtered => revlog_entry::ReviewKind::Filtered,\n            RevlogReviewKind::Manual => revlog_entry::ReviewKind::Manual,\n            RevlogReviewKind::Rescheduled => revlog_entry::ReviewKind::Rescheduled,\n        } as i32,\n    }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n    use super::*;\n\n    const NEXT_DAY_AT: TimestampSecs = TimestampSecs(86400 * 1000);\n\n    fn days_ago_ms(days_ago: i64) -> TimestampMillis {\n        ((NEXT_DAY_AT.0 - days_ago * 86400) * 1000).into()\n    }\n\n    pub(crate) fn revlog(review_kind: RevlogReviewKind, days_ago: i64) -> RevlogEntry {\n        let button_chosen = match review_kind {\n            RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => 0,\n            _ => 3,\n        };\n        RevlogEntry {\n            review_kind,\n            id: days_ago_ms(days_ago).into(),\n            button_chosen,\n            interval: 1,\n            ..Default::default()\n        }\n    }\n\n    pub(crate) fn review(delta_t: u32) -> FSRSReview {\n        FSRSReview { rating: 3, delta_t }\n    }\n\n    pub(crate) fn convert_ignore_before(\n        revlog: &[RevlogEntry],\n        training: bool,\n        ignore_before: TimestampMillis,\n    ) -> Option<Vec<FSRSItem>> {\n        reviews_for_fsrs(revlog.to_vec(), NEXT_DAY_AT, training, ignore_before)\n            .map(|i| i.fsrs_items.into_iter().map(|(_, item)| item).collect_vec())\n    }\n\n    pub(crate) fn convert(revlog: &[RevlogEntry], training: bool) -> Option<Vec<FSRSItem>> {\n        convert_ignore_before(revlog, training, 0.into())\n    }\n\n    #[macro_export]\n    macro_rules! fsrs_items {\n        ($($reviews:expr),*) => {\n            Some(vec![\n                $(\n                    FSRSItem {\n                        reviews: $reviews.to_vec()\n                    }\n                ),*\n            ])\n        };\n    }\n\n    #[test]\n    fn delta_t_is_correct() -> Result<()> {\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 1),\n                    revlog(RevlogReviewKind::Review, 0)\n                ],\n                true,\n            ),\n            fsrs_items!([review(0), review(1)])\n        );\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 15),\n                    revlog(RevlogReviewKind::Learning, 13),\n                    revlog(RevlogReviewKind::Review, 10),\n                    revlog(RevlogReviewKind::Review, 5)\n                ],\n                true,\n            ),\n            fsrs_items!(\n                [review(0), review(2)],\n                [review(0), review(2), review(3)],\n                [review(0), review(2), review(3), review(5)]\n            )\n        );\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 15),\n                    revlog(RevlogReviewKind::Learning, 13),\n                ],\n                true,\n            ),\n            fsrs_items!([review(0), review(2),])\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn cram_is_filtered() {\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 10),\n                    revlog(RevlogReviewKind::Review, 9),\n                    revlog(RevlogReviewKind::Filtered, 7),\n                    revlog(RevlogReviewKind::Review, 4),\n                ],\n                true,\n            ),\n            fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)])\n        );\n    }\n\n    #[test]\n    fn set_due_date_is_filtered() {\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 10),\n                    revlog(RevlogReviewKind::Review, 9),\n                    RevlogEntry {\n                        ease_factor: 100,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                    revlog(RevlogReviewKind::Review, 4),\n                ],\n                true,\n            ),\n            fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)])\n        );\n    }\n\n    #[test]\n    fn card_reset_drops_all_previous_history() {\n        // If Reset comes in between two Learn entries, only the ones after the Reset\n        // are used.\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 10),\n                    RevlogEntry {\n                        ease_factor: 0,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                    revlog(RevlogReviewKind::Learning, 4),\n                    revlog(RevlogReviewKind::Review, 0),\n                ],\n                true,\n            ),\n            fsrs_items!([review(0), review(4)])\n        );\n        // Return None if Reset is the last entry or is followed by only manual entries.\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 10),\n                    revlog(RevlogReviewKind::Review, 9),\n                    RevlogEntry {\n                        ease_factor: 0,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                    RevlogEntry {\n                        ease_factor: 100,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                ],\n                false,\n            ),\n            None,\n        );\n        // If non-learning user-graded entries are found after Reset, return None during\n        // training but return the remaining entries during memory state calculation.\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Learning, 10),\n                    revlog(RevlogReviewKind::Review, 9),\n                    RevlogEntry {\n                        ease_factor: 0,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                    revlog(RevlogReviewKind::Review, 1),\n                    revlog(RevlogReviewKind::Relearning, 0),\n                ],\n                true,\n            ),\n            None,\n        );\n        assert_eq!(\n            convert(\n                &[\n                    revlog(RevlogReviewKind::Review, 9),\n                    RevlogEntry {\n                        ease_factor: 0,\n                        ..revlog(RevlogReviewKind::Manual, 7)\n                    },\n                    revlog(RevlogReviewKind::Review, 1),\n                    revlog(RevlogReviewKind::Relearning, 0),\n                ],\n                false,\n            ),\n            fsrs_items!([review(0), review(1)])\n        );\n    }\n\n    #[test]\n    fn single_learning_step_skipped_when_training() {\n        assert_eq!(\n            convert(&[revlog(RevlogReviewKind::Learning, 1),], true),\n            None,\n        );\n        assert_eq!(\n            convert(&[revlog(RevlogReviewKind::Learning, 1),], false),\n            fsrs_items!([review(0)])\n        );\n    }\n\n    #[test]\n    fn ignores_cards_before_ignore_before_date_when_training() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Learning, 10),\n            revlog(RevlogReviewKind::Learning, 8),\n        ];\n        // | = Ignore before\n        // L = learning step\n        // L L |\n        assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(7)), None);\n        // L | L\n        assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(9)), None);\n        // L (|L) (exact same millisecond)\n        assert_eq!(\n            convert_ignore_before(revlogs, true, days_ago_ms(10)),\n            convert(revlogs, true)\n        );\n        // | L L\n        assert_eq!(\n            convert_ignore_before(revlogs, true, days_ago_ms(11)),\n            convert(revlogs, true)\n        );\n    }\n\n    #[test]\n    fn partially_ignored_learning_steps_terminate_training() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Learning, 10),\n            revlog(RevlogReviewKind::Learning, 8),\n            revlog(RevlogReviewKind::Review, 6),\n        ];\n        // | = Ignore before\n        // L = learning step\n        // L | L R\n        assert_eq!(convert_ignore_before(revlogs, true, days_ago_ms(9)), None);\n    }\n\n    #[test]\n    fn skip_initial_relearning_steps() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Review, 10),\n            RevlogEntry {\n                button_chosen: 1, // Again\n                interval: -600,\n                ..revlog(RevlogReviewKind::Review, 8)\n            },\n            revlog(RevlogReviewKind::Relearning, 8),\n            revlog(RevlogReviewKind::Review, 6),\n        ];\n        // | = Ignore before\n        // A = Again\n        // X = Relearning\n        // R | A X R\n        assert_eq!(\n            convert_ignore_before(revlogs, false, days_ago_ms(9)),\n            fsrs_items!([review(0), review(2)])\n        );\n    }\n\n    #[test]\n    fn ignore_before_date_between_learning_steps_when_reviewing() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Learning, 10),\n            revlog(RevlogReviewKind::Learning, 8),\n            revlog(RevlogReviewKind::Review, 2),\n        ];\n        // L | L R\n        assert_ne!(\n            convert_ignore_before(revlogs, false, days_ago_ms(9)),\n            convert(revlogs, false)\n        );\n        assert_eq!(\n            convert_ignore_before(revlogs, false, days_ago_ms(9))\n                .unwrap()\n                .last()\n                .unwrap()\n                .reviews\n                .len(),\n            2\n        );\n        // | L L R\n        assert_eq!(\n            convert_ignore_before(revlogs, false, days_ago_ms(11)),\n            convert(revlogs, false)\n        );\n    }\n\n    #[test]\n    fn handle_ignore_before_when_no_learning_steps() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Review, 10),\n            revlog(RevlogReviewKind::Review, 8),\n            revlog(RevlogReviewKind::Review, 6),\n        ];\n        // R | R R\n        assert_eq!(\n            convert_ignore_before(revlogs, false, days_ago_ms(9))\n                .unwrap()\n                .last()\n                .unwrap()\n                .reviews\n                .len(),\n            2\n        );\n    }\n\n    #[test]\n    fn ignore_before_after_last_revlog_entry() {\n        let revlogs = &[\n            revlog(RevlogReviewKind::Learning, 10),\n            revlog(RevlogReviewKind::Review, 6),\n        ];\n        // L R |\n        assert_eq!(convert_ignore_before(revlogs, false, days_ago_ms(4)), None);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/rescheduler.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\n\nuse chrono::Datelike;\n\nuse crate::prelude::*;\nuse crate::scheduler::states::fuzz::constrained_fuzz_bounds;\nuse crate::scheduler::states::load_balancer::build_easy_days_percentages;\nuse crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;\nuse crate::scheduler::states::load_balancer::select_weighted_interval;\nuse crate::scheduler::states::load_balancer::EasyDay;\nuse crate::scheduler::states::load_balancer::LoadBalancerInterval;\n\npub struct Rescheduler {\n    today: i32,\n    next_day_at: TimestampSecs,\n    due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>>,\n    due_today_by_preset: HashMap<DeckConfigId, usize>,\n    reviewed_today_by_preset: HashMap<DeckConfigId, usize>,\n    easy_days_percentages_by_preset: HashMap<DeckConfigId, [EasyDay; 7]>,\n}\n\nimpl Rescheduler {\n    pub fn new(col: &mut Collection) -> Result<Self> {\n        let timing = col.timing_today()?;\n        let deck_stats = col.storage.get_deck_due_counts()?;\n        let deck_map = col.storage.get_decks_map()?;\n        let did_to_dcid = deck_map\n            .values()\n            .filter_map(|deck| Some((deck.id, deck.config_id()?)))\n            .collect::<HashMap<_, _>>();\n\n        let mut due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>> =\n            HashMap::new();\n        for (did, due_date, count) in deck_stats {\n            let deck_config_id = did_to_dcid.get(&did).or_not_found(did)?;\n            due_cnt_per_day_by_preset\n                .entry(*deck_config_id)\n                .or_default()\n                .entry(due_date)\n                .and_modify(|e| *e += count)\n                .or_insert(count);\n        }\n\n        let today = timing.days_elapsed as i32;\n        let due_today_by_preset = due_cnt_per_day_by_preset\n            .iter()\n            .map(|(deck_config_id, config_dues)| {\n                let due_today = config_dues\n                    .iter()\n                    .filter(|(&due, _)| due <= today)\n                    .map(|(_, &count)| count)\n                    .sum();\n                (*deck_config_id, due_today)\n            })\n            .collect();\n\n        let next_day_at = timing.next_day_at;\n        let reviewed_stats = col.storage.studied_today_by_deck(timing.next_day_at)?;\n        let mut reviewed_today_by_preset: HashMap<DeckConfigId, usize> = HashMap::new();\n        for (did, count) in reviewed_stats {\n            if let Some(&deck_config_id) = &did_to_dcid.get(&did) {\n                *reviewed_today_by_preset.entry(deck_config_id).or_default() += count;\n            }\n        }\n\n        let easy_days_percentages_by_preset =\n            build_easy_days_percentages(col.storage.get_deck_config_map()?)?;\n\n        Ok(Self {\n            today,\n            next_day_at,\n            due_cnt_per_day_by_preset,\n            due_today_by_preset,\n            reviewed_today_by_preset,\n            easy_days_percentages_by_preset,\n        })\n    }\n\n    pub fn update_due_cnt_per_day(\n        &mut self,\n        due_before: i32,\n        due_after: i32,\n        deck_config_id: DeckConfigId,\n    ) {\n        if let Some(counts) = self.due_cnt_per_day_by_preset.get_mut(&deck_config_id) {\n            if let Some(count) = counts.get_mut(&due_before) {\n                *count -= 1;\n            }\n            *counts.entry(due_after).or_default() += 1;\n        }\n\n        if due_before <= self.today && due_after > self.today {\n            if let Some(count) = self.due_today_by_preset.get_mut(&deck_config_id) {\n                *count -= 1;\n            }\n        }\n        if due_before > self.today && due_after <= self.today {\n            *self.due_today_by_preset.entry(deck_config_id).or_default() += 1;\n        }\n    }\n\n    fn due_today(&self, deck_config_id: DeckConfigId) -> usize {\n        *self.due_today_by_preset.get(&deck_config_id).unwrap_or(&0)\n    }\n\n    fn reviewed_today(&self, deck_config_id: DeckConfigId) -> usize {\n        *self\n            .reviewed_today_by_preset\n            .get(&deck_config_id)\n            .unwrap_or(&0)\n    }\n\n    pub fn find_interval(\n        &self,\n        interval: f32,\n        minimum_interval: u32,\n        maximum_interval: u32,\n        days_elapsed: u32,\n        deckconfig_id: DeckConfigId,\n        fuzz_seed: Option<u64>,\n    ) -> Option<u32> {\n        let (before_days, after_days) =\n            constrained_fuzz_bounds(interval, minimum_interval, maximum_interval);\n\n        // Don't reschedule the card when it's overdue\n        if after_days < days_elapsed {\n            return None;\n        }\n        // Don't reschedule the card to the past\n        let before_days = before_days.max(days_elapsed);\n\n        // Generate possible intervals and their review counts\n        let possible_intervals: Vec<u32> = (before_days..=after_days).collect();\n        let review_counts: Vec<usize> = possible_intervals\n            .iter()\n            .map(|&ivl| {\n                if ivl > days_elapsed {\n                    let check_due = self.today + ivl as i32 - days_elapsed as i32;\n                    *self\n                        .due_cnt_per_day_by_preset\n                        .get(&deckconfig_id)\n                        .and_then(|counts| counts.get(&check_due))\n                        .unwrap_or(&0)\n                } else {\n                    // today's workload is the sum of backlogs, cards due today and cards reviewed\n                    // today\n                    self.due_today(deckconfig_id) + self.reviewed_today(deckconfig_id)\n                }\n            })\n            .collect();\n        let weekdays: Vec<usize> = possible_intervals\n            .iter()\n            .map(|&ivl| {\n                self.next_day_at\n                    .adding_secs(days_elapsed as i64 * -86400)\n                    .adding_secs((ivl - 1) as i64 * 86400)\n                    .local_datetime()\n                    .unwrap()\n                    .weekday()\n                    .num_days_from_monday() as usize\n            })\n            .collect();\n\n        let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;\n        let easy_days_modifier =\n            calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);\n\n        let intervals =\n            possible_intervals\n                .iter()\n                .enumerate()\n                .map(|(interval_index, &target_interval)| LoadBalancerInterval {\n                    target_interval,\n                    review_count: review_counts[interval_index],\n                    sibling_modifier: 1.0,\n                    easy_days_modifier: easy_days_modifier[interval_index],\n                });\n\n        select_weighted_interval(intervals, fuzz_seed)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/retention.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::scheduler::SimulateFsrsReviewRequest;\nuse fsrs::extract_simulator_config;\nuse fsrs::SimulatorConfig;\nuse fsrs::FSRS;\n\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\n\n#[derive(Default, Clone, Copy, Debug)]\npub struct ComputeRetentionProgress {\n    pub current: u32,\n    pub total: u32,\n}\n\nimpl Collection {\n    pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result<f32> {\n        let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();\n        let fsrs = FSRS::new(None)?;\n        if req.days_to_simulate == 0 {\n            invalid_input!(\"no days to simulate\")\n        }\n        let (config, cards) = self.simulate_request_to_config(&req)?;\n        Ok(fsrs\n            .optimal_retention(\n                &config,\n                &req.params,\n                |ip| {\n                    anki_progress\n                        .update(false, |p| {\n                            p.current = ip.current as u32;\n                        })\n                        .is_ok()\n                },\n                Some(cards),\n                None,\n            )?\n            .clamp(0.7, 0.95))\n    }\n\n    pub fn get_optimal_retention_parameters(\n        &mut self,\n        revlogs: Vec<RevlogEntry>,\n    ) -> Result<SimulatorConfig> {\n        let fsrs_revlog: Vec<fsrs::RevlogEntry> = revlogs.into_iter().map(|r| r.into()).collect();\n        let params =\n            extract_simulator_config(fsrs_revlog, self.timing_today()?.next_day_at.into(), true);\n        Ok(params)\n    }\n}\n\nimpl From<crate::revlog::RevlogReviewKind> for fsrs::RevlogReviewKind {\n    fn from(kind: crate::revlog::RevlogReviewKind) -> Self {\n        match kind {\n            crate::revlog::RevlogReviewKind::Learning => fsrs::RevlogReviewKind::Learning,\n            crate::revlog::RevlogReviewKind::Review => fsrs::RevlogReviewKind::Review,\n            crate::revlog::RevlogReviewKind::Relearning => fsrs::RevlogReviewKind::Relearning,\n            crate::revlog::RevlogReviewKind::Filtered => fsrs::RevlogReviewKind::Filtered,\n            crate::revlog::RevlogReviewKind::Manual\n            | crate::revlog::RevlogReviewKind::Rescheduled => fsrs::RevlogReviewKind::Manual,\n        }\n    }\n}\n\nimpl From<crate::revlog::RevlogEntry> for fsrs::RevlogEntry {\n    fn from(entry: crate::revlog::RevlogEntry) -> Self {\n        fsrs::RevlogEntry {\n            id: entry.id.into(),\n            cid: entry.cid.into(),\n            usn: entry.usn.into(),\n            button_chosen: entry.button_chosen,\n            interval: entry.interval,\n            last_interval: entry.last_interval,\n            ease_factor: entry.ease_factor,\n            taken_millis: entry.taken_millis,\n            review_kind: entry.review_kind.into(),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/simulator.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\nuse anki_proto::deck_config::deck_config::config::ReviewCardOrder;\nuse anki_proto::deck_config::deck_config::config::ReviewCardOrder::*;\nuse anki_proto::scheduler::SimulateFsrsReviewRequest;\nuse anki_proto::scheduler::SimulateFsrsReviewResponse;\nuse anki_proto::scheduler::SimulateFsrsWorkloadResponse;\nuse fsrs::simulate;\nuse fsrs::PostSchedulingFn;\nuse fsrs::ReviewPriorityFn;\nuse fsrs::SimulatorConfig;\nuse fsrs::FSRS;\nuse itertools::Itertools;\nuse rand::rngs::StdRng;\nuse rand::Rng;\nuse rayon::iter::IntoParallelIterator;\nuse rayon::iter::ParallelIterator;\n\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::card::FsrsMemoryState;\nuse crate::prelude::*;\nuse crate::scheduler::states::fuzz::constrained_fuzz_bounds;\nuse crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;\nuse crate::scheduler::states::load_balancer::interval_to_weekday;\nuse crate::scheduler::states::load_balancer::parse_easy_days_percentages;\nuse crate::scheduler::states::load_balancer::select_weighted_interval;\nuse crate::scheduler::states::load_balancer::EasyDay;\nuse crate::scheduler::states::load_balancer::LoadBalancerInterval;\nuse crate::search::SortMode;\n\npub(crate) fn apply_load_balance_and_easy_days(\n    interval: f32,\n    max_interval: f32,\n    day_elapsed: usize,\n    due_cnt_per_day: &[usize],\n    rng: &mut StdRng,\n    next_day_at: TimestampSecs,\n    easy_days_percentages: &[EasyDay; 7],\n) -> f32 {\n    let (lower, upper) = constrained_fuzz_bounds(interval, 1, max_interval as u32);\n    let mut review_counts = vec![0; upper as usize - lower as usize + 1];\n\n    // Fill review_counts with due counts for each interval\n    let start = day_elapsed + lower as usize;\n    let end = (day_elapsed + upper as usize + 1).min(due_cnt_per_day.len());\n    if start < due_cnt_per_day.len() {\n        let copy_len = (end - start).min(review_counts.len());\n        review_counts[..copy_len].copy_from_slice(&due_cnt_per_day[start..start + copy_len]);\n    }\n\n    let possible_intervals: Vec<u32> = (lower..=upper).collect();\n    let weekdays = possible_intervals\n        .iter()\n        .map(|interval| {\n            interval_to_weekday(\n                *interval,\n                next_day_at.adding_secs(day_elapsed as i64 * 86400),\n            )\n        })\n        .collect::<Vec<_>>();\n    let easy_days_modifier =\n        calculate_easy_days_modifiers(easy_days_percentages, &weekdays, &review_counts);\n\n    let intervals =\n        possible_intervals\n            .iter()\n            .enumerate()\n            .map(|(interval_index, &target_interval)| LoadBalancerInterval {\n                target_interval,\n                review_count: review_counts[interval_index],\n                sibling_modifier: 1.0,\n                easy_days_modifier: easy_days_modifier[interval_index],\n            });\n    let fuzz_seed = rng.random();\n    select_weighted_interval(intervals, Some(fuzz_seed)).unwrap() as f32\n}\n\nfn create_review_priority_fn(\n    review_order: ReviewCardOrder,\n    deck_size: usize,\n) -> Option<ReviewPriorityFn> {\n    // Helper macro to wrap closure in ReviewPriorityFn\n    macro_rules! wrap {\n        ($f:expr) => {\n            Some(ReviewPriorityFn(std::sync::Arc::new($f)))\n        };\n    }\n\n    match review_order {\n        // Ease-based ordering\n        EaseAscending => wrap!(|c, _w| -(c.difficulty * 100.0) as i32),\n        EaseDescending => wrap!(|c, _w| (c.difficulty * 100.0) as i32),\n\n        // Interval-based ordering\n        IntervalsAscending => wrap!(|c, _w| c.interval as i32),\n        IntervalsDescending => wrap!(|c, _w| (c.interval as i32).saturating_neg()),\n        // Retrievability-based ordering\n        RetrievabilityAscending => {\n            wrap!(move |c, w| (c.retrievability(w) * 1000.0) as i32)\n        }\n        RetrievabilityDescending => {\n            wrap!(move |c, w| -(c.retrievability(w) * 1000.0) as i32)\n        }\n\n        // Due date ordering\n        Day | DayThenDeck | DeckThenDay => {\n            wrap!(|c, _w| c.scheduled_due() as i32)\n        }\n\n        // Random ordering\n        Random => {\n            wrap!(move |_c, _w| rand::rng().random_range(0..deck_size) as i32)\n        }\n\n        // Not implemented yet\n        Added | ReverseAdded | RelativeOverdueness => None,\n    }\n}\n\npub(crate) fn is_included_card(c: &Card) -> bool {\n    c.queue != CardQueue::Suspended\n        && c.queue != CardQueue::PreviewRepeat\n        && c.ctype != CardType::New\n}\n\nimpl Collection {\n    pub fn simulate_request_to_config(\n        &mut self,\n        req: &SimulateFsrsReviewRequest,\n    ) -> Result<(SimulatorConfig, Vec<fsrs::Card>)> {\n        let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?;\n        let revlogs = guard\n            .col\n            .storage\n            .get_revlog_entries_for_searched_cards_in_card_order()?;\n        let mut cards = guard.col.storage.all_searched_cards()?;\n        drop(guard);\n        // calculate any missing memory state\n        for c in &mut cards {\n            if is_included_card(c) && c.memory_state.is_none() {\n                let fsrs_data = self.compute_memory_state(c.id)?;\n                c.memory_state = fsrs_data.state.map(Into::into);\n                c.desired_retention = Some(fsrs_data.desired_retention);\n                c.decay = Some(fsrs_data.decay);\n                self.storage.update_card(c)?;\n            }\n        }\n        let days_elapsed = self.timing_today().unwrap().days_elapsed as i32;\n        let new_cards = cards\n            .iter()\n            .filter(|c| c.ctype == CardType::New && c.queue != CardQueue::Suspended)\n            .count()\n            + req.deck_size as usize;\n        let fsrs = FSRS::new(Some(&req.params))?;\n        let mut converted_cards = cards\n            .into_iter()\n            .filter(is_included_card)\n            .filter_map(|c| {\n                let memory_state = match c.memory_state {\n                    Some(state) => state,\n                    // cards that lack memory states after compute_memory_state have no FSRS items,\n                    // implying a truncated or ignored revlog\n                    None => fsrs\n                        .memory_state_from_sm2(\n                            c.ease_factor(),\n                            c.interval as f32,\n                            req.historical_retention,\n                        )\n                        .ok()?\n                        .into(),\n                };\n                Card::convert(c, days_elapsed, memory_state)\n            })\n            .collect_vec();\n        let introduced_today_count = self\n            .search_cards(&format!(\"{} introduced:1\", &req.search), SortMode::NoOrder)?\n            .len()\n            .min(req.new_limit as usize);\n        if req.new_limit > 0 {\n            let new_cards = (0..new_cards).map(|i| fsrs::Card {\n                id: -(i as i64),\n                difficulty: f32::NEG_INFINITY,\n                stability: 1e-8,              // Not filtered by fsrs-rs\n                last_date: f32::NEG_INFINITY, // Treated as a new card in simulation\n                due: ((introduced_today_count + i) / req.new_limit as usize) as f32,\n                interval: f32::NEG_INFINITY,\n                lapses: 0,\n            });\n            converted_cards.extend(new_cards);\n        }\n        let deck_size = converted_cards.len();\n        let p = self.get_optimal_retention_parameters(revlogs)?;\n\n        let easy_days_percentages = parse_easy_days_percentages(&req.easy_days_percentages)?;\n        let next_day_at = self.timing_today()?.next_day_at;\n\n        let post_scheduling_fn: Option<PostSchedulingFn> =\n            if self.get_config_bool(BoolKey::LoadBalancerEnabled) {\n                Some(PostSchedulingFn(Arc::new(\n                    move |card, max_interval, today, due_cnt_per_day, rng| {\n                        apply_load_balance_and_easy_days(\n                            card.interval,\n                            max_interval,\n                            today,\n                            due_cnt_per_day,\n                            rng,\n                            next_day_at,\n                            &easy_days_percentages,\n                        )\n                    },\n                )))\n            } else {\n                None\n            };\n\n        let review_priority_fn = req\n            .review_order\n            .try_into()\n            .ok()\n            .and_then(|order| create_review_priority_fn(order, deck_size));\n\n        let config = SimulatorConfig {\n            deck_size,\n            learn_span: req.days_to_simulate as usize,\n            max_cost_perday: f32::MAX,\n            max_ivl: req.max_interval as f32,\n            first_rating_prob: p.first_rating_prob,\n            review_rating_prob: p.review_rating_prob,\n            learn_limit: req.new_limit as usize,\n            review_limit: req.review_limit as usize,\n            new_cards_ignore_review_limit: req.new_cards_ignore_review_limit,\n            suspend_after_lapses: req.suspend_after_lapse_count,\n            post_scheduling_fn,\n            review_priority_fn,\n            learning_step_transitions: p.learning_step_transitions,\n            relearning_step_transitions: p.relearning_step_transitions,\n            state_rating_costs: p.state_rating_costs,\n            learning_step_count: req.learning_step_count as usize,\n            relearning_step_count: req.relearning_step_count as usize,\n        };\n\n        Ok((config, converted_cards))\n    }\n\n    pub fn simulate_review(\n        &mut self,\n        req: SimulateFsrsReviewRequest,\n    ) -> Result<SimulateFsrsReviewResponse> {\n        let (config, cards) = self.simulate_request_to_config(&req)?;\n        let result = simulate(\n            &config,\n            &req.params,\n            req.desired_retention,\n            None,\n            Some(cards),\n        )?;\n        Ok(SimulateFsrsReviewResponse {\n            accumulated_knowledge_acquisition: result.memorized_cnt_per_day,\n            daily_review_count: result\n                .review_cnt_per_day\n                .iter()\n                .map(|x| *x as u32)\n                .collect_vec(),\n            daily_new_count: result\n                .learn_cnt_per_day\n                .iter()\n                .map(|x| *x as u32)\n                .collect_vec(),\n            daily_time_cost: result.cost_per_day,\n        })\n    }\n\n    pub fn simulate_workload(\n        &mut self,\n        req: SimulateFsrsReviewRequest,\n    ) -> Result<SimulateFsrsWorkloadResponse> {\n        let (config, cards) = self.simulate_request_to_config(&req)?;\n        let dr_workload = (70u32..=99u32)\n            .into_par_iter()\n            .map(|dr| {\n                let result = simulate(\n                    &config,\n                    &req.params,\n                    dr as f32 / 100.,\n                    None,\n                    Some(cards.clone()),\n                )?;\n                Ok((\n                    dr,\n                    (\n                        *result.memorized_cnt_per_day.last().unwrap_or(&0.),\n                        result.cost_per_day.iter().sum::<f32>(),\n                        result.review_cnt_per_day.iter().sum::<usize>() as u32\n                            + result.learn_cnt_per_day.iter().sum::<usize>() as u32,\n                    ),\n                ))\n            })\n            .collect::<Result<HashMap<_, _>>>()?;\n        Ok(SimulateFsrsWorkloadResponse {\n            memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(),\n            cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(),\n            review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(),\n        })\n    }\n}\n\nimpl Card {\n    pub(crate) fn convert(\n        card: Card,\n        days_elapsed: i32,\n        memory_state: FsrsMemoryState,\n    ) -> Option<fsrs::Card> {\n        match card.queue {\n            CardQueue::DayLearn | CardQueue::Review => {\n                let due = card.original_or_current_due();\n                let relative_due = due - days_elapsed;\n                let last_date = (relative_due - card.interval as i32).min(0) as f32;\n                Some(fsrs::Card {\n                    id: card.id.0,\n                    difficulty: memory_state.difficulty,\n                    stability: memory_state.stability,\n                    last_date,\n                    due: relative_due as f32,\n                    interval: card.interval as f32,\n                    lapses: card.lapses,\n                })\n            }\n            CardQueue::New => None,\n            CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => Some(fsrs::Card {\n                id: card.id.0,\n                difficulty: memory_state.difficulty,\n                stability: memory_state.stability,\n                last_date: 0.0,\n                due: 0.0,\n                interval: card.interval as f32,\n                lapses: card.lapses,\n            }),\n            CardQueue::PreviewRepeat => None,\n            CardQueue::Suspended => None,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/fsrs/try_collect.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::error::AnkiError;\nuse crate::invalid_input;\n\n// Roll our own implementation until this becomes stable\n// https://github.com/rust-lang/rust/issues/94047\n#[allow(unused)]\npub(crate) trait TryCollect: ExactSizeIterator {\n    fn try_collect<const N: usize>(self) -> Result<[Self::Item; N], AnkiError>\n    where\n        // Self: Sized,\n        Self::Item: Copy + Default;\n}\n\nimpl<I, T> TryCollect for I\nwhere\n    I: ExactSizeIterator<Item = T>,\n    T: Copy + Default,\n{\n    fn try_collect<const N: usize>(self) -> Result<[T; N], AnkiError> {\n        if self.len() != N {\n            invalid_input!(\"expected {N}; got {}\", self.len());\n        }\n\n        let mut result = [T::default(); N];\n        for (index, value) in self.enumerate() {\n            result[index] = value;\n        }\n\n        Ok(result)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::collection::Collection;\nuse crate::config::SchedulerVersion;\nuse crate::error::Result;\nuse crate::prelude::*;\n\npub mod answering;\npub mod bury_and_suspend;\npub(crate) mod congrats;\npub(crate) mod filtered;\npub mod fsrs;\npub mod new;\npub(crate) mod queue;\nmod reviews;\nmod service;\npub mod states;\npub mod timespan;\npub mod timing;\nmod upgrade;\n\nuse chrono::FixedOffset;\npub use reviews::parse_due_date_str;\nuse timing::sched_timing_today;\nuse timing::SchedTimingToday;\n\n#[derive(Debug, Clone, Copy)]\npub struct SchedulerInfo {\n    pub version: SchedulerVersion,\n    pub timing: SchedTimingToday,\n}\n\nimpl Collection {\n    pub fn scheduler_info(&mut self) -> Result<SchedulerInfo> {\n        let now = TimestampSecs::now();\n        if let Some(mut info) = self.state.scheduler_info {\n            if now < info.timing.next_day_at {\n                info.timing.now = now;\n                return Ok(info);\n            }\n        }\n        let version = self.scheduler_version();\n        let timing = self.timing_for_timestamp(now)?;\n        let info = SchedulerInfo { version, timing };\n        self.state.scheduler_info = Some(info);\n        Ok(info)\n    }\n\n    pub fn timing_today(&mut self) -> Result<SchedTimingToday> {\n        self.scheduler_info().map(|info| info.timing)\n    }\n\n    pub fn current_due_day(&mut self, delta: i32) -> Result<u32> {\n        Ok(((self.timing_today()?.days_elapsed as i32) + delta).max(0) as u32)\n    }\n\n    pub(crate) fn timing_for_timestamp(&mut self, now: TimestampSecs) -> Result<SchedTimingToday> {\n        let current_utc_offset = self.local_utc_offset_for_user()?;\n\n        let rollover_hour = match self.scheduler_version() {\n            SchedulerVersion::V1 => None,\n            SchedulerVersion::V2 => {\n                let configured_rollover = self.get_v2_rollover();\n                match configured_rollover {\n                    None => {\n                        // an older Anki version failed to set this; correct\n                        // the issue\n                        self.set_v2_rollover(4)?;\n                        Some(4)\n                    }\n                    val => val,\n                }\n            }\n        };\n\n        sched_timing_today(\n            self.storage.creation_stamp()?,\n            now,\n            self.creation_utc_offset(),\n            current_utc_offset,\n            rollover_hour,\n        )\n    }\n\n    /// In the client case, return the current local timezone offset,\n    /// ensuring the config reflects the current value.\n    /// In the server case, return the value set in the config, and\n    /// fall back on UTC if it's missing/invalid.\n    pub(crate) fn local_utc_offset_for_user(&mut self) -> Result<FixedOffset> {\n        let config_tz = self\n            .get_configured_utc_offset()\n            .and_then(|v| FixedOffset::west_opt(v * 60))\n            .unwrap_or_else(|| FixedOffset::west_opt(0).unwrap());\n\n        let local_tz = TimestampSecs::now().local_utc_offset()?;\n\n        Ok(if self.server {\n            config_tz\n        } else {\n            // if the timezone has changed, update the config\n            if config_tz != local_tz {\n                self.set_configured_utc_offset(local_tz.utc_minus_local() / 60)?;\n            }\n            local_tz\n        })\n    }\n\n    /// Return the timezone offset at collection creation time. This should\n    /// only be set when the V2 scheduler is active and the new timezone\n    /// code is enabled.\n    fn creation_utc_offset(&self) -> Option<FixedOffset> {\n        self.get_creation_utc_offset()\n            .and_then(|v| FixedOffset::west_opt(v * 60))\n    }\n\n    pub fn rollover_for_current_scheduler(&self) -> Result<u8> {\n        match self.scheduler_version() {\n            SchedulerVersion::V1 => Err(AnkiError::SchedulerUpgradeRequired),\n            SchedulerVersion::V2 => Ok(self.get_v2_rollover().unwrap_or(4)),\n        }\n    }\n\n    pub(crate) fn set_rollover_for_current_scheduler(&mut self, hour: u8) -> Result<()> {\n        match self.scheduler_version() {\n            SchedulerVersion::V1 => Err(AnkiError::SchedulerUpgradeRequired),\n            SchedulerVersion::V2 => self.set_v2_rollover(hour as u32),\n        }\n    }\n\n    pub(crate) fn set_creation_stamp(&mut self, stamp: TimestampSecs) -> Result<()> {\n        self.state.scheduler_info = None;\n        self.storage.set_creation_stamp(stamp)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/new.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\npub use anki_proto::scheduler::schedule_cards_as_new_request::Context as ScheduleAsNewContext;\npub use anki_proto::scheduler::RepositionDefaultsResponse;\npub use anki_proto::scheduler::ScheduleCardsAsNewDefaultsResponse;\nuse rand::seq::SliceRandom;\n\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::config::BoolKey;\nuse crate::config::SchedulerVersion;\nuse crate::deckconfig::NewCardInsertOrder;\nuse crate::prelude::*;\nuse crate::search::JoinSearches;\nuse crate::search::SearchNode;\nuse crate::search::SortMode;\nuse crate::search::StateKind;\n\nimpl Card {\n    pub(crate) fn original_or_current_due(&self) -> i32 {\n        if self.is_filtered() {\n            self.original_due\n        } else {\n            self.due\n        }\n    }\n\n    pub(crate) fn last_position(&self) -> Option<u32> {\n        if self.ctype == CardType::New {\n            Some(self.original_or_current_due() as u32)\n        } else {\n            self.original_position\n        }\n    }\n\n    /// True if the provided position has been used.\n    /// (Always true, if restore_position is false.)\n    pub(crate) fn schedule_as_new(\n        &mut self,\n        position: u32,\n        reset_counts: bool,\n        restore_position: bool,\n    ) -> bool {\n        let last_position = restore_position.then(|| self.last_position()).flatten();\n        self.remove_from_filtered_deck_before_reschedule();\n        self.due = last_position.unwrap_or(position) as i32;\n        self.ctype = CardType::New;\n        self.queue = CardQueue::New;\n        self.interval = 0;\n        self.ease_factor = 0;\n        self.original_position = None;\n        if reset_counts {\n            self.reps = 0;\n            self.lapses = 0;\n        }\n        self.memory_state = None;\n\n        last_position.is_none()\n    }\n\n    /// If the card is new, change its position, and return true.\n    fn set_new_position(&mut self, position: u32) -> bool {\n        if self.ctype == CardType::New {\n            if self.is_filtered() {\n                self.original_due = position as i32;\n            } else {\n                self.due = position as i32;\n            }\n            true\n        } else if self.queue == CardQueue::New {\n            self.due = position as i32;\n            true\n        } else {\n            false\n        }\n    }\n}\npub(crate) struct NewCardSorter {\n    position: HashMap<NoteId, u32>,\n}\n\n#[derive(PartialEq, Eq)]\npub enum NewCardDueOrder {\n    NoteId,\n    Random,\n    Preserve,\n}\n\nimpl NewCardSorter {\n    pub(crate) fn new(\n        cards: &[Card],\n        starting_from: u32,\n        step: u32,\n        order: NewCardDueOrder,\n    ) -> Self {\n        let nids = nids_in_desired_order(cards, order);\n\n        NewCardSorter {\n            position: nids\n                .into_iter()\n                .enumerate()\n                .map(|(i, nid)| (nid, ((i as u32) * step) + starting_from))\n                .collect(),\n        }\n    }\n\n    pub(crate) fn position(&self, card: &Card) -> u32 {\n        self.position\n            .get(&card.note_id)\n            .cloned()\n            .unwrap_or_default()\n    }\n}\n\nfn nids_in_desired_order(cards: &[Card], order: NewCardDueOrder) -> Vec<NoteId> {\n    if order == NewCardDueOrder::Preserve {\n        nids_in_preserved_order(cards)\n    } else {\n        let nids: HashSet<_> = cards.iter().map(|c| c.note_id).collect();\n        let mut nids: Vec<_> = nids.into_iter().collect();\n        match order {\n            NewCardDueOrder::NoteId => {\n                nids.sort_unstable();\n            }\n            NewCardDueOrder::Random => {\n                nids.shuffle(&mut rand::rng());\n            }\n            NewCardDueOrder::Preserve => unreachable!(),\n        }\n        nids\n    }\n}\n\nfn nids_in_preserved_order(cards: &[Card]) -> Vec<NoteId> {\n    let mut seen = HashSet::new();\n    cards\n        .iter()\n        .filter_map(|card| {\n            if seen.insert(card.note_id) {\n                Some(card.note_id)\n            } else {\n                None\n            }\n        })\n        .collect()\n}\n\nimpl Collection {\n    pub fn reschedule_cards_as_new(\n        &mut self,\n        cids: &[CardId],\n        log: bool,\n        restore_position: bool,\n        reset_counts: bool,\n        context: Option<ScheduleAsNewContext>,\n    ) -> Result<OpOutput<()>> {\n        let usn = self.usn()?;\n        let mut position = self.get_next_card_position();\n        self.transact(Op::ScheduleAsNew, |col| {\n            let cards = col.all_cards_for_ids(cids, true)?;\n            for mut card in cards {\n                let original = card.clone();\n                if card.schedule_as_new(position, reset_counts, restore_position) {\n                    position += 1;\n                }\n                if log {\n                    col.log_manually_scheduled_review(&card, original.interval, usn)?;\n                }\n                col.update_card_inner(&mut card, original, usn)?;\n            }\n            col.set_next_card_position(position)?;\n\n            match context {\n                Some(ScheduleAsNewContext::Browser) => {\n                    col.set_config_bool_inner(BoolKey::RestorePositionBrowser, restore_position)?;\n                    col.set_config_bool_inner(BoolKey::ResetCountsBrowser, reset_counts)?;\n                }\n                Some(ScheduleAsNewContext::Reviewer) => {\n                    col.set_config_bool_inner(BoolKey::RestorePositionReviewer, restore_position)?;\n                    col.set_config_bool_inner(BoolKey::ResetCountsReviewer, reset_counts)?;\n                }\n                None => (),\n            }\n\n            Ok(())\n        })\n    }\n\n    pub fn reschedule_cards_as_new_defaults(\n        &self,\n        context: ScheduleAsNewContext,\n    ) -> ScheduleCardsAsNewDefaultsResponse {\n        match context {\n            ScheduleAsNewContext::Browser => ScheduleCardsAsNewDefaultsResponse {\n                restore_position: self.get_config_bool(BoolKey::RestorePositionBrowser),\n                reset_counts: self.get_config_bool(BoolKey::ResetCountsBrowser),\n            },\n            ScheduleAsNewContext::Reviewer => ScheduleCardsAsNewDefaultsResponse {\n                restore_position: self.get_config_bool(BoolKey::RestorePositionReviewer),\n                reset_counts: self.get_config_bool(BoolKey::ResetCountsReviewer),\n            },\n        }\n    }\n\n    pub fn sort_cards(\n        &mut self,\n        cids: &[CardId],\n        starting_from: u32,\n        step: u32,\n        order: NewCardDueOrder,\n        shift: bool,\n    ) -> Result<OpOutput<usize>> {\n        let usn = self.usn()?;\n        self.transact(Op::SortCards, |col| {\n            col.set_config_bool_inner(\n                BoolKey::RandomOrderReposition,\n                order == NewCardDueOrder::Random,\n            )?;\n            col.set_config_bool_inner(BoolKey::ShiftPositionOfExistingCards, shift)?;\n            col.sort_cards_inner(cids, starting_from, step, order, shift, usn)\n        })\n    }\n\n    fn sort_cards_inner(\n        &mut self,\n        cids: &[CardId],\n        starting_from: u32,\n        step: u32,\n        order: NewCardDueOrder,\n        shift: bool,\n        usn: Usn,\n    ) -> Result<usize> {\n        if self.scheduler_version() == SchedulerVersion::V1 {\n            return Err(AnkiError::SchedulerUpgradeRequired);\n        }\n        if shift {\n            self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?;\n        }\n        let cards = self.all_cards_for_ids(cids, true)?;\n        let sorter = NewCardSorter::new(&cards, starting_from, step, order);\n        let mut count = 0;\n        for mut card in cards {\n            let original = card.clone();\n            if card.set_new_position(sorter.position(&card)) {\n                count += 1;\n                self.update_card_inner(&mut card, original, usn)?;\n            }\n        }\n        Ok(count)\n    }\n\n    pub fn reposition_defaults(&self) -> RepositionDefaultsResponse {\n        RepositionDefaultsResponse {\n            random: self.get_config_bool(BoolKey::RandomOrderReposition),\n            shift: self.get_config_bool(BoolKey::ShiftPositionOfExistingCards),\n        }\n    }\n\n    /// This is handled by update_deck_configs() now; this function has been\n    /// kept around for now to support the old deck config screen.\n    pub fn sort_deck_legacy(&mut self, deck: DeckId, random: bool) -> Result<OpOutput<usize>> {\n        self.transact(Op::SortCards, |col| {\n            col.sort_deck(\n                deck,\n                if random {\n                    NewCardInsertOrder::Random\n                } else {\n                    NewCardInsertOrder::Due\n                },\n                col.usn()?,\n            )\n        })\n    }\n\n    pub(crate) fn sort_deck(\n        &mut self,\n        deck: DeckId,\n        order: NewCardInsertOrder,\n        usn: Usn,\n    ) -> Result<usize> {\n        let cids = self.search_cards(\n            SearchNode::DeckIdsWithoutChildren(deck.to_string()).and(StateKind::New),\n            SortMode::NoOrder,\n        )?;\n        self.sort_cards_inner(&cids, 1, 1, order.into(), false, usn)\n    }\n\n    fn shift_existing_cards(&mut self, start: u32, by: u32, usn: Usn) -> Result<()> {\n        for mut card in self.storage.all_cards_at_or_above_position(start)? {\n            let original = card.clone();\n            card.set_new_position(card.due as u32 + by);\n            self.update_card_inner(&mut card, original, usn)?;\n        }\n        Ok(())\n    }\n}\n\nimpl From<NewCardInsertOrder> for NewCardDueOrder {\n    fn from(o: NewCardInsertOrder) -> Self {\n        match o {\n            NewCardInsertOrder::Due => NewCardDueOrder::NoteId,\n            NewCardInsertOrder::Random => NewCardDueOrder::Random,\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn new_order() {\n        let mut c1 = Card::new(NoteId(6), 0, DeckId(0), 0);\n        c1.id.0 = 2;\n        let mut c2 = Card::new(NoteId(5), 0, DeckId(0), 0);\n        c2.id.0 = 3;\n        let mut c3 = Card::new(NoteId(4), 0, DeckId(0), 0);\n        c3.id.0 = 1;\n        let cards = vec![c1.clone(), c2.clone(), c3.clone()];\n\n        // Preserve\n        let sorter = NewCardSorter::new(&cards, 0, 1, NewCardDueOrder::Preserve);\n        assert_eq!(sorter.position(&c1), 0);\n        assert_eq!(sorter.position(&c2), 1);\n        assert_eq!(sorter.position(&c3), 2);\n\n        // NoteId/step/starting\n        let sorter = NewCardSorter::new(&cards, 3, 2, NewCardDueOrder::NoteId);\n        assert_eq!(sorter.position(&c3), 3);\n        assert_eq!(sorter.position(&c2), 5);\n        assert_eq!(sorter.position(&c1), 7);\n\n        // Random\n        let mut c1_positions = HashSet::new();\n        for _ in 1..100 {\n            let sorter = NewCardSorter::new(&cards, 0, 1, NewCardDueOrder::Random);\n            c1_positions.insert(sorter.position(&c1));\n            if c1_positions.len() == cards.len() {\n                return;\n            }\n        }\n        unreachable!(\"not random\");\n    }\n\n    #[test]\n    fn last_position() {\n        // new card\n        let mut card = Card::new(NoteId(0), 0, DeckId(1), 42);\n        assert_eq!(card.last_position(), Some(42));\n        // in filtered deck\n        card.original_deck_id.0 = 1;\n        card.deck_id.0 = 2;\n        card.original_due = 42;\n        card.due = 123456789;\n        card.queue = CardQueue::Review;\n        assert_eq!(card.last_position(), Some(42));\n\n        // graduated card\n        let mut card = Card::new(NoteId(0), 0, DeckId(1), 42);\n        card.queue = CardQueue::Review;\n        card.ctype = CardType::Review;\n        card.due = 123456789;\n        // only recent clients remember the original position\n        assert_eq!(card.last_position(), None);\n        card.original_position = Some(42);\n        assert_eq!(card.last_position(), Some(42));\n    }\n\n    #[test]\n    fn scheduling_as_new() {\n        let mut card = Card::new(NoteId(0), 0, DeckId(1), 42);\n        card.reps = 4;\n        card.lapses = 2;\n        // keep counts and position\n        card.schedule_as_new(1, false, true);\n        assert_eq!((card.due, card.reps, card.lapses), (42, 4, 2));\n        // complete reset\n        card.schedule_as_new(1, true, false);\n        assert_eq!((card.due, card.reps, card.lapses), (1, 0, 0));\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/burying.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::super::BuryMode;\nuse super::Context;\nuse super::DueCard;\nuse super::NewCard;\nuse super::QueueBuilder;\nuse crate::deckconfig::DeckConfig;\nuse crate::prelude::*;\n\npub(super) enum DueOrNewCard {\n    Due(DueCard),\n    New(NewCard),\n}\n\nimpl DueOrNewCard {\n    fn original_deck_id(&self) -> DeckId {\n        match self {\n            Self::Due(card) => card.original_deck_id.or(card.current_deck_id),\n            Self::New(card) => card.original_deck_id.or(card.current_deck_id),\n        }\n    }\n\n    fn note_id(&self) -> NoteId {\n        match self {\n            Self::Due(card) => card.note_id,\n            Self::New(card) => card.note_id,\n        }\n    }\n}\n\nimpl From<DueCard> for DueOrNewCard {\n    fn from(card: DueCard) -> DueOrNewCard {\n        DueOrNewCard::Due(card)\n    }\n}\n\nimpl From<NewCard> for DueOrNewCard {\n    fn from(card: NewCard) -> DueOrNewCard {\n        DueOrNewCard::New(card)\n    }\n}\n\nimpl Context {\n    pub(super) fn bury_mode(&self, deck_id: DeckId) -> BuryMode {\n        self.deck_map\n            .get(&deck_id)\n            .and_then(|deck| deck.config_id())\n            .and_then(|config_id| self.config_map.get(&config_id))\n            .map(BuryMode::from_deck_config)\n            .unwrap_or_default()\n    }\n}\n\nimpl BuryMode {\n    pub(crate) fn from_deck_config(config: &DeckConfig) -> BuryMode {\n        let cfg = &config.inner;\n        BuryMode {\n            bury_new: cfg.bury_new,\n            bury_reviews: cfg.bury_reviews,\n            bury_interday_learning: cfg.bury_interday_learning,\n        }\n    }\n\n    pub(crate) fn any_burying(self) -> bool {\n        self.bury_interday_learning || self.bury_reviews || self.bury_new\n    }\n}\n\nimpl QueueBuilder {\n    /// If burying is enabled in `new_settings`, existing entry will be updated.\n    /// Returns a copy made before changing the entry, so that a card with\n    /// burying enabled will bury future siblings, but not itself.\n    pub(super) fn get_and_update_bury_mode_for_note(\n        &mut self,\n        card: DueOrNewCard,\n    ) -> Option<BuryMode> {\n        let mut previous_mode = None;\n        let new_mode = self.context.bury_mode(card.original_deck_id());\n        self.context\n            .seen_note_ids\n            .entry(card.note_id())\n            .and_modify(|entry| {\n                previous_mode = Some(*entry);\n                entry.bury_new |= new_mode.bury_new;\n                entry.bury_reviews |= new_mode.bury_reviews;\n                entry.bury_interday_learning |= new_mode.bury_interday_learning;\n            })\n            .or_insert(new_mode);\n\n        previous_mode\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/gathering.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::DueCard;\nuse super::NewCard;\nuse super::QueueBuilder;\nuse crate::deckconfig::NewCardGatherPriority;\nuse crate::decks::limits::LimitKind;\nuse crate::prelude::*;\nuse crate::scheduler::queue::DueCardKind;\nuse crate::storage::card::NewCardSorting;\n\nimpl QueueBuilder {\n    pub(super) fn gather_cards(&mut self, col: &mut Collection) -> Result<()> {\n        self.gather_intraday_learning_cards(col)?;\n        self.gather_due_cards(col, DueCardKind::Learning)?;\n        self.gather_due_cards(col, DueCardKind::Review)?;\n        self.gather_new_cards(col)?;\n\n        Ok(())\n    }\n\n    fn gather_intraday_learning_cards(&mut self, col: &mut Collection) -> Result<()> {\n        col.storage.for_each_intraday_card_in_active_decks(\n            self.context.timing.next_day_at,\n            |card| {\n                self.get_and_update_bury_mode_for_note(card.into());\n                self.learning.push(card);\n            },\n        )?;\n\n        Ok(())\n    }\n\n    fn gather_due_cards(&mut self, col: &mut Collection, kind: DueCardKind) -> Result<()> {\n        if self.limits.root_limit_reached(LimitKind::Review) {\n            return Ok(());\n        }\n        col.storage.for_each_due_card_in_active_decks(\n            self.context.timing,\n            self.context.sort_options.review_order,\n            kind,\n            self.context.fsrs,\n            |card| {\n                if self.limits.root_limit_reached(LimitKind::Review) {\n                    return Ok(false);\n                }\n                if !self\n                    .limits\n                    .limit_reached(card.current_deck_id, LimitKind::Review)?\n                    && self.add_due_card(card)\n                {\n                    self.limits.decrement_deck_and_parent_limits(\n                        card.current_deck_id,\n                        LimitKind::Review,\n                    )?;\n                }\n                Ok(true)\n            },\n        )\n    }\n\n    fn gather_new_cards(&mut self, col: &mut Collection) -> Result<()> {\n        let salt = Self::knuth_salt(self.context.timing.days_elapsed);\n        match self.context.sort_options.new_gather_priority {\n            NewCardGatherPriority::Deck => {\n                self.gather_new_cards_by_deck(col, NewCardSorting::LowestPosition)\n            }\n            NewCardGatherPriority::DeckThenRandomNotes => {\n                self.gather_new_cards_by_deck(col, NewCardSorting::RandomNotes(salt))\n            }\n            NewCardGatherPriority::LowestPosition => {\n                self.gather_new_cards_sorted(col, NewCardSorting::LowestPosition)\n            }\n            NewCardGatherPriority::HighestPosition => {\n                self.gather_new_cards_sorted(col, NewCardSorting::HighestPosition)\n            }\n            NewCardGatherPriority::RandomNotes => {\n                self.gather_new_cards_sorted(col, NewCardSorting::RandomNotes(salt))\n            }\n            NewCardGatherPriority::RandomCards => {\n                self.gather_new_cards_sorted(col, NewCardSorting::RandomCards(salt))\n            }\n        }\n    }\n\n    fn gather_new_cards_by_deck(\n        &mut self,\n        col: &mut Collection,\n        sort: NewCardSorting,\n    ) -> Result<()> {\n        for deck_id in col.storage.get_active_deck_ids_sorted()? {\n            if self.limits.root_limit_reached(LimitKind::New) {\n                break;\n            }\n            if self.limits.limit_reached(deck_id, LimitKind::New)? {\n                continue;\n            }\n            col.storage\n                .for_each_new_card_in_deck(deck_id, sort, |card| {\n                    let limit_reached = self.limits.limit_reached(deck_id, LimitKind::New)?;\n                    if !limit_reached && self.add_new_card(card) {\n                        self.limits\n                            .decrement_deck_and_parent_limits(deck_id, LimitKind::New)?;\n                    }\n                    Ok(!limit_reached)\n                })?;\n        }\n\n        Ok(())\n    }\n\n    fn gather_new_cards_sorted(\n        &mut self,\n        col: &mut Collection,\n        order: NewCardSorting,\n    ) -> Result<()> {\n        col.storage\n            .for_each_new_card_in_active_decks(order, |card| {\n                if self.limits.root_limit_reached(LimitKind::New) {\n                    return Ok(false);\n                }\n                if !self\n                    .limits\n                    .limit_reached(card.current_deck_id, LimitKind::New)?\n                    && self.add_new_card(card)\n                {\n                    self.limits\n                        .decrement_deck_and_parent_limits(card.current_deck_id, LimitKind::New)?;\n                }\n                Ok(true)\n            })\n    }\n\n    /// True if limit should be decremented.\n    fn add_due_card(&mut self, card: DueCard) -> bool {\n        let bury_this_card = self\n            .get_and_update_bury_mode_for_note(card.into())\n            .map(|mode| match card.kind {\n                DueCardKind::Review => mode.bury_reviews,\n                DueCardKind::Learning => mode.bury_interday_learning,\n            })\n            .unwrap_or_default();\n        if bury_this_card {\n            false\n        } else {\n            match card.kind {\n                DueCardKind::Review => self.review.push(card),\n                DueCardKind::Learning => self.day_learning.push(card),\n            }\n\n            true\n        }\n    }\n\n    // True if limit should be decremented.\n    fn add_new_card(&mut self, card: NewCard) -> bool {\n        let bury_this_card = self\n            .get_and_update_bury_mode_for_note(card.into())\n            .map(|mode| mode.bury_new)\n            .unwrap_or_default();\n        // no previous siblings seen?\n        if bury_this_card {\n            false\n        } else {\n            self.new.push(card);\n            true\n        }\n    }\n\n    // Generates a salt for use with fnvhash. Useful to increase randomness\n    // when the base salt is a small integer.\n    fn knuth_salt(base_salt: u32) -> u32 {\n        base_salt.wrapping_mul(2654435761)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/intersperser.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/// Adapter to evenly mix two iterators of varying lengths into one.\npub(crate) struct Intersperser<I, I2>\nwhere\n    I: Iterator + ExactSizeIterator,\n{\n    one: I,\n    two: I2,\n    one_idx: usize,\n    two_idx: usize,\n    one_len: usize,\n    two_len: usize,\n    ratio: f32,\n}\n\nimpl<I, I2> Intersperser<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n    pub fn new(one: I, two: I2) -> Self {\n        let one_len = one.len();\n        let two_len = two.len();\n        let ratio = (one_len + 1) as f32 / (two_len + 1) as f32;\n        Intersperser {\n            one,\n            two,\n            one_idx: 0,\n            two_idx: 0,\n            one_len,\n            two_len,\n            ratio,\n        }\n    }\n\n    fn one_idx(&self) -> Option<usize> {\n        if self.one_idx == self.one_len {\n            None\n        } else {\n            Some(self.one_idx)\n        }\n    }\n\n    fn two_idx(&self) -> Option<usize> {\n        if self.two_idx == self.two_len {\n            None\n        } else {\n            Some(self.two_idx)\n        }\n    }\n\n    fn next_one(&mut self) -> Option<I::Item> {\n        self.one_idx += 1;\n        self.one.next()\n    }\n\n    fn next_two(&mut self) -> Option<I::Item> {\n        self.two_idx += 1;\n        self.two.next()\n    }\n}\n\nimpl<I, I2> Iterator for Intersperser<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n    type Item = I::Item;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        match (self.one_idx(), self.two_idx()) {\n            (Some(idx1), Some(idx2)) => {\n                let relative_idx2 = (idx2 + 1) as f32 * self.ratio;\n                if relative_idx2 < (idx1 + 1) as f32 {\n                    self.next_two()\n                } else {\n                    self.next_one()\n                }\n            }\n            (Some(_), None) => self.next_one(),\n            (None, Some(_)) => self.next_two(),\n            (None, None) => None,\n        }\n    }\n\n    fn size_hint(&self) -> (usize, Option<usize>) {\n        let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx);\n        (remaining, Some(remaining))\n    }\n}\n\nimpl<I, I2> ExactSizeIterator for Intersperser<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n}\n\n#[cfg(test)]\nmod test {\n    use super::Intersperser;\n\n    fn intersperse(a: &[u32], b: &[u32]) -> Vec<u32> {\n        Intersperser::new(a.iter().cloned(), b.iter().cloned()).collect()\n    }\n\n    #[test]\n    fn interspersing() {\n        let a = &[1, 2, 3];\n        let b = &[11, 22, 33];\n        assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3, 33]);\n\n        let b = &[11, 22];\n        assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3]);\n\n        // always add from longer iter first\n        let b = &[11, 22, 33, 44, 55, 66];\n        assert_eq!(&intersperse(a, b), &[11, 1, 22, 33, 2, 44, 55, 3, 66]);\n\n        // space is distributed as evenly as possible between elements of\n        // the same iter and start and end\n        let b = &[11, 22, 33, 44, 55, 66, 77, 88];\n        assert_eq!(\n            &intersperse(a, b),\n            &[11, 22, 1, 33, 44, 2, 55, 66, 3, 77, 88]\n        );\n\n        let b = &[];\n        assert_eq!(&intersperse(a, b), &[1, 2, 3]);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod burying;\nmod gathering;\npub(crate) mod intersperser;\npub(crate) mod sized_chain;\nmod sorting;\n\nuse std::collections::HashMap;\nuse std::collections::VecDeque;\n\nuse intersperser::Intersperser;\nuse sized_chain::SizedChain;\n\nuse super::BuryMode;\nuse super::CardQueues;\nuse super::Counts;\nuse super::LearningQueueEntry;\nuse super::MainQueueEntry;\nuse super::MainQueueEntryKind;\nuse crate::deckconfig::NewCardGatherPriority;\nuse crate::deckconfig::NewCardSortOrder;\nuse crate::deckconfig::ReviewCardOrder;\nuse crate::deckconfig::ReviewMix;\nuse crate::decks::limits::LimitTreeMap;\nuse crate::prelude::*;\nuse crate::scheduler::states::load_balancer::LoadBalancer;\nuse crate::scheduler::timing::SchedTimingToday;\n\n/// Temporary holder for review cards that will be built into a queue.\n#[derive(Debug, Clone, Copy)]\npub(crate) struct DueCard {\n    pub id: CardId,\n    pub note_id: NoteId,\n    pub mtime: TimestampSecs,\n    pub due: i32,\n    pub current_deck_id: DeckId,\n    pub original_deck_id: DeckId,\n    pub kind: DueCardKind,\n}\n\n#[derive(Debug, Clone, Copy)]\npub(crate) enum DueCardKind {\n    Review,\n    Learning,\n}\n\n/// Temporary holder for new cards that will be built into a queue.\n#[derive(Debug, Default, Clone, Copy)]\npub(crate) struct NewCard {\n    pub id: CardId,\n    pub note_id: NoteId,\n    pub mtime: TimestampSecs,\n    pub current_deck_id: DeckId,\n    pub original_deck_id: DeckId,\n    pub template_index: u32,\n    pub hash: u64,\n}\n\nimpl From<DueCard> for MainQueueEntry {\n    fn from(c: DueCard) -> Self {\n        MainQueueEntry {\n            id: c.id,\n            mtime: c.mtime,\n            kind: match c.kind {\n                DueCardKind::Review => MainQueueEntryKind::Review,\n                DueCardKind::Learning => MainQueueEntryKind::InterdayLearning,\n            },\n        }\n    }\n}\n\nimpl From<NewCard> for MainQueueEntry {\n    fn from(c: NewCard) -> Self {\n        MainQueueEntry {\n            id: c.id,\n            mtime: c.mtime,\n            kind: MainQueueEntryKind::New,\n        }\n    }\n}\n\nimpl From<DueCard> for LearningQueueEntry {\n    fn from(c: DueCard) -> Self {\n        LearningQueueEntry {\n            due: TimestampSecs(c.due as i64),\n            id: c.id,\n            mtime: c.mtime,\n        }\n    }\n}\n\n#[derive(Default, Clone, Debug)]\npub(super) struct QueueSortOptions {\n    pub(super) new_order: NewCardSortOrder,\n    pub(super) new_gather_priority: NewCardGatherPriority,\n    pub(super) review_order: ReviewCardOrder,\n    pub(super) day_learn_mix: ReviewMix,\n    pub(super) new_review_mix: ReviewMix,\n}\n\n#[derive(Debug)]\npub(super) struct QueueBuilder {\n    pub(super) new: Vec<NewCard>,\n    pub(super) review: Vec<DueCard>,\n    pub(super) learning: Vec<DueCard>,\n    pub(super) day_learning: Vec<DueCard>,\n    limits: LimitTreeMap,\n    load_balancer: Option<LoadBalancer>,\n    context: Context,\n}\n\n/// Data container and helper for building queues.\n#[derive(Debug, Clone)]\nstruct Context {\n    timing: SchedTimingToday,\n    config_map: HashMap<DeckConfigId, DeckConfig>,\n    root_deck: Deck,\n    sort_options: QueueSortOptions,\n    seen_note_ids: HashMap<NoteId, BuryMode>,\n    deck_map: HashMap<DeckId, Deck>,\n    fsrs: bool,\n}\n\nimpl QueueBuilder {\n    pub(super) fn new(col: &mut Collection, deck_id: DeckId) -> Result<Self> {\n        let timing = col.timing_for_timestamp(TimestampSecs::now())?;\n        let new_cards_ignore_review_limit = col.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit);\n        let apply_all_parent_limits = col.get_config_bool(BoolKey::ApplyAllParentLimits);\n        let config_map = col.storage.get_deck_config_map()?;\n        let root_deck = col.storage.get_deck(deck_id)?.or_not_found(deck_id)?;\n        let mut decks = col.storage.child_decks(&root_deck)?;\n        decks.insert(0, root_deck.clone());\n        if apply_all_parent_limits {\n            for parent in col.storage.parent_decks(&root_deck)? {\n                decks.insert(0, parent);\n            }\n        }\n        let limits = LimitTreeMap::build(\n            &decks,\n            &config_map,\n            timing.days_elapsed,\n            new_cards_ignore_review_limit,\n        );\n        let sort_options = sort_options(&root_deck, &config_map);\n        let deck_map = col.storage.get_decks_map()?;\n\n        let load_balancer = col\n            .get_config_bool(BoolKey::LoadBalancerEnabled)\n            .then(|| {\n                let did_to_dcid = deck_map\n                    .values()\n                    .filter_map(|deck| Some((deck.id, deck.config_id()?)))\n                    .collect::<HashMap<_, _>>();\n                LoadBalancer::new(\n                    timing.days_elapsed,\n                    did_to_dcid,\n                    col.timing_today()?.next_day_at,\n                    &col.storage,\n                )\n            })\n            .transpose()?;\n\n        Ok(QueueBuilder {\n            new: Vec::new(),\n            review: Vec::new(),\n            learning: Vec::new(),\n            day_learning: Vec::new(),\n            limits,\n            load_balancer,\n            context: Context {\n                timing,\n                config_map,\n                root_deck,\n                sort_options,\n                seen_note_ids: HashMap::new(),\n                deck_map,\n                fsrs: col.get_config_bool(BoolKey::Fsrs),\n            },\n        })\n    }\n\n    pub(super) fn build(mut self, learn_ahead_secs: i64) -> CardQueues {\n        self.sort_new();\n\n        // intraday learning and total learn count\n        let intraday_learning = sort_learning(self.learning);\n        let now = TimestampSecs::now();\n        let cutoff = now.adding_secs(learn_ahead_secs);\n        let learn_count = intraday_learning\n            .iter()\n            .take_while(|e| e.due <= cutoff)\n            .count()\n            + self.day_learning.len();\n\n        let review_count = self.review.len();\n        let new_count = self.new.len();\n\n        // merge interday and new cards into main\n        let with_interday_learn = merge_day_learning(\n            self.review,\n            self.day_learning,\n            self.context.sort_options.day_learn_mix,\n        );\n        let main_iter = merge_new(\n            with_interday_learn,\n            self.new,\n            self.context.sort_options.new_review_mix,\n        );\n\n        CardQueues {\n            counts: Counts {\n                new: new_count,\n                review: review_count,\n                learning: learn_count,\n            },\n            main: main_iter.collect(),\n            intraday_learning,\n            learn_ahead_secs,\n            current_day: self.context.timing.days_elapsed,\n            build_time: TimestampMillis::now(),\n            load_balancer: self.load_balancer,\n            current_learning_cutoff: now,\n        }\n    }\n}\n\nfn sort_options(deck: &Deck, config_map: &HashMap<DeckConfigId, DeckConfig>) -> QueueSortOptions {\n    deck.config_id()\n        .and_then(|config_id| config_map.get(&config_id))\n        .map(|config| QueueSortOptions {\n            new_order: config.inner.new_card_sort_order(),\n            new_gather_priority: config.inner.new_card_gather_priority(),\n            review_order: config.inner.review_order(),\n            day_learn_mix: config.inner.interday_learning_mix(),\n            new_review_mix: config.inner.new_mix(),\n        })\n        .unwrap_or_else(|| {\n            // filtered decks do not space siblings\n            QueueSortOptions {\n                new_order: NewCardSortOrder::NoSort,\n                ..Default::default()\n            }\n        })\n}\n\nfn merge_day_learning(\n    reviews: Vec<DueCard>,\n    day_learning: Vec<DueCard>,\n    mode: ReviewMix,\n) -> Box<dyn ExactSizeIterator<Item = MainQueueEntry>> {\n    let day_learning_iter = day_learning.into_iter().map(Into::into);\n    let reviews_iter = reviews.into_iter().map(Into::into);\n\n    match mode {\n        ReviewMix::AfterReviews => Box::new(SizedChain::new(reviews_iter, day_learning_iter)),\n        ReviewMix::BeforeReviews => Box::new(SizedChain::new(day_learning_iter, reviews_iter)),\n        ReviewMix::MixWithReviews => Box::new(Intersperser::new(reviews_iter, day_learning_iter)),\n    }\n}\n\nfn merge_new(\n    review_iter: impl ExactSizeIterator<Item = MainQueueEntry> + 'static,\n    new: Vec<NewCard>,\n    mode: ReviewMix,\n) -> Box<dyn ExactSizeIterator<Item = MainQueueEntry>> {\n    let new_iter = new.into_iter().map(Into::into);\n\n    match mode {\n        ReviewMix::BeforeReviews => Box::new(SizedChain::new(new_iter, review_iter)),\n        ReviewMix::AfterReviews => Box::new(SizedChain::new(review_iter, new_iter)),\n        ReviewMix::MixWithReviews => Box::new(Intersperser::new(review_iter, new_iter)),\n    }\n}\n\nfn sort_learning(mut learning: Vec<DueCard>) -> VecDeque<LearningQueueEntry> {\n    learning.sort_unstable_by(|a, b| a.due.cmp(&b.due));\n    learning.into_iter().map(LearningQueueEntry::from).collect()\n}\n\nimpl Collection {\n    pub(crate) fn build_queues(&mut self, deck_id: DeckId) -> Result<CardQueues> {\n        let mut queues = QueueBuilder::new(self, deck_id)?;\n        self.storage\n            .update_active_decks(&queues.context.root_deck)?;\n\n        queues.gather_cards(self)?;\n\n        let queues = queues.build(self.learn_ahead_secs() as i64);\n\n        Ok(queues)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_proto::deck_config::deck_config::config::NewCardGatherPriority;\n    use anki_proto::deck_config::deck_config::config::NewCardSortOrder;\n\n    use super::*;\n    use crate::card::CardQueue;\n    use crate::card::CardType;\n\n    impl Collection {\n        fn set_deck_gather_order(&mut self, deck: &mut Deck, order: NewCardGatherPriority) {\n            let mut conf = DeckConfig::default();\n            conf.inner.new_card_gather_priority = order as i32;\n            conf.inner.new_card_sort_order = NewCardSortOrder::NoSort as i32;\n            self.add_or_update_deck_config(&mut conf).unwrap();\n            deck.normal_mut().unwrap().config_id = conf.id.0;\n            self.add_or_update_deck(deck).unwrap();\n        }\n\n        fn set_deck_new_limit(&mut self, deck: &mut Deck, new_limit: u32) {\n            let mut conf = DeckConfig::default();\n            conf.inner.new_per_day = new_limit;\n            self.add_or_update_deck_config(&mut conf).unwrap();\n            deck.normal_mut().unwrap().config_id = conf.id.0;\n            self.add_or_update_deck(deck).unwrap();\n        }\n\n        fn set_deck_review_limit(&mut self, deck: DeckId, limit: u32) {\n            let dcid = self.get_deck(deck).unwrap().unwrap().config_id().unwrap();\n            let mut conf = self.get_deck_config(dcid, false).unwrap().unwrap();\n            conf.inner.reviews_per_day = limit;\n            self.add_or_update_deck_config(&mut conf).unwrap();\n        }\n\n        fn queue_as_deck_and_template(&mut self, deck_id: DeckId) -> Vec<(DeckId, u16)> {\n            self.build_queues(deck_id)\n                .unwrap()\n                .iter()\n                .map(|entry| {\n                    let card = self.storage.get_card(entry.card_id()).unwrap().unwrap();\n                    (card.deck_id, card.template_idx)\n                })\n                .collect()\n        }\n\n        fn set_deck_review_order(&mut self, deck: &mut Deck, order: ReviewCardOrder) {\n            let mut conf = DeckConfig::default();\n            conf.inner.review_order = order as i32;\n            self.add_or_update_deck_config(&mut conf).unwrap();\n            deck.normal_mut().unwrap().config_id = conf.id.0;\n            self.add_or_update_deck(deck).unwrap();\n        }\n\n        fn queue_as_due_and_ivl(&mut self, deck_id: DeckId) -> Vec<(i32, u32)> {\n            self.build_queues(deck_id)\n                .unwrap()\n                .iter()\n                .map(|entry| {\n                    let card = self.storage.get_card(entry.card_id()).unwrap().unwrap();\n                    (card.due, card.interval)\n                })\n                .collect()\n        }\n    }\n\n    #[test]\n    fn should_build_empty_queue_if_limit_is_reached() {\n        let mut col = Collection::new();\n        CardAdder::new().due_dates([\"0\"]).add(&mut col);\n        col.set_deck_review_limit(DeckId(1), 0);\n        assert_eq!(col.queue_as_deck_and_template(DeckId(1)), vec![]);\n    }\n\n    #[test]\n    fn new_queue_building() -> Result<()> {\n        let mut col = Collection::new();\n\n        // parent\n        // ┣━━child━━grandchild\n        // ┗━━child_2\n        let mut parent = DeckAdder::new(\"parent\").add(&mut col);\n        let mut child = DeckAdder::new(\"parent::child\").add(&mut col);\n        let child_2 = DeckAdder::new(\"parent::child_2\").add(&mut col);\n        let grandchild = DeckAdder::new(\"parent::child::grandchild\").add(&mut col);\n\n        // add 2 new cards to each deck\n        for deck in [&parent, &child, &child_2, &grandchild] {\n            CardAdder::new().siblings(2).deck(deck.id).add(&mut col);\n        }\n\n        // set child's new limit to 3, which should affect grandchild\n        col.set_deck_new_limit(&mut child, 3);\n\n        // depth-first tree order\n        col.set_deck_gather_order(&mut parent, NewCardGatherPriority::Deck);\n        let cards = vec![\n            (parent.id, 0),\n            (parent.id, 1),\n            (child.id, 0),\n            (child.id, 1),\n            (grandchild.id, 0),\n            (child_2.id, 0),\n            (child_2.id, 1),\n        ];\n        assert_eq!(col.queue_as_deck_and_template(parent.id), cards);\n\n        // insertion order\n        col.set_deck_gather_order(&mut parent, NewCardGatherPriority::LowestPosition);\n        let cards = vec![\n            (parent.id, 0),\n            (parent.id, 1),\n            (child.id, 0),\n            (child.id, 1),\n            (child_2.id, 0),\n            (child_2.id, 1),\n            (grandchild.id, 0),\n        ];\n        assert_eq!(col.queue_as_deck_and_template(parent.id), cards);\n\n        // inverted insertion order, but sibling order is preserved\n        col.set_deck_gather_order(&mut parent, NewCardGatherPriority::HighestPosition);\n        let cards = vec![\n            (grandchild.id, 0),\n            (grandchild.id, 1),\n            (child_2.id, 0),\n            (child_2.id, 1),\n            (child.id, 0),\n            (parent.id, 0),\n            (parent.id, 1),\n        ];\n        assert_eq!(col.queue_as_deck_and_template(parent.id), cards);\n\n        Ok(())\n    }\n\n    #[test]\n    fn review_queue_building() -> Result<()> {\n        let mut col = Collection::new();\n\n        let mut deck = col.get_or_create_normal_deck(\"Default\").unwrap();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut cards = vec![];\n\n        // relative overdueness\n        let expected_queue = vec![\n            (-150, 1),\n            (-100, 1),\n            (-50, 1),\n            (-150, 5),\n            (-100, 5),\n            (-50, 5),\n            (-150, 20),\n            (-150, 20),\n            (-100, 20),\n            (-50, 20),\n            (-150, 100),\n            (-100, 100),\n            (-50, 100),\n            (0, 1),\n            (0, 5),\n            (0, 20),\n            (0, 100),\n        ];\n        for t in expected_queue.iter() {\n            let mut note = nt.new_note();\n            note.set_field(0, \"foo\")?;\n            note.id.0 = 0;\n            col.add_note(&mut note, deck.id)?;\n            let mut card = col.storage.get_card_by_ordinal(note.id, 0)?.unwrap();\n            card.interval = t.1;\n            card.due = t.0;\n            card.ctype = CardType::Review;\n            card.queue = CardQueue::Review;\n            cards.push(card);\n        }\n        col.update_cards_maybe_undoable(cards, false)?;\n        col.set_deck_review_order(&mut deck, ReviewCardOrder::RelativeOverdueness);\n        assert_eq!(col.queue_as_due_and_ivl(deck.id), expected_queue);\n\n        Ok(())\n    }\n\n    impl Collection {\n        fn card_queue_len(&mut self) -> usize {\n            self.get_queued_cards(5, false).unwrap().cards.len()\n        }\n    }\n\n    #[test]\n    fn new_card_potentially_burying_review_card() {\n        let mut col = Collection::new();\n        // add one new and one review card\n        CardAdder::new().siblings(2).due_dates([\"0\"]).add(&mut col);\n        // Potentially problematic config: New cards are shown first and would bury\n        // review siblings. This poses a problem because we gather review cards first.\n        col.update_default_deck_config(|config| {\n            config.new_mix = ReviewMix::BeforeReviews as i32;\n            config.bury_new = false;\n            config.bury_reviews = true;\n        });\n\n        let old_queue_len = col.card_queue_len();\n        col.answer_easy();\n        col.clear_study_queues();\n\n        // The number of cards in the queue must decrease by exactly 1, either because\n        // no burying was performed, or the first built queue anticipated it and didn't\n        // include the buried card.\n        assert_eq!(col.card_queue_len(), old_queue_len - 1);\n    }\n\n    #[test]\n    fn new_cards_may_ignore_review_limit() {\n        let mut col = Collection::new();\n        col.set_config_bool(BoolKey::NewCardsIgnoreReviewLimit, true, false)\n            .unwrap();\n        col.update_default_deck_config(|config| {\n            config.reviews_per_day = 0;\n        });\n        CardAdder::new().add(&mut col);\n\n        // review limit doesn't apply to new card\n        assert_eq!(col.card_queue_len(), 1);\n    }\n\n    #[test]\n    fn reviews_dont_affect_new_limit_before_review_limit_is_reached() {\n        let mut col = Collection::new();\n        col.update_default_deck_config(|config| {\n            config.new_per_day = 1;\n        });\n        CardAdder::new().siblings(2).due_dates([\"0\"]).add(&mut col);\n        assert_eq!(col.card_queue_len(), 2);\n    }\n\n    #[test]\n    fn may_apply_parent_limits() {\n        let mut col = Collection::new();\n        col.set_config_bool(BoolKey::ApplyAllParentLimits, true, false)\n            .unwrap();\n        col.update_default_deck_config(|config| {\n            config.new_per_day = 0;\n        });\n        let child = DeckAdder::new(\"Default::child\")\n            .with_config(|_| ())\n            .add(&mut col);\n        CardAdder::new().deck(child.id).add(&mut col);\n        col.set_current_deck(child.id).unwrap();\n        assert_eq!(col.card_queue_len(), 0);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/sized_chain.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/// The standard Rust chain does not implement ExactSizeIterator, and we need\n/// to keep track of size so we can intersperse.\npub(crate) struct SizedChain<I, I2> {\n    one: I,\n    two: I2,\n    one_idx: usize,\n    two_idx: usize,\n    one_len: usize,\n    two_len: usize,\n}\n\nimpl<I, I2> SizedChain<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n    pub fn new(one: I, two: I2) -> Self {\n        let one_len = one.len();\n        let two_len = two.len();\n        SizedChain {\n            one,\n            two,\n            one_idx: 0,\n            two_idx: 0,\n            one_len,\n            two_len,\n        }\n    }\n}\n\nimpl<I, I2> Iterator for SizedChain<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n    type Item = I::Item;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        if self.one_idx < self.one_len {\n            self.one_idx += 1;\n            self.one.next()\n        } else if self.two_idx < self.two_len {\n            self.two_idx += 1;\n            self.two.next()\n        } else {\n            None\n        }\n    }\n\n    fn size_hint(&self) -> (usize, Option<usize>) {\n        let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx);\n        (remaining, Some(remaining))\n    }\n}\n\nimpl<I, I2> ExactSizeIterator for SizedChain<I, I2>\nwhere\n    I: ExactSizeIterator,\n    I2: ExactSizeIterator<Item = I::Item>,\n{\n}\n\n#[cfg(test)]\nmod test {\n    use super::SizedChain;\n\n    fn chain(a: &[u32], b: &[u32]) -> Vec<u32> {\n        SizedChain::new(a.iter().cloned(), b.iter().cloned()).collect()\n    }\n\n    #[test]\n    fn sized_chain() {\n        let a = &[1, 2, 3];\n        let b = &[11, 22, 33];\n        assert_eq!(&chain(a, b), &[1, 2, 3, 11, 22, 33]);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/builder/sorting.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::cmp::Ordering;\nuse std::hash::Hasher;\n\nuse fnv::FnvHasher;\n\nuse super::NewCard;\nuse super::NewCardSortOrder;\nuse super::QueueBuilder;\n\nimpl QueueBuilder {\n    pub(super) fn sort_new(&mut self) {\n        match self.context.sort_options.new_order {\n            // preserve gather order\n            NewCardSortOrder::NoSort => (),\n            NewCardSortOrder::Template => {\n                // stable sort to preserve gather order\n                self.new\n                    .sort_by(|a, b| a.template_index.cmp(&b.template_index))\n            }\n            NewCardSortOrder::TemplateThenRandom => {\n                self.hash_new_cards_by_id();\n                self.new.sort_unstable_by(cmp_template_then_hash);\n            }\n            NewCardSortOrder::RandomNoteThenTemplate => {\n                self.hash_new_cards_by_note_id();\n                self.new.sort_unstable_by(cmp_hash_then_template);\n            }\n            NewCardSortOrder::RandomCard => {\n                self.hash_new_cards_by_id();\n                self.new.sort_unstable_by(cmp_hash)\n            }\n        }\n    }\n\n    fn hash_new_cards_by_id(&mut self) {\n        self.new\n            .iter_mut()\n            .for_each(|card| card.hash_id_with_salt(self.context.timing.days_elapsed as i64));\n    }\n\n    fn hash_new_cards_by_note_id(&mut self) {\n        self.new\n            .iter_mut()\n            .for_each(|card| card.hash_note_id_with_salt(self.context.timing.days_elapsed as i64));\n    }\n}\n\nfn cmp_hash(a: &NewCard, b: &NewCard) -> Ordering {\n    a.hash.cmp(&b.hash)\n}\n\nfn cmp_template_then_hash(a: &NewCard, b: &NewCard) -> Ordering {\n    (a.template_index, a.hash).cmp(&(b.template_index, b.hash))\n}\n\nfn cmp_hash_then_template(a: &NewCard, b: &NewCard) -> Ordering {\n    (a.hash, a.template_index).cmp(&(b.hash, b.template_index))\n}\n\n// We sort based on a hash so that if the queue is rebuilt, remaining\n// cards come back in the same approximate order (mixing + due learning cards\n// may still result in a different card)\n\nimpl NewCard {\n    fn hash_id_with_salt(&mut self, salt: i64) {\n        let mut hasher = FnvHasher::default();\n        hasher.write_i64(self.id.0);\n        hasher.write_i64(salt);\n        self.hash = hasher.finish();\n    }\n\n    fn hash_note_id_with_salt(&mut self, salt: i64) {\n        let mut hasher = FnvHasher::default();\n        hasher.write_i64(self.note_id.0);\n        hasher.write_i64(salt);\n        self.hash = hasher.finish();\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/entry.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::LearningQueueEntry;\nuse super::MainQueueEntry;\nuse super::MainQueueEntryKind;\nuse crate::card::CardQueue;\nuse crate::prelude::*;\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub(crate) enum QueueEntry {\n    IntradayLearning(LearningQueueEntry),\n    Main(MainQueueEntry),\n}\n\nimpl QueueEntry {\n    pub fn card_id(&self) -> CardId {\n        match self {\n            QueueEntry::IntradayLearning(e) => e.id,\n            QueueEntry::Main(e) => e.id,\n        }\n    }\n\n    pub fn mtime(&self) -> TimestampSecs {\n        match self {\n            QueueEntry::IntradayLearning(e) => e.mtime,\n            QueueEntry::Main(e) => e.mtime,\n        }\n    }\n\n    pub fn kind(&self) -> QueueEntryKind {\n        match self {\n            QueueEntry::IntradayLearning(_e) => QueueEntryKind::Learning,\n            QueueEntry::Main(e) => match e.kind {\n                MainQueueEntryKind::New => QueueEntryKind::New,\n                MainQueueEntryKind::Review => QueueEntryKind::Review,\n                MainQueueEntryKind::InterdayLearning => QueueEntryKind::Learning,\n            },\n        }\n    }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum QueueEntryKind {\n    New,\n    Learning,\n    Review,\n}\n\nimpl From<&Card> for QueueEntry {\n    fn from(card: &Card) -> Self {\n        let kind = match card.queue {\n            CardQueue::Learn | CardQueue::PreviewRepeat => {\n                return QueueEntry::IntradayLearning(LearningQueueEntry {\n                    due: TimestampSecs(card.due as i64),\n                    id: card.id,\n                    mtime: card.mtime,\n                });\n            }\n            CardQueue::New => MainQueueEntryKind::New,\n            CardQueue::Review | CardQueue::DayLearn => MainQueueEntryKind::Review,\n            CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => {\n                unreachable!()\n            }\n        };\n        QueueEntry::Main(MainQueueEntry {\n            id: card.id,\n            mtime: card.mtime,\n            kind,\n        })\n    }\n}\n\nimpl From<LearningQueueEntry> for QueueEntry {\n    fn from(e: LearningQueueEntry) -> Self {\n        Self::IntradayLearning(e)\n    }\n}\n\nimpl From<MainQueueEntry> for QueueEntry {\n    fn from(e: MainQueueEntry) -> Self {\n        Self::Main(e)\n    }\n}\n\nimpl From<&LearningQueueEntry> for QueueEntry {\n    fn from(e: &LearningQueueEntry) -> Self {\n        Self::IntradayLearning(*e)\n    }\n}\n\nimpl From<&MainQueueEntry> for QueueEntry {\n    fn from(e: &MainQueueEntry) -> Self {\n        Self::Main(*e)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/learning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::undo::CutoffSnapshot;\nuse super::CardQueues;\nuse crate::prelude::*;\nuse crate::scheduler::timing::SchedTimingToday;\n\n#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)]\npub(crate) struct LearningQueueEntry {\n    // due comes first, so the derived ordering sorts by due\n    pub due: TimestampSecs,\n    pub id: CardId,\n    pub mtime: TimestampSecs,\n}\n\nimpl CardQueues {\n    /// Intraday learning cards that can be shown immediately.\n    pub(super) fn intraday_now_iter(&self) -> impl Iterator<Item = &LearningQueueEntry> {\n        let cutoff = self.current_learning_cutoff;\n        self.intraday_learning\n            .iter()\n            .take_while(move |e| e.due <= cutoff)\n    }\n\n    /// Intraday learning cards that can be shown after the main queue is empty.\n    pub(super) fn intraday_ahead_iter(&self) -> impl Iterator<Item = &LearningQueueEntry> {\n        let cutoff = self.current_learning_cutoff;\n        let ahead_cutoff = self.current_learn_ahead_cutoff();\n        self.intraday_learning\n            .iter()\n            .skip_while(move |e| e.due <= cutoff)\n            .take_while(move |e| e.due <= ahead_cutoff)\n    }\n\n    /// Increase the cutoff to the current time, and increase the learning count\n    /// for any new cards that now fall within the cutoff.\n    pub(super) fn update_learning_cutoff_and_count(&mut self) -> CutoffSnapshot {\n        let change = CutoffSnapshot {\n            learning_count: self.counts.learning,\n            learning_cutoff: self.current_learning_cutoff,\n        };\n        let last_ahead_cutoff = self.current_learn_ahead_cutoff();\n        self.current_learning_cutoff = TimestampSecs::now();\n        let new_ahead_cutoff = self.current_learn_ahead_cutoff();\n        let new_learning_cards = self\n            .intraday_learning\n            .iter()\n            .skip_while(|e| e.due <= last_ahead_cutoff)\n            .take_while(|e| e.due <= new_ahead_cutoff)\n            .count();\n        self.counts.learning += new_learning_cards;\n\n        change\n    }\n\n    /// Given the just-answered `card`, place it back in the learning queues if\n    /// it's still due today. Avoid placing it in a position where it would\n    /// be shown again immediately.\n    pub(super) fn maybe_requeue_learning_card(\n        &mut self,\n        card: &Card,\n        timing: SchedTimingToday,\n    ) -> Option<LearningQueueEntry> {\n        // not due today?\n        if !card.is_intraday_learning() || card.due >= timing.next_day_at.0 as i32 {\n            return None;\n        }\n\n        let entry = LearningQueueEntry {\n            due: TimestampSecs(card.due as i64),\n            id: card.id,\n            mtime: card.mtime,\n        };\n\n        Some(self.requeue_learning_entry(entry))\n    }\n\n    pub(super) fn cutoff_snapshot(&self) -> Box<CutoffSnapshot> {\n        Box::new(CutoffSnapshot {\n            learning_count: self.counts.learning,\n            learning_cutoff: self.current_learning_cutoff,\n        })\n    }\n\n    pub(super) fn restore_cutoff(&mut self, change: &CutoffSnapshot) -> Box<CutoffSnapshot> {\n        let current = self.cutoff_snapshot();\n        self.counts.learning = change.learning_count;\n        self.current_learning_cutoff = change.learning_cutoff;\n        current\n    }\n\n    /// Caller must have validated learning entry is due today.\n    pub(super) fn requeue_learning_entry(\n        &mut self,\n        mut entry: LearningQueueEntry,\n    ) -> LearningQueueEntry {\n        let cutoff = self.current_learn_ahead_cutoff();\n\n        // if the provided entry would be shown again immediately, see if we\n        // can place it after the next card instead\n        if entry.due <= cutoff && self.learning_collapsed() {\n            if let Some(next) = self.intraday_learning.front() {\n                if next.due >= entry.due && next.due.adding_secs(1) < cutoff {\n                    entry.due = next.due.adding_secs(1);\n                }\n            }\n        }\n\n        self.insert_intraday_learning_card(entry);\n\n        entry\n    }\n\n    fn learning_collapsed(&self) -> bool {\n        self.main.is_empty()\n    }\n\n    /// Remove the head of the intraday learning queue, and update counts.\n    pub(super) fn pop_intraday_learning(&mut self) -> Option<LearningQueueEntry> {\n        self.intraday_learning.pop_front().inspect(|_head| {\n            // FIXME:\n            // under normal circumstances this should not go below 0, but currently\n            // the Python unit tests answer learning cards before they're due\n            self.counts.learning = self.counts.learning.saturating_sub(1);\n        })\n    }\n\n    /// Add an undone entry to the top of the intraday learning queue.\n    pub(super) fn push_intraday_learning(&mut self, entry: LearningQueueEntry) {\n        self.intraday_learning.push_front(entry);\n        self.counts.learning += 1;\n    }\n\n    /// Adds an intraday learning card to the correct position of the queue, and\n    /// increments learning count if card is due.\n    pub(super) fn insert_intraday_learning_card(&mut self, entry: LearningQueueEntry) {\n        if entry.due <= self.current_learn_ahead_cutoff() {\n            self.counts.learning += 1;\n        }\n\n        let target_idx = self\n            .intraday_learning\n            .binary_search_by(|e| e.due.cmp(&entry.due))\n            .unwrap_or_else(|e| e);\n        self.intraday_learning.insert(target_idx, entry);\n    }\n\n    /// Remove an inserted intraday learning card after a lapse is undone,\n    /// adjusting counts.\n    pub(super) fn remove_intraday_learning_card(\n        &mut self,\n        card_id: CardId,\n    ) -> Option<LearningQueueEntry> {\n        if let Some(position) = self.intraday_learning.iter().position(|e| e.id == card_id) {\n            let entry = self.intraday_learning.remove(position).unwrap();\n            if entry.due\n                <= self\n                    .current_learning_cutoff\n                    .adding_secs(self.learn_ahead_secs)\n            {\n                // Theoretically this should never go below zero.\n                self.counts.learning = self.counts.learning.saturating_sub(1);\n            }\n            Some(entry)\n        } else {\n            None\n        }\n    }\n\n    fn current_learn_ahead_cutoff(&self) -> TimestampSecs {\n        self.current_learning_cutoff\n            .adding_secs(self.learn_ahead_secs)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardQueues;\nuse crate::prelude::*;\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub(crate) struct MainQueueEntry {\n    pub id: CardId,\n    pub mtime: TimestampSecs,\n    pub kind: MainQueueEntryKind,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub(crate) enum MainQueueEntryKind {\n    New,\n    Review,\n    InterdayLearning,\n}\n\nimpl CardQueues {\n    /// Remove the head of the main queue, and update counts.\n    pub(super) fn pop_main(&mut self) -> Option<MainQueueEntry> {\n        self.main.pop_front().inspect(|head| {\n            match head.kind {\n                MainQueueEntryKind::New => self.counts.new -= 1,\n                MainQueueEntryKind::Review => self.counts.review -= 1,\n                MainQueueEntryKind::InterdayLearning => {\n                    // the bug causing learning counts to go below zero should\n                    // hopefully be fixed at this point, but ensure we don't wrap\n                    // if it isn't\n                    self.counts.learning = self.counts.learning.saturating_sub(1)\n                }\n            };\n        })\n    }\n\n    /// Add an undone entry to the top of the main queue.\n    pub(super) fn push_main(&mut self, entry: MainQueueEntry) {\n        match entry.kind {\n            MainQueueEntryKind::New => self.counts.new += 1,\n            MainQueueEntryKind::Review => self.counts.review += 1,\n            MainQueueEntryKind::InterdayLearning => self.counts.learning += 1,\n        };\n        self.main.push_front(entry);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod builder;\nmod entry;\nmod learning;\nmod main;\npub(crate) mod undo;\n\nuse std::collections::VecDeque;\n\nuse anki_proto::scheduler::SchedulingContext;\npub(crate) use builder::DueCard;\npub(crate) use builder::DueCardKind;\npub(crate) use builder::NewCard;\npub(crate) use entry::QueueEntry;\npub(crate) use entry::QueueEntryKind;\npub(crate) use learning::LearningQueueEntry;\npub(crate) use main::MainQueueEntry;\npub(crate) use main::MainQueueEntryKind;\n\nuse self::undo::QueueUpdate;\nuse super::states::SchedulingStates;\nuse super::timing::SchedTimingToday;\nuse crate::prelude::*;\nuse crate::scheduler::states::load_balancer::LoadBalancer;\nuse crate::timestamp::TimestampSecs;\n\n#[derive(Debug)]\npub(crate) struct CardQueues {\n    counts: Counts,\n    main: VecDeque<MainQueueEntry>,\n    intraday_learning: VecDeque<LearningQueueEntry>,\n    current_day: u32,\n    learn_ahead_secs: i64,\n    build_time: TimestampMillis,\n    /// Updated each time a card is answered, and by get_queued_cards() when the\n    /// counts are zero. Ensures we don't show a newly-due learning card after a\n    /// user returns from editing a review card.\n    current_learning_cutoff: TimestampSecs,\n    pub(crate) load_balancer: Option<LoadBalancer>,\n}\n\n#[derive(Debug, Copy, Clone)]\npub struct Counts {\n    pub new: usize,\n    pub learning: usize,\n    pub review: usize,\n}\n\nimpl Counts {\n    fn all_zero(self) -> bool {\n        self.new == 0 && self.learning == 0 && self.review == 0\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct QueuedCard {\n    pub card: Card,\n    pub kind: QueueEntryKind,\n    pub states: SchedulingStates,\n    pub context: SchedulingContext,\n}\n\n#[derive(Debug)]\npub struct QueuedCards {\n    pub cards: Vec<QueuedCard>,\n    pub new_count: usize,\n    pub learning_count: usize,\n    pub review_count: usize,\n}\n\n/// When we encounter a card with new or review burying enabled, all future\n/// siblings need to be buried, regardless of their own settings.\n#[derive(Default, Debug, Clone, Copy)]\npub(crate) struct BuryMode {\n    pub(crate) bury_new: bool,\n    pub(crate) bury_reviews: bool,\n    pub(crate) bury_interday_learning: bool,\n}\n\nimpl Collection {\n    pub fn get_next_card(&mut self) -> Result<Option<QueuedCard>> {\n        self.get_queued_cards(1, false)\n            .map(|queued| queued.cards.first().cloned())\n    }\n\n    pub fn get_queued_cards(\n        &mut self,\n        fetch_limit: usize,\n        intraday_learning_only: bool,\n    ) -> Result<QueuedCards> {\n        let queues = self.get_queues()?;\n        let counts = queues.counts();\n        let entries: Vec<_> = if intraday_learning_only {\n            queues\n                .intraday_now_iter()\n                .chain(queues.intraday_ahead_iter())\n                .map(Into::into)\n                .collect()\n        } else {\n            queues.iter().take(fetch_limit).collect()\n        };\n        let cards: Vec<_> = entries\n            .into_iter()\n            .map(|entry| {\n                let card = self\n                    .storage\n                    .get_card(entry.card_id())?\n                    .or_not_found(entry.card_id())?;\n                require!(\n                    card.mtime == entry.mtime(),\n                    \"bug: card modified without updating queue: id:{} card:{} entry:{}\",\n                    card.id,\n                    card.mtime,\n                    entry.mtime()\n                );\n\n                // fixme: pass in card instead of id\n                let next_states = self.get_scheduling_states(card.id)?;\n\n                Ok(QueuedCard {\n                    context: new_scheduling_context(self, &card)?,\n                    card,\n                    states: next_states,\n                    kind: entry.kind(),\n                })\n            })\n            .collect::<Result<_>>()?;\n        Ok(QueuedCards {\n            cards,\n            new_count: counts.new,\n            learning_count: counts.learning,\n            review_count: counts.review,\n        })\n    }\n}\n\nfn new_scheduling_context(col: &mut Collection, card: &Card) -> Result<SchedulingContext> {\n    Ok(SchedulingContext {\n        deck_name: col\n            .get_deck(card.original_or_current_deck_id())?\n            .or_not_found(card.deck_id)?\n            .human_name(),\n        seed: card.review_seed(),\n    })\n}\n\nimpl CardQueues {\n    /// An iterator over the card queues, in the order the cards will\n    /// be presented.\n    fn iter(&self) -> impl Iterator<Item = QueueEntry> + '_ {\n        self.intraday_now_iter()\n            .map(Into::into)\n            .chain(self.main.iter().map(Into::into))\n            .chain(self.intraday_ahead_iter().map(Into::into))\n    }\n\n    /// Remove the provided card from the top of the queues and\n    /// adjust the counts. If it was not at the top, return an error.\n    fn pop_entry(&mut self, id: CardId) -> Result<QueueEntry> {\n        // This ignores the current cutoff, so may match if the provided\n        // learning card is not yet due. It should not happen in normal\n        // practice, but does happen in the Python unit tests, as they answer\n        // learning cards early.\n        if self\n            .intraday_learning\n            .front()\n            .filter(|e| e.id == id)\n            .is_some()\n        {\n            Ok(self.pop_intraday_learning().unwrap().into())\n        } else if self.main.front().filter(|e| e.id == id).is_some() {\n            Ok(self.pop_main().unwrap().into())\n        } else {\n            invalid_input!(\"not at top of queue\")\n        }\n    }\n\n    fn push_undo_entry(&mut self, entry: QueueEntry) {\n        match entry {\n            QueueEntry::IntradayLearning(entry) => self.push_intraday_learning(entry),\n            QueueEntry::Main(entry) => self.push_main(entry),\n        }\n    }\n\n    /// Return the current due counts. If there are no due cards, the learning\n    /// cutoff is updated to the current time first, and any newly-due learning\n    /// cards are added to the counts.\n    pub(crate) fn counts(&mut self) -> Counts {\n        if self.counts.all_zero() {\n            // we discard the returned undo information in this case\n            self.update_learning_cutoff_and_count();\n        }\n        self.counts\n    }\n\n    fn is_stale(&self, current_day: u32) -> bool {\n        self.current_day != current_day\n    }\n}\n\nimpl Collection {\n    /// This is automatically done when transact() is called for everything\n    /// except card answers, so unless you are modifying state outside of a\n    /// transaction, you probably don't need this.\n    pub(crate) fn clear_study_queues(&mut self) {\n        self.state.card_queues = None;\n    }\n\n    pub(crate) fn maybe_clear_study_queues_after_op(&mut self, op: &OpChanges) {\n        if op.op != Op::AnswerCard && op.requires_study_queue_rebuild() {\n            self.state.card_queues = None;\n        }\n    }\n\n    pub(crate) fn update_queues_after_answering_card(\n        &mut self,\n        card: &Card,\n        timing: SchedTimingToday,\n        is_finished_preview: bool,\n    ) -> Result<()> {\n        if let Some(queues) = &mut self.state.card_queues {\n            let entry = queues.pop_entry(card.id)?;\n            let requeued_learning = if is_finished_preview {\n                None\n            } else {\n                queues.maybe_requeue_learning_card(card, timing)\n            };\n            let cutoff_snapshot = queues.update_learning_cutoff_and_count();\n            let queue_build_time = queues.build_time;\n            self.save_queue_update_undo(Box::new(QueueUpdate {\n                entry,\n                learning_requeue: requeued_learning,\n                queue_build_time,\n                cutoff_snapshot,\n            }));\n        } else {\n            // we currently allow the queues to be empty for unit tests\n        }\n\n        Ok(())\n    }\n\n    /// Get the card queues, building if necessary.\n    pub(crate) fn get_queues(&mut self) -> Result<&mut CardQueues> {\n        let deck = self.get_current_deck()?;\n        self.clear_queues_if_day_changed()?;\n        if self.state.card_queues.is_none() {\n            self.state.card_queues = Some(self.build_queues(deck.id)?);\n        }\n\n        Ok(self.state.card_queues.as_mut().unwrap())\n    }\n\n    // Returns queues if they are valid and have not been rebuilt. If build time has\n    // changed, they are cleared.\n    pub(crate) fn get_or_invalidate_queues(\n        &mut self,\n        build_time: TimestampMillis,\n    ) -> Result<Option<&mut CardQueues>> {\n        self.clear_queues_if_day_changed()?;\n        let same_build = self\n            .state\n            .card_queues\n            .as_ref()\n            .map(|q| q.build_time == build_time)\n            .unwrap_or_default();\n        if same_build {\n            Ok(self.state.card_queues.as_mut())\n        } else {\n            self.clear_study_queues();\n            Ok(None)\n        }\n    }\n\n    fn clear_queues_if_day_changed(&mut self) -> Result<()> {\n        let timing = self.timing_today()?;\n        let day_rolled_over = self\n            .state\n            .card_queues\n            .as_ref()\n            .map(|q| q.is_stale(timing.days_elapsed))\n            .unwrap_or(false);\n        if day_rolled_over {\n            self.discard_undo_and_study_queues();\n            self.unbury_on_day_rollover(timing.days_elapsed)?;\n        }\n        Ok(())\n    }\n}\n\n// test helpers\n#[cfg(test)]\nimpl Collection {\n    pub(crate) fn counts(&mut self) -> [usize; 3] {\n        self.get_queued_cards(1, false)\n            .map(|q| [q.new_count, q.learning_count, q.review_count])\n            .unwrap_or([0; 3])\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/queue/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::LearningQueueEntry;\nuse super::QueueEntry;\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableQueueChange {\n    CardAnswered(Box<QueueUpdate>),\n    CardAnswerUndone(Box<QueueUpdate>),\n}\n\n#[derive(Debug)]\npub(crate) struct QueueUpdate {\n    pub entry: QueueEntry,\n    pub learning_requeue: Option<LearningQueueEntry>,\n    pub queue_build_time: TimestampMillis,\n    pub cutoff_snapshot: CutoffSnapshot,\n}\n\n/// Stores the old learning count and cutoff prior to the\n/// cutoff being adjusted after answering a card.\n#[derive(Debug)]\npub(crate) struct CutoffSnapshot {\n    pub learning_count: usize,\n    pub learning_cutoff: TimestampSecs,\n}\n\nimpl Collection {\n    pub(crate) fn undo_queue_change(&mut self, change: UndoableQueueChange) -> Result<()> {\n        match change {\n            UndoableQueueChange::CardAnswered(update) => {\n                if let Some(queues) = self.get_or_invalidate_queues(update.queue_build_time)? {\n                    queues.restore_cutoff(&update.cutoff_snapshot);\n                    if let Some(learning) = &update.learning_requeue {\n                        queues.remove_intraday_learning_card(learning.id);\n                    }\n                    queues.push_undo_entry(update.entry);\n                }\n\n                if let Some(card_queues) = self.state.card_queues.as_mut() {\n                    if let Some(load_balancer) = card_queues.load_balancer.as_mut() {\n                        match &update.entry {\n                            QueueEntry::IntradayLearning(entry) => {\n                                load_balancer.remove_card(entry.id);\n                            }\n                            QueueEntry::Main(entry) => {\n                                load_balancer.remove_card(entry.id);\n                            }\n                        }\n                    }\n                }\n\n                self.save_undo(UndoableQueueChange::CardAnswerUndone(update));\n\n                Ok(())\n            }\n            UndoableQueueChange::CardAnswerUndone(update) => {\n                if let Some(queues) = self.get_or_invalidate_queues(update.queue_build_time)? {\n                    queues.pop_entry(update.entry.card_id())?;\n                    if let Some(learning) = update.learning_requeue {\n                        queues.insert_intraday_learning_card(learning);\n                    }\n                    queues.restore_cutoff(&update.cutoff_snapshot);\n                }\n                self.save_undo(UndoableQueueChange::CardAnswered(update));\n\n                Ok(())\n            }\n        }\n    }\n\n    pub(super) fn save_queue_update_undo(&mut self, change: Box<QueueUpdate>) {\n        self.save_undo(UndoableQueueChange::CardAnswered(change))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::card::CardQueue;\n    use crate::card::CardType;\n    use crate::deckconfig::LeechAction;\n    use crate::prelude::*;\n\n    fn add_note(col: &mut Collection, with_reverse: bool) -> Result<NoteId> {\n        let nt = col\n            .get_notetype_by_name(\"Basic (and reversed card)\")?\n            .unwrap();\n        let mut note = nt.new_note();\n        note.set_field(0, \"one\")?;\n        if with_reverse {\n            note.set_field(1, \"two\")?;\n        }\n        col.add_note(&mut note, DeckId(1))?;\n        Ok(note.id)\n    }\n\n    #[test]\n    fn undo() -> Result<()> {\n        // add a note\n        let mut col = Collection::new();\n        let nid = add_note(&mut col, true)?;\n\n        // turn burying and leech suspension on\n        let mut conf = col.storage.get_deck_config(DeckConfigId(1))?.unwrap();\n        conf.inner.bury_new = true;\n        conf.inner.leech_action = LeechAction::Suspend as i32;\n        col.storage.update_deck_conf(&conf)?;\n\n        // get the first card\n        let queued = col.get_next_card()?.unwrap();\n        let cid = queued.card.id;\n        let sibling_cid = col.storage.all_card_ids_of_note_in_template_order(nid)?[1];\n\n        let assert_initial_state = |col: &mut Collection| -> Result<()> {\n            let first = col.storage.get_card(cid)?.unwrap();\n            assert_eq!(first.queue, CardQueue::New);\n            let sibling = col.storage.get_card(sibling_cid)?.unwrap();\n            assert_eq!(sibling.queue, CardQueue::New);\n            Ok(())\n        };\n\n        assert_initial_state(&mut col)?;\n\n        // immediately graduate the first card\n        col.answer_easy();\n\n        // the sibling will be buried\n        let sibling = col.storage.get_card(sibling_cid)?.unwrap();\n        assert_eq!(sibling.queue, CardQueue::SchedBuried);\n\n        // make it due now, with 7 lapses. we use the storage layer directly,\n        // bypassing undo\n        let mut card = col.storage.get_card(cid)?.unwrap();\n        assert_eq!(card.ctype, CardType::Review);\n        card.lapses = 7;\n        card.due = 0;\n        col.storage.update_card(&card)?;\n\n        // fail it, which should cause it to be marked as a leech\n        col.clear_study_queues();\n        col.answer_again();\n\n        let assert_post_review_state = |col: &mut Collection| -> Result<()> {\n            let card = col.storage.get_card(cid)?.unwrap();\n            assert_eq!(card.interval, 1);\n            assert_eq!(card.lapses, 8);\n\n            assert_eq!(\n                col.storage.get_all_revlog_entries(TimestampSecs(0))?.len(),\n                2\n            );\n\n            let note = col.storage.get_note(nid)?.unwrap();\n            assert_eq!(note.tags, vec![\"leech\".to_string()]);\n            assert!(!col.storage.all_tags()?.is_empty());\n\n            let deck = col.get_deck(DeckId(1))?.unwrap();\n            assert_eq!(deck.common.review_studied, 1);\n\n            assert!(col.get_next_card()?.is_none());\n\n            Ok(())\n        };\n\n        let assert_pre_review_state = |col: &mut Collection| -> Result<()> {\n            // the card should have its old state, but a new mtime (which we can't\n            // easily test without waiting)\n            let card = col.storage.get_card(cid)?.unwrap();\n            assert_eq!(card.interval, 4);\n            assert_eq!(card.lapses, 7);\n\n            // the revlog entry should have been removed\n            assert_eq!(\n                col.storage.get_all_revlog_entries(TimestampSecs(0))?.len(),\n                1\n            );\n\n            // the note should no longer be tagged as a leech\n            let note = col.storage.get_note(nid)?.unwrap();\n            assert!(note.tags.is_empty());\n            assert!(col.storage.all_tags()?.is_empty());\n\n            let deck = col.get_deck(DeckId(1))?.unwrap();\n            assert_eq!(deck.common.review_studied, 0);\n            assert!(col.get_next_card()?.is_some());\n            assert_eq!(col.counts(), [0, 0, 1]);\n\n            Ok(())\n        };\n\n        // ensure everything is restored on undo/redo\n        assert_post_review_state(&mut col)?;\n        col.undo()?;\n        assert_pre_review_state(&mut col)?;\n        col.redo()?;\n        assert_post_review_state(&mut col)?;\n        col.undo()?;\n        assert_pre_review_state(&mut col)?;\n        col.undo()?;\n        assert_initial_state(&mut col)?;\n\n        Ok(())\n    }\n\n    #[test]\n    fn undo_counts() -> Result<()> {\n        let mut col = Collection::new();\n        if col.timing_today()?.near_cutoff() {\n            return Ok(());\n        }\n\n        assert_eq!(col.counts(), [0, 0, 0]);\n        add_note(&mut col, true)?;\n        assert_eq!(col.counts(), [2, 0, 0]);\n        col.answer_again();\n        assert_eq!(col.counts(), [1, 1, 0]);\n        col.answer_good();\n        assert_eq!(col.counts(), [0, 2, 0]);\n        col.answer_again();\n        assert_eq!(col.counts(), [0, 2, 0]);\n        // first card graduates\n        col.answer_good();\n        assert_eq!(col.counts(), [0, 1, 0]);\n        col.answer_easy();\n        assert_eq!(col.counts(), [0, 0, 0]);\n\n        // now work backwards\n        col.undo()?;\n        assert_eq!(col.counts(), [0, 1, 0]);\n        col.undo()?;\n        assert_eq!(col.counts(), [0, 2, 0]);\n        col.undo()?;\n        assert_eq!(col.counts(), [0, 2, 0]);\n        col.undo()?;\n        assert_eq!(col.counts(), [1, 1, 0]);\n        col.undo()?;\n        assert_eq!(col.counts(), [2, 0, 0]);\n        col.undo()?;\n        assert_eq!(col.counts(), [0, 0, 0]);\n\n        // and forwards again\n        col.redo()?;\n        assert_eq!(col.counts(), [2, 0, 0]);\n        col.redo()?;\n        assert_eq!(col.counts(), [1, 1, 0]);\n        col.redo()?;\n        assert_eq!(col.counts(), [0, 2, 0]);\n        col.redo()?;\n        assert_eq!(col.counts(), [0, 2, 0]);\n        col.redo()?;\n        assert_eq!(col.counts(), [0, 1, 0]);\n        col.redo()?;\n        assert_eq!(col.counts(), [0, 0, 0]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn redo_after_queue_invalidation_bug() -> Result<()> {\n        // add a note to the default deck\n        let mut col = Collection::new();\n        let _nid = add_note(&mut col, true)?;\n\n        // add a deck and select it\n        let mut deck = Deck::new_normal();\n        deck.name = NativeDeckName::from_human_name(\"foo\");\n        col.add_deck(&mut deck)?;\n        col.set_current_deck(deck.id)?;\n\n        // select default again, which invalidates current queues\n        col.set_current_deck(DeckId(1))?;\n\n        // get the first card and answer it\n        col.answer_easy();\n\n        // undo answer\n        col.undo()?;\n\n        // undo deck select, which invalidates the queues again\n        col.undo()?;\n\n        // redo deck select (another invalidation)\n        col.redo()?;\n\n        // when the card answer is redone, it shouldn't fail because\n        // the queues are rebuilt after the card state is restored\n        col.redo()?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/reviews.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::sync::LazyLock;\n\nuse rand::distr::Distribution;\nuse rand::distr::Uniform;\nuse regex::Regex;\n\nuse super::answering::CardAnswer;\nuse crate::card::Card;\nuse crate::card::CardId;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::collection::Collection;\nuse crate::config::StringKey;\nuse crate::error::Result;\nuse crate::prelude::*;\nuse crate::scheduler::timing::is_unix_epoch_timestamp;\n\nimpl Card {\n    /// Make card due in `days_from_today`.\n    /// If card is not a review card, convert it into one.\n    /// Review/relearning cards have their interval preserved unless\n    /// `force_reset` is true.\n    /// If the card has no ease factor (it's new), `ease_factor` is used.\n    fn set_due_date(\n        &mut self,\n        today: u32,\n        next_day_start: i64,\n        days_from_today: u32,\n        ease_factor: f32,\n        force_reset: bool,\n    ) {\n        let new_due = (today + days_from_today) as i32;\n        let fsrs_enabled = self.memory_state.is_some();\n        let new_interval = if fsrs_enabled {\n            if let Some(last_review_time) = self.last_review_time {\n                let elapsed_days =\n                    TimestampSecs(next_day_start).elapsed_days_since(last_review_time);\n                elapsed_days as u32 + days_from_today\n            } else {\n                let due = self.original_or_current_due();\n                let due_diff = if is_unix_epoch_timestamp(due) {\n                    let offset = (due as i64 - next_day_start) / 86_400;\n                    let due = (today as i64 + offset) as i32;\n                    new_due - due\n                } else {\n                    new_due - due\n                };\n                self.interval.saturating_add_signed(due_diff)\n            }\n        } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) {\n            days_from_today.max(1)\n        } else {\n            self.interval.max(1)\n        };\n        let ease_factor = (ease_factor * 1000.0).round() as u16;\n\n        self.schedule_as_review(new_interval, new_due, ease_factor);\n    }\n\n    fn schedule_as_review(&mut self, interval: u32, due: i32, ease_factor: u16) {\n        self.original_position = self.last_position();\n        self.remove_from_filtered_deck_before_reschedule();\n        self.interval = interval;\n        self.due = due;\n        self.ctype = CardType::Review;\n        self.queue = CardQueue::Review;\n        if self.ease_factor == 0 {\n            // unlike the old Python code, we leave the ease factor alone\n            // if it's already set\n            self.ease_factor = ease_factor;\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct DueDateSpecifier {\n    min: u32,\n    max: u32,\n    force_reset: bool,\n}\n\npub fn parse_due_date_str(s: &str) -> Result<DueDateSpecifier> {\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r\"(?x)^\n            # a number\n            (?P<min>\\d+)\n            # an optional hyphen and another number\n            (?:\n                -\n                (?P<max>\\d+)\n            )?\n            # optional exclamation mark\n            (?P<bang>!)?\n            $\n        \",\n        )\n        .unwrap()\n    });\n    let caps = RE.captures(s).or_invalid(s)?;\n    let min: u32 = caps.name(\"min\").unwrap().as_str().parse()?;\n    let max = if let Some(max) = caps.name(\"max\") {\n        max.as_str().parse()?\n    } else {\n        min\n    };\n    let force_reset = caps.name(\"bang\").is_some();\n    Ok(DueDateSpecifier {\n        min: min.min(max),\n        max: max.max(min),\n        force_reset,\n    })\n}\n\nimpl Collection {\n    /// `days` should be in a format parseable by `parse_due_date_str`.\n    /// If `context` is provided, provided key will be updated with the new\n    /// value of `days`.\n    pub fn set_due_date(\n        &mut self,\n        cids: &[CardId],\n        days: &str,\n        context: Option<StringKey>,\n    ) -> Result<OpOutput<()>> {\n        let spec = parse_due_date_str(days)?;\n        let usn = self.usn()?;\n        let today = self.timing_today()?.days_elapsed;\n        let next_day_start = self.timing_today()?.next_day_at.0;\n        let mut rng = rand::rng();\n        let distribution = Uniform::new_inclusive(spec.min, spec.max).unwrap();\n        let mut decks_initial_ease: HashMap<DeckId, f32> = HashMap::new();\n        self.transact(Op::SetDueDate, |col| {\n            for mut card in col.all_cards_for_ids(cids, false)? {\n                let deck_id = card.original_deck_id.or(card.deck_id);\n                let ease_factor = match decks_initial_ease.get(&deck_id) {\n                    Some(ease) => *ease,\n                    None => {\n                        let deck = col.get_deck(deck_id)?.or_not_found(deck_id)?;\n                        let config_id = deck.config_id().or_invalid(\"home deck is filtered\")?;\n                        let ease = col\n                            .get_deck_config(config_id, true)?\n                            // just for compiler; get_deck_config() is guaranteed to return a value\n                            .unwrap_or_default()\n                            .inner\n                            .initial_ease;\n                        decks_initial_ease.insert(deck_id, ease);\n                        ease\n                    }\n                };\n                let original = card.clone();\n                let days_from_today = distribution.sample(&mut rng);\n                card.set_due_date(\n                    today,\n                    next_day_start,\n                    days_from_today,\n                    ease_factor,\n                    spec.force_reset,\n                );\n                col.log_manually_scheduled_review(&card, original.interval, usn)?;\n                col.update_card_inner(&mut card, original, usn)?;\n            }\n            if let Some(key) = context {\n                col.set_config_string_inner(key, days)?;\n            }\n            Ok(())\n        })\n    }\n\n    pub fn grade_now(&mut self, cids: &[CardId], rating: i32) -> Result<OpOutput<()>> {\n        self.transact(Op::GradeNow, |col| {\n            for &card_id in cids {\n                let states = col.get_scheduling_states(card_id)?;\n                let new_state = match rating {\n                    0 => states.again,\n                    1 => states.hard,\n                    2 => states.good,\n                    3 => states.easy,\n                    _ => invalid_input!(\"invalid rating\"),\n                };\n                let mut answer: CardAnswer = anki_proto::scheduler::CardAnswer {\n                    card_id: card_id.into(),\n                    current_state: Some(states.current.into()),\n                    new_state: Some(new_state.into()),\n                    rating,\n                    milliseconds_taken: 0,\n                    answered_at_millis: TimestampMillis::now().into(),\n                }\n                .into();\n                // Process the card without updating queues yet\n                answer.from_queue = false;\n                col.answer_card_inner(&mut answer)?;\n            }\n            Ok(())\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::prelude::*;\n\n    #[test]\n    fn parse() -> Result<()> {\n        type S = DueDateSpecifier;\n        assert!(parse_due_date_str(\"\").is_err());\n        assert!(parse_due_date_str(\"x\").is_err());\n        assert!(parse_due_date_str(\"-5\").is_err());\n        assert_eq!(\n            parse_due_date_str(\"5\")?,\n            S {\n                min: 5,\n                max: 5,\n                force_reset: false\n            }\n        );\n        assert_eq!(\n            parse_due_date_str(\"5!\")?,\n            S {\n                min: 5,\n                max: 5,\n                force_reset: true\n            }\n        );\n        assert_eq!(\n            parse_due_date_str(\"50-70\")?,\n            S {\n                min: 50,\n                max: 70,\n                force_reset: false\n            }\n        );\n        assert_eq!(\n            parse_due_date_str(\"70-50!\")?,\n            S {\n                min: 50,\n                max: 70,\n                force_reset: true\n            }\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn due_date() {\n        let mut c = Card::new(NoteId(0), 0, DeckId(0), 0);\n\n        // setting the due date of a new card will convert it\n        c.set_due_date(5, 0, 2, 1.8, false);\n        assert_eq!(c.ctype, CardType::Review);\n        assert_eq!(c.due, 7);\n        assert_eq!(c.interval, 2);\n        assert_eq!(c.ease_factor, 1800);\n\n        // reschedule it again the next day, shifting it from day 7 to day 9\n        c.set_due_date(6, 0, 3, 2.5, false);\n        assert_eq!(c.due, 9);\n        assert_eq!(c.interval, 2);\n        assert_eq!(c.ease_factor, 1800); // interval doesn't change\n\n        // we can bring cards forward too - return it to its original due date\n        c.set_due_date(6, 0, 1, 2.4, false);\n        assert_eq!(c.due, 7);\n        assert_eq!(c.interval, 2);\n        assert_eq!(c.ease_factor, 1800); // interval doesn't change\n\n        // we can force the interval to be reset instead of shifted\n        c.set_due_date(6, 0, 3, 2.3, true);\n        assert_eq!(c.due, 9);\n        assert_eq!(c.interval, 3);\n        assert_eq!(c.ease_factor, 1800); // interval doesn't change\n\n        // should work in a filtered deck\n        c.interval = 2;\n        c.ease_factor = 0;\n        c.original_due = 7;\n        c.original_deck_id = DeckId(1);\n        c.due = -10000;\n        c.queue = CardQueue::New;\n        c.set_due_date(6, 0, 1, 2.2, false);\n        assert_eq!(c.due, 7);\n        assert_eq!(c.interval, 2);\n        assert_eq!(c.ease_factor, 2200);\n        assert_eq!(c.queue, CardQueue::Review);\n        assert_eq!(c.original_due, 0);\n        assert_eq!(c.original_deck_id, DeckId(0));\n\n        // relearning treated like review\n        c.ctype = CardType::Relearn;\n        c.original_due = c.due;\n        c.due = 12345678;\n        c.set_due_date(6, 0, 10, 2.1, false);\n        assert_eq!(c.due, 16);\n        assert_eq!(c.interval, 2);\n        assert_eq!(c.ease_factor, 2200); // interval doesn't change\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/answering.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::mem;\n\nuse crate::prelude::*;\nuse crate::scheduler::answering::CardAnswer;\nuse crate::scheduler::answering::Rating;\nuse crate::scheduler::queue::QueuedCard;\nuse crate::scheduler::queue::QueuedCards;\n\nimpl From<anki_proto::scheduler::CardAnswer> for CardAnswer {\n    fn from(mut answer: anki_proto::scheduler::CardAnswer) -> Self {\n        let mut new_state = mem::take(&mut answer.new_state).unwrap_or_default();\n        let custom_data = mem::take(&mut new_state.custom_data);\n        CardAnswer {\n            card_id: CardId(answer.card_id),\n            rating: answer.rating().into(),\n            current_state: answer.current_state.unwrap_or_default().into(),\n            new_state: new_state.into(),\n            answered_at: TimestampMillis(answer.answered_at_millis),\n            milliseconds_taken: answer.milliseconds_taken,\n            custom_data,\n            from_queue: true,\n        }\n    }\n}\n\nimpl From<anki_proto::scheduler::card_answer::Rating> for Rating {\n    fn from(rating: anki_proto::scheduler::card_answer::Rating) -> Self {\n        match rating {\n            anki_proto::scheduler::card_answer::Rating::Again => Rating::Again,\n            anki_proto::scheduler::card_answer::Rating::Hard => Rating::Hard,\n            anki_proto::scheduler::card_answer::Rating::Good => Rating::Good,\n            anki_proto::scheduler::card_answer::Rating::Easy => Rating::Easy,\n        }\n    }\n}\n\nimpl From<QueuedCard> for anki_proto::scheduler::queued_cards::QueuedCard {\n    fn from(queued_card: QueuedCard) -> Self {\n        Self {\n            card: Some(queued_card.card.into()),\n            states: Some(queued_card.states.into()),\n            context: Some(queued_card.context),\n            queue: match queued_card.kind {\n                crate::scheduler::queue::QueueEntryKind::New => {\n                    anki_proto::scheduler::queued_cards::Queue::New\n                }\n                crate::scheduler::queue::QueueEntryKind::Review => {\n                    anki_proto::scheduler::queued_cards::Queue::Review\n                }\n                crate::scheduler::queue::QueueEntryKind::Learning => {\n                    anki_proto::scheduler::queued_cards::Queue::Learning\n                }\n            } as i32,\n        }\n    }\n}\n\nimpl From<QueuedCards> for anki_proto::scheduler::QueuedCards {\n    fn from(queued_cards: QueuedCards) -> Self {\n        Self {\n            cards: queued_cards.cards.into_iter().map(Into::into).collect(),\n            new_count: queued_cards.new_count as u32,\n            learning_count: queued_cards.learning_count as u32,\n            review_count: queued_cards.review_count as u32,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod answering;\nmod states;\n\nuse anki_proto::cards;\nuse anki_proto::generic;\nuse anki_proto::scheduler;\nuse anki_proto::scheduler::ComputeFsrsParamsResponse;\nuse anki_proto::scheduler::ComputeMemoryStateResponse;\nuse anki_proto::scheduler::ComputeOptimalRetentionResponse;\nuse anki_proto::scheduler::FsrsBenchmarkResponse;\nuse anki_proto::scheduler::FuzzDeltaRequest;\nuse anki_proto::scheduler::FuzzDeltaResponse;\nuse anki_proto::scheduler::GetOptimalRetentionParametersResponse;\nuse anki_proto::scheduler::SimulateFsrsReviewRequest;\nuse anki_proto::scheduler::SimulateFsrsReviewResponse;\nuse anki_proto::scheduler::SimulateFsrsWorkloadResponse;\nuse fsrs::ComputeParametersInput;\nuse fsrs::FSRSItem;\nuse fsrs::FSRSReview;\nuse fsrs::FSRS;\n\nuse crate::backend::Backend;\nuse crate::prelude::*;\nuse crate::scheduler::fsrs::params::ComputeParamsRequest;\nuse crate::scheduler::new::NewCardDueOrder;\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::SchedulingStates;\nuse crate::search::SortMode;\nuse crate::stats::studied_today;\n\nimpl crate::services::SchedulerService for Collection {\n    /// This behaves like _updateCutoff() in older code - it also unburies at\n    /// the start of a new day.\n    fn sched_timing_today(&mut self) -> Result<scheduler::SchedTimingTodayResponse> {\n        let timing = self.timing_today()?;\n        self.unbury_if_day_rolled_over(timing)?;\n        Ok(timing.into())\n    }\n\n    /// Fetch data from DB and return rendered string.\n    fn studied_today(&mut self) -> Result<generic::String> {\n        self.studied_today().map(Into::into)\n    }\n\n    /// Message rendering only, for old graphs.\n    fn studied_today_message(\n        &mut self,\n        input: scheduler::StudiedTodayMessageRequest,\n    ) -> Result<generic::String> {\n        Ok(studied_today(input.cards, input.seconds as f32, &self.tr).into())\n    }\n\n    fn update_stats(&mut self, input: scheduler::UpdateStatsRequest) -> Result<()> {\n        self.transact_no_undo(|col| {\n            let today = col.current_due_day(0)?;\n            let usn = col.usn()?;\n            col.update_deck_stats(today, usn, input)\n        })\n    }\n\n    fn extend_limits(&mut self, input: scheduler::ExtendLimitsRequest) -> Result<()> {\n        self.transact_no_undo(|col| {\n            let today = col.current_due_day(0)?;\n            let usn = col.usn()?;\n            col.extend_limits(\n                today,\n                usn,\n                input.deck_id.into(),\n                input.new_delta,\n                input.review_delta,\n            )\n        })\n    }\n\n    fn counts_for_deck_today(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> Result<scheduler::CountsForDeckTodayResponse> {\n        self.counts_for_deck_today(input.did.into())\n    }\n\n    fn congrats_info(&mut self) -> Result<scheduler::CongratsInfoResponse> {\n        self.congrats_info()\n    }\n\n    fn restore_buried_and_suspended_cards(\n        &mut self,\n        input: anki_proto::cards::CardIds,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        let cids: Vec<_> = input.cids.into_iter().map(CardId).collect();\n        self.unbury_or_unsuspend_cards(&cids).map(Into::into)\n    }\n\n    fn unbury_deck(\n        &mut self,\n        input: scheduler::UnburyDeckRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.unbury_deck(input.deck_id.into(), input.mode())\n            .map(Into::into)\n    }\n\n    fn bury_or_suspend_cards(\n        &mut self,\n        input: scheduler::BuryOrSuspendCardsRequest,\n    ) -> Result<anki_proto::collection::OpChangesWithCount> {\n        let mode = input.mode();\n        let cids = if input.card_ids.is_empty() {\n            self.storage\n                .card_ids_of_notes(&input.note_ids.into_newtype(NoteId))?\n        } else {\n            input.card_ids.into_newtype(CardId)\n        };\n        self.bury_or_suspend_cards(&cids, mode).map(Into::into)\n    }\n\n    fn empty_filtered_deck(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.empty_filtered_deck(input.did.into()).map(Into::into)\n    }\n\n    fn rebuild_filtered_deck(\n        &mut self,\n        input: anki_proto::decks::DeckId,\n    ) -> Result<anki_proto::collection::OpChangesWithCount> {\n        self.rebuild_filtered_deck(input.did.into()).map(Into::into)\n    }\n\n    fn schedule_cards_as_new(\n        &mut self,\n        input: scheduler::ScheduleCardsAsNewRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        let cids = input.card_ids.into_newtype(CardId);\n        self.reschedule_cards_as_new(\n            &cids,\n            input.log,\n            input.restore_position,\n            input.reset_counts,\n            input\n                .context\n                .and_then(|s| scheduler::schedule_cards_as_new_request::Context::try_from(s).ok()),\n        )\n        .map(Into::into)\n    }\n\n    fn schedule_cards_as_new_defaults(\n        &mut self,\n        input: scheduler::ScheduleCardsAsNewDefaultsRequest,\n    ) -> Result<scheduler::ScheduleCardsAsNewDefaultsResponse> {\n        Ok(Collection::reschedule_cards_as_new_defaults(\n            self,\n            input.context(),\n        ))\n    }\n\n    fn set_due_date(\n        &mut self,\n        input: scheduler::SetDueDateRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        let config = input.config_key.map(|v| v.key().into());\n        let days = input.days;\n        let cids = input.card_ids.into_newtype(CardId);\n        self.set_due_date(&cids, &days, config).map(Into::into)\n    }\n\n    fn grade_now(\n        &mut self,\n        input: scheduler::GradeNowRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.grade_now(&input.card_ids.into_newtype(CardId), input.rating)\n            .map(Into::into)\n    }\n\n    fn sort_cards(\n        &mut self,\n        input: scheduler::SortCardsRequest,\n    ) -> Result<anki_proto::collection::OpChangesWithCount> {\n        let cids = input.card_ids.into_newtype(CardId);\n        let (start, step, random, shift) = (\n            input.starting_from,\n            input.step_size,\n            input.randomize,\n            input.shift_existing,\n        );\n        let order = if random {\n            NewCardDueOrder::Random\n        } else {\n            NewCardDueOrder::Preserve\n        };\n\n        self.sort_cards(&cids, start, step, order, shift)\n            .map(Into::into)\n    }\n\n    fn reposition_defaults(&mut self) -> Result<scheduler::RepositionDefaultsResponse> {\n        Ok(Collection::reposition_defaults(self))\n    }\n\n    fn sort_deck(\n        &mut self,\n        input: scheduler::SortDeckRequest,\n    ) -> Result<anki_proto::collection::OpChangesWithCount> {\n        self.sort_deck_legacy(input.deck_id.into(), input.randomize)\n            .map(Into::into)\n    }\n\n    fn get_scheduling_states(\n        &mut self,\n        input: anki_proto::cards::CardId,\n    ) -> Result<scheduler::SchedulingStates> {\n        let cid: CardId = input.into();\n        self.get_scheduling_states(cid).map(Into::into)\n    }\n\n    fn describe_next_states(\n        &mut self,\n        input: scheduler::SchedulingStates,\n    ) -> Result<generic::StringList> {\n        let states: SchedulingStates = input.into();\n        self.describe_next_states(&states).map(Into::into)\n    }\n\n    fn state_is_leech(&mut self, input: scheduler::SchedulingState) -> Result<generic::Bool> {\n        let state: CardState = input.into();\n        Ok(state.leeched().into())\n    }\n\n    fn answer_card(\n        &mut self,\n        input: scheduler::CardAnswer,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.answer_card(&mut input.into()).map(Into::into)\n    }\n\n    fn upgrade_scheduler(&mut self) -> Result<()> {\n        self.transact_no_undo(|col| col.upgrade_to_v2_scheduler())\n    }\n\n    fn get_queued_cards(\n        &mut self,\n        input: scheduler::GetQueuedCardsRequest,\n    ) -> Result<scheduler::QueuedCards> {\n        self.get_queued_cards(input.fetch_limit as usize, input.intraday_learning_only)\n            .map(Into::into)\n    }\n\n    fn custom_study(\n        &mut self,\n        input: scheduler::CustomStudyRequest,\n    ) -> Result<anki_proto::collection::OpChanges> {\n        self.custom_study(input).map(Into::into)\n    }\n\n    fn custom_study_defaults(\n        &mut self,\n        input: scheduler::CustomStudyDefaultsRequest,\n    ) -> Result<scheduler::CustomStudyDefaultsResponse> {\n        self.custom_study_defaults(input.deck_id.into())\n    }\n\n    fn compute_fsrs_params(\n        &mut self,\n        input: scheduler::ComputeFsrsParamsRequest,\n    ) -> Result<scheduler::ComputeFsrsParamsResponse> {\n        self.compute_params(ComputeParamsRequest {\n            search: &input.search,\n            ignore_revlogs_before_ms: input.ignore_revlogs_before_ms.into(),\n            current_preset: 1,\n            total_presets: 1,\n            current_params: &input.current_params,\n            num_of_relearning_steps: input.num_of_relearning_steps as usize,\n            health_check: input.health_check,\n        })\n    }\n\n    fn simulate_fsrs_review(\n        &mut self,\n        input: SimulateFsrsReviewRequest,\n    ) -> Result<SimulateFsrsReviewResponse> {\n        self.simulate_review(input)\n    }\n\n    fn simulate_fsrs_workload(\n        &mut self,\n        input: SimulateFsrsReviewRequest,\n    ) -> Result<SimulateFsrsWorkloadResponse> {\n        self.simulate_workload(input)\n    }\n\n    fn compute_optimal_retention(\n        &mut self,\n        input: SimulateFsrsReviewRequest,\n    ) -> Result<ComputeOptimalRetentionResponse> {\n        Ok(ComputeOptimalRetentionResponse {\n            optimal_retention: self.compute_optimal_retention(input)?,\n        })\n    }\n\n    fn evaluate_params(\n        &mut self,\n        input: scheduler::EvaluateParamsRequest,\n    ) -> Result<scheduler::EvaluateParamsResponse> {\n        let ret = self.evaluate_params(\n            &input.search,\n            input.ignore_revlogs_before_ms.into(),\n            input.num_of_relearning_steps as usize,\n        )?;\n        Ok(scheduler::EvaluateParamsResponse {\n            log_loss: ret.log_loss,\n            rmse_bins: ret.rmse_bins,\n        })\n    }\n\n    fn evaluate_params_legacy(\n        &mut self,\n        input: scheduler::EvaluateParamsLegacyRequest,\n    ) -> Result<scheduler::EvaluateParamsResponse> {\n        let ret = self.evaluate_params_legacy(\n            &input.params,\n            &input.search,\n            input.ignore_revlogs_before_ms.into(),\n        )?;\n        Ok(scheduler::EvaluateParamsResponse {\n            log_loss: ret.log_loss,\n            rmse_bins: ret.rmse_bins,\n        })\n    }\n\n    fn get_optimal_retention_parameters(\n        &mut self,\n        input: scheduler::GetOptimalRetentionParametersRequest,\n    ) -> Result<scheduler::GetOptimalRetentionParametersResponse> {\n        let revlogs = self\n            .search_cards_into_table(&input.search, SortMode::NoOrder)?\n            .col\n            .storage\n            .get_revlog_entries_for_searched_cards_in_card_order()?;\n        let simulator_config = self.get_optimal_retention_parameters(revlogs)?;\n        Ok(GetOptimalRetentionParametersResponse {\n            deck_size: simulator_config.deck_size as u32,\n            learn_span: simulator_config.learn_span as u32,\n            max_cost_perday: simulator_config.max_cost_perday,\n            max_ivl: simulator_config.max_ivl,\n            first_rating_prob: simulator_config.first_rating_prob.to_vec(),\n            review_rating_prob: simulator_config.review_rating_prob.to_vec(),\n            loss_aversion: 1.0,\n            learn_limit: simulator_config.learn_limit as u32,\n            review_limit: simulator_config.review_limit as u32,\n            learning_step_transitions: simulator_config\n                .learning_step_transitions\n                .iter()\n                .flatten()\n                .cloned()\n                .collect(),\n            relearning_step_transitions: simulator_config\n                .relearning_step_transitions\n                .iter()\n                .flatten()\n                .cloned()\n                .collect(),\n            state_rating_costs: simulator_config\n                .state_rating_costs\n                .iter()\n                .flatten()\n                .cloned()\n                .collect(),\n            learning_step_count: simulator_config.learning_step_count as u32,\n            relearning_step_count: simulator_config.relearning_step_count as u32,\n        })\n    }\n\n    fn compute_memory_state(&mut self, input: cards::CardId) -> Result<ComputeMemoryStateResponse> {\n        self.compute_memory_state(input.into())\n    }\n\n    fn fuzz_delta(&mut self, input: FuzzDeltaRequest) -> Result<FuzzDeltaResponse> {\n        Ok(FuzzDeltaResponse {\n            delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?,\n        })\n    }\n}\n\nimpl crate::services::BackendSchedulerService for Backend {\n    fn compute_fsrs_params_from_items(\n        &self,\n        req: scheduler::ComputeFsrsParamsFromItemsRequest,\n    ) -> Result<scheduler::ComputeFsrsParamsResponse> {\n        let fsrs = FSRS::new(None)?;\n        let fsrs_items = req.items.len() as u32;\n        let params = fsrs.compute_parameters(ComputeParametersInput {\n            train_set: req.items.into_iter().map(fsrs_item_proto_to_fsrs).collect(),\n            progress: None,\n            enable_short_term: true,\n            num_relearning_steps: None,\n        })?;\n        Ok(ComputeFsrsParamsResponse {\n            params,\n            fsrs_items,\n            health_check_passed: None,\n        })\n    }\n\n    fn fsrs_benchmark(\n        &self,\n        req: scheduler::FsrsBenchmarkRequest,\n    ) -> Result<scheduler::FsrsBenchmarkResponse> {\n        let fsrs = FSRS::new(None)?;\n        let train_set = req\n            .train_set\n            .into_iter()\n            .map(fsrs_item_proto_to_fsrs)\n            .collect();\n        let params = fsrs.benchmark(ComputeParametersInput {\n            train_set,\n            progress: None,\n            enable_short_term: true,\n            num_relearning_steps: None,\n        });\n        Ok(FsrsBenchmarkResponse { params })\n    }\n\n    fn export_dataset(&self, req: scheduler::ExportDatasetRequest) -> Result<()> {\n        self.with_col(|col| {\n            col.export_dataset(\n                req.min_entries.try_into().unwrap(),\n                req.target_path.as_ref(),\n            )\n        })\n    }\n}\n\nfn fsrs_item_proto_to_fsrs(item: anki_proto::scheduler::FsrsItem) -> FSRSItem {\n    FSRSItem {\n        reviews: item\n            .reviews\n            .into_iter()\n            .map(fsrs_review_proto_to_fsrs)\n            .collect(),\n    }\n}\n\nfn fsrs_review_proto_to_fsrs(review: anki_proto::scheduler::FsrsReview) -> FSRSReview {\n    FSRSReview {\n        delta_t: review.delta_t,\n        rating: review.rating,\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/filtered.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::FilteredState;\n\nimpl From<FilteredState> for anki_proto::scheduler::scheduling_state::Filtered {\n    fn from(state: FilteredState) -> Self {\n        anki_proto::scheduler::scheduling_state::Filtered {\n            kind: Some(match state {\n                FilteredState::Preview(state) => {\n                    anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(state.into())\n                }\n                FilteredState::Rescheduling(state) => {\n                    anki_proto::scheduler::scheduling_state::filtered::Kind::Rescheduling(\n                        state.into(),\n                    )\n                }\n            }),\n        }\n    }\n}\n\nimpl From<anki_proto::scheduler::scheduling_state::Filtered> for FilteredState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Filtered) -> Self {\n        match state.kind.unwrap_or_else(|| {\n            anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(Default::default())\n        }) {\n            anki_proto::scheduler::scheduling_state::filtered::Kind::Preview(state) => {\n                FilteredState::Preview(state.into())\n            }\n            anki_proto::scheduler::scheduling_state::filtered::Kind::Rescheduling(state) => {\n                FilteredState::Rescheduling(state.into())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/learning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::LearnState;\n\nimpl From<anki_proto::scheduler::scheduling_state::Learning> for LearnState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Learning) -> Self {\n        LearnState {\n            remaining_steps: state.remaining_steps,\n            scheduled_secs: state.scheduled_secs,\n            elapsed_secs: state.elapsed_secs,\n            memory_state: state.memory_state.map(Into::into),\n        }\n    }\n}\n\nimpl From<LearnState> for anki_proto::scheduler::scheduling_state::Learning {\n    fn from(state: LearnState) -> Self {\n        anki_proto::scheduler::scheduling_state::Learning {\n            remaining_steps: state.remaining_steps,\n            scheduled_secs: state.scheduled_secs,\n            elapsed_secs: state.elapsed_secs,\n            memory_state: state.memory_state.map(Into::into),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod filtered;\nmod learning;\nmod new;\nmod normal;\nmod preview;\nmod relearning;\nmod rescheduling;\nmod review;\n\nuse crate::scheduler::states::CardState;\nuse crate::scheduler::states::NewState;\nuse crate::scheduler::states::NormalState;\nuse crate::scheduler::states::SchedulingStates;\n\nimpl From<SchedulingStates> for anki_proto::scheduler::SchedulingStates {\n    fn from(choices: SchedulingStates) -> Self {\n        anki_proto::scheduler::SchedulingStates {\n            current: Some(choices.current.into()),\n            again: Some(choices.again.into()),\n            hard: Some(choices.hard.into()),\n            good: Some(choices.good.into()),\n            easy: Some(choices.easy.into()),\n        }\n    }\n}\n\nimpl From<anki_proto::scheduler::SchedulingStates> for SchedulingStates {\n    fn from(choices: anki_proto::scheduler::SchedulingStates) -> Self {\n        SchedulingStates {\n            current: choices.current.unwrap_or_default().into(),\n            again: choices.again.unwrap_or_default().into(),\n            hard: choices.hard.unwrap_or_default().into(),\n            good: choices.good.unwrap_or_default().into(),\n            easy: choices.easy.unwrap_or_default().into(),\n        }\n    }\n}\n\nimpl From<CardState> for anki_proto::scheduler::SchedulingState {\n    fn from(state: CardState) -> Self {\n        anki_proto::scheduler::SchedulingState {\n            kind: Some(match state {\n                CardState::Normal(state) => {\n                    anki_proto::scheduler::scheduling_state::Kind::Normal(state.into())\n                }\n                CardState::Filtered(state) => {\n                    anki_proto::scheduler::scheduling_state::Kind::Filtered(state.into())\n                }\n            }),\n            custom_data: None,\n        }\n    }\n}\n\nimpl From<anki_proto::scheduler::SchedulingState> for CardState {\n    fn from(state: anki_proto::scheduler::SchedulingState) -> Self {\n        if let Some(value) = state.kind {\n            match value {\n                anki_proto::scheduler::scheduling_state::Kind::Normal(normal) => {\n                    CardState::Normal(normal.into())\n                }\n                anki_proto::scheduler::scheduling_state::Kind::Filtered(filtered) => {\n                    CardState::Filtered(filtered.into())\n                }\n            }\n        } else {\n            CardState::Normal(NormalState::New(NewState::default()))\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/new.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::NewState;\n\nimpl From<anki_proto::scheduler::scheduling_state::New> for NewState {\n    fn from(state: anki_proto::scheduler::scheduling_state::New) -> Self {\n        NewState {\n            position: state.position,\n        }\n    }\n}\n\nimpl From<NewState> for anki_proto::scheduler::scheduling_state::New {\n    fn from(state: NewState) -> Self {\n        anki_proto::scheduler::scheduling_state::New {\n            position: state.position,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/normal.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::NormalState;\n\nimpl From<NormalState> for anki_proto::scheduler::scheduling_state::Normal {\n    fn from(state: NormalState) -> Self {\n        anki_proto::scheduler::scheduling_state::Normal {\n            kind: Some(match state {\n                NormalState::New(state) => {\n                    anki_proto::scheduler::scheduling_state::normal::Kind::New(state.into())\n                }\n                NormalState::Learning(state) => {\n                    anki_proto::scheduler::scheduling_state::normal::Kind::Learning(state.into())\n                }\n                NormalState::Review(state) => {\n                    anki_proto::scheduler::scheduling_state::normal::Kind::Review(state.into())\n                }\n                NormalState::Relearning(state) => {\n                    anki_proto::scheduler::scheduling_state::normal::Kind::Relearning(state.into())\n                }\n            }),\n        }\n    }\n}\n\nimpl From<anki_proto::scheduler::scheduling_state::Normal> for NormalState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Normal) -> Self {\n        match state.kind.unwrap_or_else(|| {\n            anki_proto::scheduler::scheduling_state::normal::Kind::New(Default::default())\n        }) {\n            anki_proto::scheduler::scheduling_state::normal::Kind::New(state) => {\n                NormalState::New(state.into())\n            }\n            anki_proto::scheduler::scheduling_state::normal::Kind::Learning(state) => {\n                NormalState::Learning(state.into())\n            }\n            anki_proto::scheduler::scheduling_state::normal::Kind::Review(state) => {\n                NormalState::Review(state.into())\n            }\n            anki_proto::scheduler::scheduling_state::normal::Kind::Relearning(state) => {\n                NormalState::Relearning(state.into())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/preview.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::PreviewState;\n\nimpl From<anki_proto::scheduler::scheduling_state::Preview> for PreviewState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Preview) -> Self {\n        PreviewState {\n            scheduled_secs: state.scheduled_secs,\n            finished: state.finished,\n        }\n    }\n}\n\nimpl From<PreviewState> for anki_proto::scheduler::scheduling_state::Preview {\n    fn from(state: PreviewState) -> Self {\n        anki_proto::scheduler::scheduling_state::Preview {\n            scheduled_secs: state.scheduled_secs,\n            finished: state.finished,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/relearning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::RelearnState;\n\nimpl From<anki_proto::scheduler::scheduling_state::Relearning> for RelearnState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Relearning) -> Self {\n        RelearnState {\n            review: state.review.unwrap_or_default().into(),\n            learning: state.learning.unwrap_or_default().into(),\n        }\n    }\n}\n\nimpl From<RelearnState> for anki_proto::scheduler::scheduling_state::Relearning {\n    fn from(state: RelearnState) -> Self {\n        anki_proto::scheduler::scheduling_state::Relearning {\n            review: Some(state.review.into()),\n            learning: Some(state.learning.into()),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/rescheduling.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::ReschedulingFilterState;\n\nimpl From<anki_proto::scheduler::scheduling_state::ReschedulingFilter> for ReschedulingFilterState {\n    fn from(state: anki_proto::scheduler::scheduling_state::ReschedulingFilter) -> Self {\n        ReschedulingFilterState {\n            original_state: state.original_state.unwrap_or_default().into(),\n        }\n    }\n}\n\nimpl From<ReschedulingFilterState> for anki_proto::scheduler::scheduling_state::ReschedulingFilter {\n    fn from(state: ReschedulingFilterState) -> Self {\n        anki_proto::scheduler::scheduling_state::ReschedulingFilter {\n            original_state: Some(state.original_state.into()),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/service/states/review.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::scheduler::states::ReviewState;\n\nimpl From<anki_proto::scheduler::scheduling_state::Review> for ReviewState {\n    fn from(state: anki_proto::scheduler::scheduling_state::Review) -> Self {\n        ReviewState {\n            scheduled_days: state.scheduled_days,\n            elapsed_days: state.elapsed_days,\n            ease_factor: state.ease_factor,\n            lapses: state.lapses,\n            leeched: state.leeched,\n            memory_state: state.memory_state.map(Into::into),\n        }\n    }\n}\n\nimpl From<ReviewState> for anki_proto::scheduler::scheduling_state::Review {\n    fn from(state: ReviewState) -> Self {\n        anki_proto::scheduler::scheduling_state::Review {\n            scheduled_days: state.scheduled_days,\n            elapsed_days: state.elapsed_days,\n            ease_factor: state.ease_factor,\n            lapses: state.lapses,\n            leeched: state.leeched,\n            memory_state: state.memory_state.map(Into::into),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/filtered.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::IntervalKind;\nuse super::PreviewState;\nuse super::ReschedulingFilterState;\nuse super::ReviewState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum FilteredState {\n    Preview(PreviewState),\n    Rescheduling(ReschedulingFilterState),\n}\n\nimpl FilteredState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        match self {\n            FilteredState::Preview(state) => state.interval_kind(),\n            FilteredState::Rescheduling(state) => state.interval_kind(),\n        }\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        match self {\n            FilteredState::Preview(state) => state.revlog_kind(),\n            FilteredState::Rescheduling(state) => state.revlog_kind(),\n        }\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        match self {\n            FilteredState::Preview(state) => state.next_states(ctx),\n            FilteredState::Rescheduling(state) => state.next_states(ctx),\n        }\n    }\n\n    pub(crate) fn review_state(self) -> Option<ReviewState> {\n        match self {\n            FilteredState::Preview(_) => None,\n            FilteredState::Rescheduling(state) => state.original_state.review_state(),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/fuzz.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::StateContext;\nuse crate::collection::Collection;\nuse crate::prelude::*;\n\n/// Describes a range of days for which a certain amount of fuzz is applied to\n/// the new interval.\nstruct FuzzRange {\n    start: f32,\n    end: f32,\n    factor: f32,\n}\n\nstatic FUZZ_RANGES: [FuzzRange; 3] = [\n    FuzzRange {\n        start: 2.5,\n        end: 7.0,\n        factor: 0.15,\n    },\n    FuzzRange {\n        start: 7.0,\n        end: 20.0,\n        factor: 0.1,\n    },\n    FuzzRange {\n        start: 20.0,\n        end: f32::MAX,\n        factor: 0.05,\n    },\n];\n\nimpl StateContext<'_> {\n    /// Apply fuzz, respecting the passed bounds.\n    pub(crate) fn with_review_fuzz(&self, interval: f32, minimum: u32, maximum: u32) -> u32 {\n        self.load_balancer_ctx\n            .as_ref()\n            .and_then(|load_balancer_ctx| {\n                load_balancer_ctx.find_interval(interval, minimum, maximum)\n            })\n            .unwrap_or_else(|| with_review_fuzz(self.fuzz_factor, interval, minimum, maximum))\n    }\n}\n\nimpl Collection {\n    /// Used for FSRS add-on.\n    pub(crate) fn get_fuzz_delta(&self, card_id: CardId, interval: u32) -> Result<i32> {\n        let card = self.storage.get_card(card_id)?.or_not_found(card_id)?;\n        let deck = self\n            .storage\n            .get_deck(card.deck_id)?\n            .or_not_found(card.deck_id)?;\n        let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?;\n        let fuzzed = with_review_fuzz(\n            card.get_fuzz_factor(true),\n            interval as f32,\n            1,\n            config.inner.maximum_review_interval,\n        );\n        Ok((fuzzed as i32) - (interval as i32))\n    }\n}\n\npub(crate) fn with_review_fuzz(\n    fuzz_factor: Option<f32>,\n    interval: f32,\n    minimum: u32,\n    maximum: u32,\n) -> u32 {\n    if let Some(fuzz_factor) = fuzz_factor {\n        let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum);\n        (lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32\n    } else {\n        (interval.round() as u32).clamp(minimum, maximum)\n    }\n}\n\n/// Return the bounds of the fuzz range, respecting `minimum` and `maximum`.\n/// Ensure the upper bound is larger than the lower bound, if `maximum` allows\n/// it and it is larger than 1.\npub(crate) fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u32) {\n    let minimum = minimum.min(maximum);\n    let interval = interval.clamp(minimum as f32, maximum as f32);\n    let (mut lower, mut upper) = fuzz_bounds(interval);\n\n    // minimum <= maximum and lower <= upper are assumed\n    // now ensure minimum <= lower <= upper <= maximum\n    lower = lower.clamp(minimum, maximum);\n    upper = upper.clamp(minimum, maximum);\n\n    if upper == lower && upper > 2 && upper < maximum {\n        upper = lower + 1;\n    };\n\n    (lower, upper)\n}\n\npub(crate) fn fuzz_bounds(interval: f32) -> (u32, u32) {\n    let delta = fuzz_delta(interval);\n    (\n        (interval - delta).round() as u32,\n        (interval + delta).round() as u32,\n    )\n}\n\n/// Return the amount of fuzz to apply to the interval in both directions.\n/// Short intervals do not get fuzzed. All other intervals get fuzzed by 1 day\n/// plus the number of its days in each defined fuzz range multiplied with the\n/// given factor.\nfn fuzz_delta(interval: f32) -> f32 {\n    if interval < 2.5 {\n        0.0\n    } else {\n        FUZZ_RANGES.iter().fold(1.0, |delta, range| {\n            delta + range.factor * (interval.min(range.end) - range.start).max(0.0)\n        })\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn with_review_fuzz() {\n        let mut ctx = StateContext::defaults_for_testing();\n\n        // no fuzz\n        assert_eq!(ctx.with_review_fuzz(1.5, 1, 100), 2);\n        assert_eq!(ctx.with_review_fuzz(0.1, 1, 100), 1);\n        assert_eq!(ctx.with_review_fuzz(101.0, 1, 100), 100);\n\n        macro_rules! assert_lower_middle_upper {\n            ($interval:expr, $minimum:expr, $maximum:expr, $lower:expr, $middle:expr, $upper:expr) => {{\n                ctx.fuzz_factor = Some(0.0);\n                assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $lower);\n                ctx.fuzz_factor = Some(0.5);\n                assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $middle);\n                ctx.fuzz_factor = Some(0.99);\n                assert_eq!(ctx.with_review_fuzz($interval, $minimum, $maximum), $upper);\n            }};\n        }\n\n        // no fuzzing for an interval of 1-2.49\n        assert_lower_middle_upper!(1.0, 1, 1000, 1, 1, 1);\n        assert_lower_middle_upper!(2.49, 1, 1000, 2, 2, 2);\n\n        // 1 day for intervals >= 2.5\n        assert_lower_middle_upper!(2.5, 1, 1000, 2, 3, 4);\n        // ... plus 0.15 for every day in the range 2.5-7\n        assert_lower_middle_upper!(7.0, 1, 1000, 5, 7, 9);\n        // ... plus 0.1 for every day in the range 7-20\n        assert_lower_middle_upper!(17.0, 1, 1000, 14, 17, 20);\n        // ... plus 0.05 for every day above 20\n        assert_lower_middle_upper!(37.0, 1, 1000, 33, 37, 41);\n\n        // ensure fuzz range of at least 2, if allowed\n        assert_lower_middle_upper!(2.0, 2, 1000, 2, 2, 2);\n        assert_lower_middle_upper!(2.0, 3, 1000, 3, 4, 4);\n        assert_lower_middle_upper!(2.0, 3, 3, 3, 3, 3);\n\n        // fuzz range transitions\n        assert_lower_middle_upper!(6.9, 3, 1000, 5, 7, 9);\n        assert_lower_middle_upper!(7.0, 3, 1000, 5, 7, 9);\n        assert_lower_middle_upper!(7.1, 3, 1000, 5, 7, 9);\n        assert_lower_middle_upper!(19.9, 3, 1000, 17, 20, 23);\n        assert_lower_middle_upper!(20.0, 3, 1000, 17, 20, 23);\n        assert_lower_middle_upper!(20.1, 3, 1000, 17, 20, 23);\n\n        // respect limits and preserve uniform distribution of valid intervals\n        assert_lower_middle_upper!(100.0, 101, 1000, 101, 105, 108);\n        assert_lower_middle_upper!(100.0, 1, 99, 92, 96, 99);\n        assert_lower_middle_upper!(100.0, 97, 103, 97, 100, 103);\n    }\n\n    #[test]\n    fn invalid_values_will_not_panic() {\n        constrained_fuzz_bounds(1.0, 3, 2);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/interval_kind.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum IntervalKind {\n    InSecs(u32),\n    InDays(u32),\n}\n\nimpl IntervalKind {\n    /// Convert seconds-based intervals that pass the day barrier into days.\n    pub(crate) fn maybe_as_days(self, secs_until_rollover: u32) -> Self {\n        match self {\n            IntervalKind::InSecs(secs) => {\n                if secs >= secs_until_rollover {\n                    IntervalKind::InDays(((secs - secs_until_rollover) / 86_400) + 1)\n                } else {\n                    IntervalKind::InSecs(secs)\n                }\n            }\n            other => other,\n        }\n    }\n\n    pub(crate) fn as_seconds(self) -> u32 {\n        match self {\n            IntervalKind::InSecs(secs) => secs,\n            IntervalKind::InDays(days) => days.saturating_mul(86_400),\n        }\n    }\n\n    pub(crate) fn as_revlog_interval(self) -> i32 {\n        match self {\n            IntervalKind::InDays(days) => days as i32,\n            IntervalKind::InSecs(secs) => -i32::try_from(secs).unwrap_or(i32::MAX),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/learning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::interval_kind::IntervalKind;\nuse super::CardState;\nuse super::ReviewState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::card::FsrsMemoryState;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct LearnState {\n    pub remaining_steps: u32,\n    pub scheduled_secs: u32,\n    pub elapsed_secs: u32,\n    pub memory_state: Option<FsrsMemoryState>,\n}\n\nimpl LearnState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        IntervalKind::InSecs(self.scheduled_secs)\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        RevlogReviewKind::Learning\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        SchedulingStates {\n            current: self.into(),\n            again: self.answer_again(ctx),\n            hard: self.answer_hard(ctx),\n            good: self.answer_good(ctx),\n            easy: self.answer_easy(ctx).into(),\n        }\n    }\n\n    fn answer_again(self, ctx: &StateContext) -> CardState {\n        let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into());\n        if let Some(again_delay) = ctx.steps.again_delay_secs_learn() {\n            LearnState {\n                remaining_steps: ctx.steps.remaining_for_failed(),\n                scheduled_secs: again_delay,\n                elapsed_secs: 0,\n                memory_state,\n            }\n            .into()\n        } else {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states {\n                (\n                    states.again.interval,\n                    ctx.fsrs_allow_short_term\n                        && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty())\n                        && states.again.interval < 0.5,\n                )\n            } else {\n                (ctx.graduating_interval_good as f32, false)\n            };\n\n            if short_term {\n                LearnState {\n                    remaining_steps: ctx.steps.remaining_for_failed(),\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    elapsed_secs: 0,\n                    memory_state,\n                }\n                .into()\n            } else {\n                ReviewState {\n                    scheduled_days: ctx.with_review_fuzz(\n                        interval.round().max(1.0),\n                        minimum,\n                        maximum,\n                    ),\n                    ease_factor: ctx.initial_ease_factor,\n                    memory_state,\n                    ..Default::default()\n                }\n                .into()\n            }\n        }\n    }\n\n    fn answer_hard(self, ctx: &StateContext) -> CardState {\n        let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into());\n        if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) {\n            LearnState {\n                scheduled_secs: hard_delay,\n                elapsed_secs: 0,\n                memory_state,\n                ..self\n            }\n            .into()\n        } else {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states {\n                (\n                    states.hard.interval,\n                    ctx.fsrs_allow_short_term\n                        && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty())\n                        && states.hard.interval < 0.5,\n                )\n            } else {\n                (ctx.graduating_interval_good as f32, false)\n            };\n\n            if short_term {\n                LearnState {\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    elapsed_secs: 0,\n                    memory_state,\n                    ..self\n                }\n                .into()\n            } else {\n                ReviewState {\n                    scheduled_days: ctx.with_review_fuzz(\n                        interval.round().max(1.0),\n                        minimum,\n                        maximum,\n                    ),\n                    ease_factor: ctx.initial_ease_factor,\n                    memory_state,\n                    ..Default::default()\n                }\n                .into()\n            }\n        }\n    }\n\n    fn answer_good(self, ctx: &StateContext) -> CardState {\n        let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into());\n        if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) {\n            LearnState {\n                remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),\n                scheduled_secs: good_delay,\n                elapsed_secs: 0,\n                memory_state,\n            }\n            .into()\n        } else {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let (interval, short_term) = if let Some(states) = &ctx.fsrs_next_states {\n                (\n                    states.good.interval,\n                    ctx.fsrs_allow_short_term\n                        && (ctx.fsrs_short_term_with_steps_enabled || ctx.steps.is_empty())\n                        && states.good.interval < 0.5,\n                )\n            } else {\n                (ctx.graduating_interval_good as f32, false)\n            };\n\n            if short_term {\n                LearnState {\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    elapsed_secs: 0,\n                    memory_state,\n                    ..self\n                }\n                .into()\n            } else {\n                ReviewState {\n                    scheduled_days: ctx.with_review_fuzz(\n                        interval.round().max(1.0),\n                        minimum,\n                        maximum,\n                    ),\n                    ease_factor: ctx.initial_ease_factor,\n                    memory_state,\n                    ..Default::default()\n                }\n                .into()\n            }\n        }\n    }\n\n    fn answer_easy(self, ctx: &StateContext) -> ReviewState {\n        let (mut minimum, maximum) = ctx.min_and_max_review_intervals(1);\n        let interval = if let Some(states) = &ctx.fsrs_next_states {\n            let good = ctx.with_review_fuzz(states.good.interval, minimum, maximum);\n            minimum = good + 1;\n            states.easy.interval.round().max(1.0) as u32\n        } else {\n            ctx.graduating_interval_easy\n        };\n        ReviewState {\n            scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum),\n            ease_factor: ctx.initial_ease_factor,\n            memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/load_balancer.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse chrono::Datelike;\nuse rand::distr::weighted::WeightedIndex;\nuse rand::distr::Distribution;\nuse rand::rngs::StdRng;\nuse rand::SeedableRng;\n\nuse super::fuzz::constrained_fuzz_bounds;\nuse crate::card::CardId;\nuse crate::deckconfig::DeckConfigId;\nuse crate::error::InvalidInputError;\nuse crate::notes::NoteId;\nuse crate::prelude::*;\nuse crate::storage::SqliteStorage;\n\nconst MAX_LOAD_BALANCE_INTERVAL: usize = 90;\n// due to the nature of load balancing, we may schedule things in the future and\n// so need to keep more than just the `MAX_LOAD_BALANCE_INTERVAL` days in our\n// cache. a flat 10% increase over the max interval should be enough to not have\n// problems\nconst LOAD_BALANCE_DAYS: usize = (MAX_LOAD_BALANCE_INTERVAL as f32 * 1.1) as usize;\nconst SIBLING_PENALTY: f32 = 0.001;\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\npub enum EasyDay {\n    Minimum,\n    Reduced,\n    Normal,\n}\n\nimpl From<f32> for EasyDay {\n    fn from(other: f32) -> EasyDay {\n        match other {\n            1.0 => EasyDay::Normal,\n            0.0 => EasyDay::Minimum,\n            _ => EasyDay::Reduced,\n        }\n    }\n}\n\nimpl EasyDay {\n    pub(crate) fn load_modifier(&self) -> f32 {\n        match self {\n            // this is a non-zero value so if all days are minimum, the load balancer will\n            // proceed as normal\n            EasyDay::Minimum => 0.0001,\n            EasyDay::Reduced => 0.5,\n            EasyDay::Normal => 1.0,\n        }\n    }\n}\n\n#[derive(Debug, Default)]\nstruct LoadBalancerDay {\n    cards: Vec<(CardId, NoteId)>,\n    notes: HashSet<NoteId>,\n}\n\nimpl LoadBalancerDay {\n    fn add(&mut self, cid: CardId, nid: NoteId) {\n        self.cards.push((cid, nid));\n        self.notes.insert(nid);\n    }\n\n    fn remove(&mut self, cid: CardId) {\n        if let Some(index) = self.cards.iter().position(|c| c.0 == cid) {\n            let (_, rnid) = self.cards.swap_remove(index);\n\n            // if all cards of a note are removed, remove note\n            if !self.cards.iter().any(|(_cid, nid)| *nid == rnid) {\n                self.notes.remove(&rnid);\n            }\n        }\n    }\n\n    fn has_sibling(&self, nid: &NoteId) -> bool {\n        self.notes.contains(nid)\n    }\n}\n\npub struct LoadBalancerContext<'a> {\n    load_balancer: &'a LoadBalancer,\n    note_id: Option<NoteId>,\n    deckconfig_id: DeckConfigId,\n    fuzz_seed: Option<u64>,\n}\n\nimpl LoadBalancerContext<'_> {\n    pub fn find_interval(&self, interval: f32, minimum: u32, maximum: u32) -> Option<u32> {\n        self.load_balancer.find_interval(\n            interval,\n            minimum,\n            maximum,\n            self.deckconfig_id,\n            self.fuzz_seed,\n            self.note_id,\n        )\n    }\n\n    pub fn set_fuzz_seed(mut self, fuzz_seed: Option<u64>) -> Self {\n        self.fuzz_seed = fuzz_seed;\n        self\n    }\n}\n\n#[derive(Debug)]\npub struct LoadBalancer {\n    /// Load balancer operates at the preset level, it only counts\n    /// cards in the same preset as the card being balanced.\n    days_by_preset: HashMap<DeckConfigId, [LoadBalancerDay; LOAD_BALANCE_DAYS]>,\n    easy_days_percentages_by_preset: HashMap<DeckConfigId, [EasyDay; 7]>,\n    next_day_at: TimestampSecs,\n}\n\nimpl LoadBalancer {\n    pub fn new(\n        today: u32,\n        did_to_dcid: HashMap<DeckId, DeckConfigId>,\n        next_day_at: TimestampSecs,\n        storage: &SqliteStorage,\n    ) -> Result<LoadBalancer> {\n        let cards_on_each_day =\n            storage.get_all_cards_due_in_range(today, today + LOAD_BALANCE_DAYS as u32)?;\n        let days_by_preset = cards_on_each_day\n            .into_iter()\n            // for each day, group all cards on each day by their deck config id\n            .map(|cards_on_day| {\n                cards_on_day\n                    .into_iter()\n                    .filter_map(|(cid, nid, did)| Some((cid, nid, did_to_dcid.get(&did)?)))\n                    .fold(\n                        HashMap::<_, Vec<_>>::new(),\n                        |mut day_group_by_dcid, (cid, nid, dcid)| {\n                            day_group_by_dcid.entry(dcid).or_default().push((cid, nid));\n\n                            day_group_by_dcid\n                        },\n                    )\n            })\n            .enumerate()\n            // consolidate card by day groups into groups of [LoadBalancerDay; LOAD_BALANCE_DAYS]s\n            .fold(\n                HashMap::new(),\n                |mut deckconfig_group, (day_index, days_grouped_by_dcid)| {\n                    for (group, cards) in days_grouped_by_dcid.into_iter() {\n                        let day = deckconfig_group\n                            .entry(*group)\n                            .or_insert_with(|| std::array::from_fn(|_| LoadBalancerDay::default()));\n\n                        for (cid, nid) in cards {\n                            day[day_index].add(cid, nid);\n                        }\n                    }\n\n                    deckconfig_group\n                },\n            );\n        let configs = storage.get_deck_config_map()?;\n        let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?;\n\n        Ok(LoadBalancer {\n            days_by_preset,\n            easy_days_percentages_by_preset,\n            next_day_at,\n        })\n    }\n\n    pub fn review_context(\n        &self,\n        note_id: Option<NoteId>,\n        deckconfig_id: DeckConfigId,\n    ) -> LoadBalancerContext<'_> {\n        LoadBalancerContext {\n            load_balancer: self,\n            note_id,\n            deckconfig_id,\n            fuzz_seed: None,\n        }\n    }\n\n    /// The main load balancing function\n    /// Given an interval and min/max range it does its best to find the best\n    /// day within the standard fuzz range to schedule a card that leads to\n    /// a consistent workload.\n    ///\n    /// It works by using a weighted random, assigning a weight between 0.0 and\n    /// 1.0 to each day in the fuzz range for an interval.\n    /// the weight takes into account the number of cards due on a day as well\n    /// as the interval itself.\n    /// `weight = (1 / (cards_due))**2 * (1 / target_interval)`\n    ///\n    /// By including the target_interval in the calculation, the interval is\n    /// slightly biased to be due earlier. Without this, the load balancer\n    /// ends up being very biased towards later days, especially around\n    /// graduating intervals.\n    ///\n    /// if a note_id is provided, it attempts to avoid placing a card on a day\n    /// that already has that note_id (aka avoid siblings)\n    fn find_interval(\n        &self,\n        interval: f32,\n        minimum: u32,\n        maximum: u32,\n        deckconfig_id: DeckConfigId,\n        fuzz_seed: Option<u64>,\n        note_id: Option<NoteId>,\n    ) -> Option<u32> {\n        // if we're sending a card far out into the future, the need to balance is low\n        if interval as usize > MAX_LOAD_BALANCE_INTERVAL\n            || minimum as usize > MAX_LOAD_BALANCE_INTERVAL\n        {\n            return None;\n        }\n\n        let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum);\n\n        let days = self.days_by_preset.get(&deckconfig_id)?;\n        let interval_days = &days[before_days as usize..=after_days as usize];\n\n        // calculate review counts and expected distribution\n        let (review_counts, weekdays): (Vec<usize>, Vec<usize>) = interval_days\n            .iter()\n            .enumerate()\n            .map(|(i, day)| {\n                (\n                    day.cards.len(),\n                    interval_to_weekday(i as u32 + before_days, self.next_day_at),\n                )\n            })\n            .unzip();\n\n        let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;\n        let easy_days_modifier =\n            calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);\n\n        let intervals = interval_days\n            .iter()\n            .enumerate()\n            .map(|(interval_index, interval_day)| {\n                LoadBalancerInterval {\n                    target_interval: interval_index as u32 + before_days,\n                    review_count: review_counts[interval_index],\n                    // if there is a sibling on this day, give it a very low weight\n                    sibling_modifier: note_id\n                        .and_then(|note_id| {\n                            interval_day\n                                .has_sibling(&note_id)\n                                .then_some(SIBLING_PENALTY)\n                        })\n                        .unwrap_or(1.0),\n                    easy_days_modifier: easy_days_modifier[interval_index],\n                }\n            });\n\n        select_weighted_interval(intervals, fuzz_seed)\n    }\n\n    pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) {\n        if let Some(days) = self.days_by_preset.get_mut(&dcid) {\n            if let Some(day) = days.get_mut(interval as usize) {\n                day.add(cid, nid);\n            }\n        }\n    }\n\n    pub fn remove_card(&mut self, cid: CardId) {\n        for (_, days) in self.days_by_preset.iter_mut() {\n            for day in days.iter_mut() {\n                day.remove(cid);\n            }\n        }\n    }\n}\n\npub(crate) fn parse_easy_days_percentages(percentages: &[f32]) -> Result<[EasyDay; 7]> {\n    if percentages.is_empty() {\n        return Ok([EasyDay::Normal; 7]);\n    }\n\n    Ok(TryInto::<[_; 7]>::try_into(percentages)\n        .map_err(|_| {\n            AnkiError::from(InvalidInputError {\n                message: \"expected 7 days\".into(),\n                source: None,\n                backtrace: None,\n            })\n        })?\n        .map(EasyDay::from))\n}\n\npub(crate) fn build_easy_days_percentages(\n    configs: HashMap<DeckConfigId, DeckConfig>,\n) -> Result<HashMap<DeckConfigId, [EasyDay; 7]>> {\n    configs\n        .into_iter()\n        .map(|(dcid, conf)| {\n            let easy_days_percentages =\n                parse_easy_days_percentages(&conf.inner.easy_days_percentages)?;\n            Ok((dcid, easy_days_percentages))\n        })\n        .collect()\n}\n\n// Determine which days to schedule to with respect to Easy Day settings\n// If a day is Normal, it will always be an option to schedule to\n// If a day is Minimum, it will almost never be an option to schedule to\n// If a day is Reduced, it will look at the amount of cards due in the fuzz\n//    range to determine if scheduling a card on that day would put it\n//    above the reduced threshold or not.\n// the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be\n//    used when calculating the day's weight. This turns the day on or off.\n//    Note that it does not actually set it to 0.0, but a small\n//    0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to\n//    handle a handful of zero-related corner cases.\npub(crate) fn calculate_easy_days_modifiers(\n    easy_days_load: &[EasyDay; 7],\n    weekdays: &[usize],\n    review_counts: &[usize],\n) -> Vec<f32> {\n    let total_review_count: usize = review_counts.iter().sum();\n    let total_percents: f32 = weekdays\n        .iter()\n        .map(|&weekday| easy_days_load[weekday].load_modifier())\n        .sum();\n\n    weekdays\n        .iter()\n        .zip(review_counts.iter())\n        .map(|(&weekday, &review_count)| {\n            let day = match easy_days_load[weekday] {\n                EasyDay::Reduced => {\n                    const HALF: f32 = 0.5;\n                    let other_days_review_total = (total_review_count - review_count) as f32;\n                    let other_days_percent_total = total_percents - HALF;\n                    let normalized_count = review_count as f32 / HALF;\n                    let reduced_day_threshold = other_days_review_total / other_days_percent_total;\n                    if normalized_count > reduced_day_threshold {\n                        EasyDay::Minimum\n                    } else {\n                        EasyDay::Normal\n                    }\n                }\n                other => other,\n            };\n            day.load_modifier()\n        })\n        .collect()\n}\n\npub struct LoadBalancerInterval {\n    pub target_interval: u32,\n    pub review_count: usize,\n    pub sibling_modifier: f32,\n    pub easy_days_modifier: f32,\n}\n\npub fn select_weighted_interval(\n    intervals: impl Iterator<Item = LoadBalancerInterval>,\n    fuzz_seed: Option<u64>,\n) -> Option<u32> {\n    let intervals_and_weights = intervals\n        .map(|interval| {\n            let weight = match interval.review_count {\n                0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight\n                card_count => {\n                    let card_count_weight = (1.0 / card_count as f32).powf(2.15);\n                    let card_interval_weight = (1.0 / interval.target_interval as f32).powi(3);\n\n                    card_count_weight\n                        * card_interval_weight\n                        * interval.sibling_modifier\n                        * interval.easy_days_modifier\n                }\n            };\n\n            (interval.target_interval, weight)\n        })\n        .collect::<Vec<_>>();\n\n    let mut rng = StdRng::seed_from_u64(fuzz_seed?);\n\n    let weighted_intervals = WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?;\n\n    let selected_interval_index = weighted_intervals.sample(&mut rng);\n    Some(intervals_and_weights[selected_interval_index].0)\n}\n\npub(crate) fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {\n    let target_datetime = next_day_at\n        .adding_secs((interval - 1) as i64 * 86400)\n        .local_datetime()\n        .unwrap();\n    target_datetime.weekday().num_days_from_monday() as usize\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod filtered;\npub(crate) mod fuzz;\npub(crate) mod interval_kind;\npub(crate) mod learning;\npub(crate) mod load_balancer;\npub(crate) mod new;\npub(crate) mod normal;\npub(crate) mod preview_filter;\npub(crate) mod relearning;\npub(crate) mod rescheduling_filter;\npub(crate) mod review;\npub(crate) mod steps;\n\npub use filtered::FilteredState;\nuse fsrs::NextStates;\npub(crate) use interval_kind::IntervalKind;\npub use learning::LearnState;\nuse load_balancer::LoadBalancerContext;\npub use new::NewState;\npub use normal::NormalState;\npub use preview_filter::PreviewState;\npub use relearning::RelearnState;\npub use rescheduling_filter::ReschedulingFilterState;\npub use review::ReviewState;\n\nuse self::steps::LearningSteps;\nuse crate::revlog::RevlogReviewKind;\nuse crate::scheduler::answering::PreviewDelays;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum CardState {\n    Normal(NormalState),\n    Filtered(FilteredState),\n}\n\nimpl CardState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        match self {\n            CardState::Normal(normal) => normal.interval_kind(),\n            CardState::Filtered(filtered) => filtered.interval_kind(),\n        }\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        match self {\n            CardState::Normal(normal) => normal.revlog_kind(),\n            CardState::Filtered(filtered) => filtered.revlog_kind(),\n        }\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        match self {\n            CardState::Normal(state) => state.next_states(ctx),\n            CardState::Filtered(state) => state.next_states(ctx),\n        }\n    }\n\n    /// Returns underlying review state, if it exists.\n    pub(crate) fn review_state(self) -> Option<ReviewState> {\n        match self {\n            CardState::Normal(state) => state.review_state(),\n            CardState::Filtered(state) => state.review_state(),\n        }\n    }\n\n    pub(crate) fn leeched(self) -> bool {\n        self.review_state().map(|r| r.leeched).unwrap_or_default()\n    }\n\n    /// Returns the position if it's a [NewState].\n    pub(super) fn new_position(&self) -> Option<u32> {\n        match self {\n            Self::Normal(NormalState::New(NewState { position }))\n            | Self::Filtered(FilteredState::Rescheduling(ReschedulingFilterState {\n                original_state: NormalState::New(NewState { position }),\n            })) => Some(*position),\n            _ => None,\n        }\n    }\n}\n\n/// Info required during state transitions.\npub(crate) struct StateContext<'a> {\n    /// In range `0.0..1.0`. Used to pick the final interval from the fuzz\n    /// range.\n    pub fuzz_factor: Option<f32>,\n    pub fsrs_next_states: Option<NextStates>,\n    pub fsrs_short_term_with_steps_enabled: bool,\n    pub fsrs_allow_short_term: bool,\n    // learning\n    pub steps: LearningSteps<'a>,\n    pub graduating_interval_good: u32,\n    pub graduating_interval_easy: u32,\n    pub initial_ease_factor: f32,\n\n    // reviewing\n    pub hard_multiplier: f32,\n    pub easy_multiplier: f32,\n    pub interval_multiplier: f32,\n    pub maximum_review_interval: u32,\n    pub leech_threshold: u32,\n    pub load_balancer_ctx: Option<LoadBalancerContext<'a>>,\n\n    // relearning\n    pub relearn_steps: LearningSteps<'a>,\n    pub lapse_multiplier: f32,\n    pub minimum_lapse_interval: u32,\n\n    // filtered\n    pub in_filtered_deck: bool,\n    pub preview_delays: PreviewDelays,\n}\n\nimpl StateContext<'_> {\n    /// Return the minimum and maximum review intervals.\n    /// - `maximum` is `self.maximum_review_interval`, but at least 1.\n    /// - `minimum` is as passed, but at least 1, and at most `maximum`.\n    pub(crate) fn min_and_max_review_intervals(&self, minimum: u32) -> (u32, u32) {\n        let maximum = self.maximum_review_interval.max(1);\n        let minimum = minimum.clamp(1, maximum);\n        (minimum, maximum)\n    }\n\n    #[cfg(test)]\n    pub(crate) fn defaults_for_testing() -> Self {\n        Self {\n            fuzz_factor: None,\n            steps: LearningSteps::new(&[1.0, 10.0]),\n            graduating_interval_good: 1,\n            graduating_interval_easy: 4,\n            initial_ease_factor: 2.5,\n            hard_multiplier: 1.2,\n            easy_multiplier: 1.3,\n            interval_multiplier: 1.0,\n            maximum_review_interval: 36500,\n            leech_threshold: 8,\n            load_balancer_ctx: None,\n            relearn_steps: LearningSteps::new(&[10.0]),\n            lapse_multiplier: 0.0,\n            minimum_lapse_interval: 1,\n            in_filtered_deck: false,\n            preview_delays: PreviewDelays {\n                again: 1,\n                hard: 10,\n                good: 0,\n            },\n            fsrs_next_states: None,\n            fsrs_short_term_with_steps_enabled: false,\n            fsrs_allow_short_term: false,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct SchedulingStates {\n    pub current: CardState,\n    pub again: CardState,\n    pub hard: CardState,\n    pub good: CardState,\n    pub easy: CardState,\n}\n\nimpl From<NewState> for CardState {\n    fn from(state: NewState) -> Self {\n        CardState::Normal(state.into())\n    }\n}\n\nimpl From<ReviewState> for CardState {\n    fn from(state: ReviewState) -> Self {\n        CardState::Normal(state.into())\n    }\n}\n\nimpl From<LearnState> for CardState {\n    fn from(state: LearnState) -> Self {\n        CardState::Normal(state.into())\n    }\n}\n\nimpl From<RelearnState> for CardState {\n    fn from(state: RelearnState) -> Self {\n        CardState::Normal(state.into())\n    }\n}\n\nimpl From<NormalState> for CardState {\n    fn from(state: NormalState) -> Self {\n        CardState::Normal(state)\n    }\n}\n\nimpl From<PreviewState> for CardState {\n    fn from(state: PreviewState) -> Self {\n        CardState::Filtered(FilteredState::Preview(state))\n    }\n}\n\nimpl From<ReschedulingFilterState> for CardState {\n    fn from(state: ReschedulingFilterState) -> Self {\n        CardState::Filtered(FilteredState::Rescheduling(state))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn min_and_max_review_intervals() {\n        let mut ctx = StateContext::defaults_for_testing();\n        ctx.maximum_review_interval = 0;\n        assert_eq!(ctx.min_and_max_review_intervals(0), (1, 1));\n        assert_eq!(ctx.min_and_max_review_intervals(2), (1, 1));\n        ctx.maximum_review_interval = 3;\n        assert_eq!(ctx.min_and_max_review_intervals(0), (1, 3));\n        assert_eq!(ctx.min_and_max_review_intervals(2), (2, 3));\n        assert_eq!(ctx.min_and_max_review_intervals(4), (3, 3));\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/new.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::interval_kind::IntervalKind;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub struct NewState {\n    pub position: u32,\n}\n\nimpl NewState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        // todo: consider packing the due number in here; it would allow us to restore\n        // the original position of cards - though not as cheaply as if it were\n        // a card property.\n        IntervalKind::InSecs(0)\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        RevlogReviewKind::Learning\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/normal.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::interval_kind::IntervalKind;\nuse super::LearnState;\nuse super::NewState;\nuse super::RelearnState;\nuse super::ReviewState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum NormalState {\n    New(NewState),\n    Learning(LearnState),\n    Review(ReviewState),\n    Relearning(RelearnState),\n}\n\nimpl NormalState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        match self {\n            NormalState::New(state) => state.interval_kind(),\n            NormalState::Learning(state) => state.interval_kind(),\n            NormalState::Review(state) => state.interval_kind(),\n            NormalState::Relearning(state) => state.interval_kind(),\n        }\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        match self {\n            NormalState::New(state) => state.revlog_kind(),\n            NormalState::Learning(state) => state.revlog_kind(),\n            NormalState::Review(state) => state.revlog_kind(),\n            NormalState::Relearning(state) => state.revlog_kind(),\n        }\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        match self {\n            NormalState::New(_) => {\n                // New state acts like answering a failed learning card\n                let next_states = LearnState {\n                    remaining_steps: ctx.steps.remaining_for_failed(),\n                    scheduled_secs: 0,\n                    elapsed_secs: 0,\n                    memory_state: None,\n                }\n                .next_states(ctx);\n                // .. but with current as New, not Learning\n                SchedulingStates {\n                    current: self.into(),\n                    ..next_states\n                }\n            }\n            NormalState::Learning(state) => state.next_states(ctx),\n            NormalState::Review(state) => state.next_states(ctx),\n            NormalState::Relearning(state) => state.next_states(ctx),\n        }\n    }\n\n    pub(crate) fn review_state(self) -> Option<ReviewState> {\n        match self {\n            NormalState::New(_) => None,\n            NormalState::Learning(_) => None,\n            NormalState::Review(state) => Some(state),\n            NormalState::Relearning(RelearnState { review, .. }) => Some(review),\n        }\n    }\n\n    pub(crate) fn leeched(self) -> bool {\n        self.review_state().map(|r| r.leeched).unwrap_or_default()\n    }\n}\n\nimpl From<NewState> for NormalState {\n    fn from(state: NewState) -> Self {\n        NormalState::New(state)\n    }\n}\n\nimpl From<ReviewState> for NormalState {\n    fn from(state: ReviewState) -> Self {\n        NormalState::Review(state)\n    }\n}\n\nimpl From<LearnState> for NormalState {\n    fn from(state: LearnState) -> Self {\n        NormalState::Learning(state)\n    }\n}\n\nimpl From<RelearnState> for NormalState {\n    fn from(state: RelearnState) -> Self {\n        NormalState::Relearning(state)\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/preview_filter.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::CardState;\nuse super::IntervalKind;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct PreviewState {\n    pub scheduled_secs: u32,\n    pub finished: bool,\n}\n\nimpl PreviewState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        IntervalKind::InSecs(self.scheduled_secs)\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        RevlogReviewKind::Filtered\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        SchedulingStates {\n            current: self.into(),\n            again: delay_or_return(ctx.preview_delays.again),\n            hard: delay_or_return(ctx.preview_delays.hard),\n            good: delay_or_return(ctx.preview_delays.good),\n            easy: delay_or_return(0),\n        }\n    }\n}\n\nfn delay_or_return(seconds: u32) -> CardState {\n    if seconds == 0 {\n        PreviewState {\n            scheduled_secs: 0,\n            finished: true,\n        }\n    } else {\n        PreviewState {\n            scheduled_secs: seconds,\n            finished: false,\n        }\n    }\n    .into()\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/relearning.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::interval_kind::IntervalKind;\nuse super::CardState;\nuse super::LearnState;\nuse super::ReviewState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct RelearnState {\n    pub learning: LearnState,\n    pub review: ReviewState,\n}\n\nimpl RelearnState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        self.learning.interval_kind()\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        RevlogReviewKind::Relearning\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        SchedulingStates {\n            current: self.into(),\n            again: self.answer_again(ctx),\n            hard: self.answer_hard(ctx),\n            good: self.answer_good(ctx),\n            easy: self.answer_easy(ctx).into(),\n        }\n    }\n\n    fn answer_again(self, ctx: &StateContext) -> CardState {\n        let (scheduled_days, memory_state) = self.review.failing_review_interval(ctx);\n        if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() {\n            RelearnState {\n                learning: LearnState {\n                    remaining_steps: ctx.relearn_steps.remaining_for_failed(),\n                    scheduled_secs: again_delay,\n                    elapsed_secs: 0,\n                    memory_state,\n                },\n                review: ReviewState {\n                    scheduled_days: scheduled_days.round().max(1.0) as u32,\n                    elapsed_days: 0,\n                    memory_state,\n                    ..self.review\n                },\n            }\n            .into()\n        } else if let Some(states) = &ctx.fsrs_next_states {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let interval = states.again.interval;\n            let again_review = ReviewState {\n                scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum),\n                memory_state,\n                ..self.review\n            };\n            let again_relearn = RelearnState {\n                learning: LearnState {\n                    remaining_steps: ctx.relearn_steps.remaining_for_failed(),\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    elapsed_secs: 0,\n                    memory_state,\n                },\n                review: again_review,\n            };\n            if ctx.fsrs_allow_short_term\n                && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty())\n                && interval < 0.5\n            {\n                again_relearn.into()\n            } else {\n                again_review.into()\n            }\n        } else {\n            self.review.into()\n        }\n    }\n\n    fn answer_hard(self, ctx: &StateContext) -> CardState {\n        let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into());\n        if let Some(hard_delay) = ctx\n            .relearn_steps\n            .hard_delay_secs(self.learning.remaining_steps)\n        {\n            RelearnState {\n                learning: LearnState {\n                    scheduled_secs: hard_delay,\n                    memory_state,\n                    ..self.learning\n                },\n                review: ReviewState {\n                    elapsed_days: 0,\n                    memory_state,\n                    ..self.review\n                },\n            }\n            .into()\n        } else if let Some(states) = &ctx.fsrs_next_states {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let interval = states.hard.interval;\n            let hard_review = ReviewState {\n                scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum),\n                memory_state,\n                ..self.review\n            };\n            let hard_relearn = RelearnState {\n                learning: LearnState {\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    memory_state,\n                    ..self.learning\n                },\n                review: hard_review,\n            };\n            if ctx.fsrs_allow_short_term\n                && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty())\n                && interval < 0.5\n            {\n                hard_relearn.into()\n            } else {\n                hard_review.into()\n            }\n        } else {\n            self.review.into()\n        }\n    }\n\n    fn answer_good(self, ctx: &StateContext) -> CardState {\n        let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into());\n        if let Some(good_delay) = ctx\n            .relearn_steps\n            .good_delay_secs(self.learning.remaining_steps)\n        {\n            RelearnState {\n                learning: LearnState {\n                    scheduled_secs: good_delay,\n                    remaining_steps: ctx\n                        .relearn_steps\n                        .remaining_for_good(self.learning.remaining_steps),\n                    elapsed_secs: 0,\n                    memory_state,\n                },\n                review: ReviewState {\n                    elapsed_days: 0,\n                    memory_state,\n                    ..self.review\n                },\n            }\n            .into()\n        } else if let Some(states) = &ctx.fsrs_next_states {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let interval = states.good.interval;\n            let good_review = ReviewState {\n                scheduled_days: ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum),\n                memory_state,\n                ..self.review\n            };\n            let good_relearn = RelearnState {\n                learning: LearnState {\n                    scheduled_secs: (interval * 86_400.0) as u32,\n                    remaining_steps: ctx\n                        .relearn_steps\n                        .remaining_for_good(self.learning.remaining_steps),\n                    memory_state,\n                    ..self.learning\n                },\n                review: good_review,\n            };\n            if ctx.fsrs_allow_short_term\n                && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty())\n                && interval < 0.5\n            {\n                good_relearn.into()\n            } else {\n                good_review.into()\n            }\n        } else {\n            self.review.into()\n        }\n    }\n\n    fn answer_easy(self, ctx: &StateContext) -> ReviewState {\n        let scheduled_days = if let Some(states) = &ctx.fsrs_next_states {\n            let (mut minimum, maximum) = ctx.min_and_max_review_intervals(1);\n            let good = ctx.with_review_fuzz(states.good.interval, minimum, maximum);\n            minimum = good + 1;\n            let interval = states.easy.interval;\n            ctx.with_review_fuzz(interval.round().max(1.0), minimum, maximum)\n        } else {\n            self.review.scheduled_days + 1\n        };\n        ReviewState {\n            scheduled_days,\n            elapsed_days: 0,\n            memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),\n            ..self.review\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/rescheduling_filter.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::interval_kind::IntervalKind;\nuse super::normal::NormalState;\nuse super::CardState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::revlog::RevlogReviewKind;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct ReschedulingFilterState {\n    pub original_state: NormalState,\n}\n\nimpl ReschedulingFilterState {\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        self.original_state.interval_kind()\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        self.original_state.revlog_kind()\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        let normal = self.original_state.next_states(ctx);\n        if ctx.in_filtered_deck {\n            SchedulingStates {\n                current: self.into(),\n                again: maybe_wrap(normal.again),\n                hard: maybe_wrap(normal.hard),\n                good: maybe_wrap(normal.good),\n                easy: maybe_wrap(normal.easy),\n            }\n        } else {\n            // card is marked as filtered, but not in a filtered deck; convert to normal\n            normal\n        }\n    }\n}\n\n/// The review state is returned unchanged because cards are returned to\n/// their original deck in that state; other normal states are wrapped\n/// in the filtered state. Providing a filtered state is an error.\nfn maybe_wrap(state: CardState) -> CardState {\n    match state {\n        CardState::Normal(normal) => {\n            if matches!(normal, NormalState::Review(_)) {\n                normal.into()\n            } else {\n                ReschedulingFilterState {\n                    original_state: normal,\n                }\n                .into()\n            }\n        }\n        CardState::Filtered(_) => {\n            unreachable!()\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/review.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse fsrs::NextStates;\n\nuse super::interval_kind::IntervalKind;\nuse super::CardState;\nuse super::LearnState;\nuse super::RelearnState;\nuse super::SchedulingStates;\nuse super::StateContext;\nuse crate::card::FsrsMemoryState;\nuse crate::revlog::RevlogReviewKind;\n\npub const INITIAL_EASE_FACTOR: f32 = 2.5;\npub const MINIMUM_EASE_FACTOR: f32 = 1.3;\npub const EASE_FACTOR_AGAIN_DELTA: f32 = -0.2;\npub const EASE_FACTOR_HARD_DELTA: f32 = -0.15;\npub const EASE_FACTOR_EASY_DELTA: f32 = 0.15;\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct ReviewState {\n    pub scheduled_days: u32,\n    pub elapsed_days: u32,\n    pub ease_factor: f32,\n    pub lapses: u32,\n    pub leeched: bool,\n    pub memory_state: Option<FsrsMemoryState>,\n}\n\nimpl Default for ReviewState {\n    fn default() -> Self {\n        ReviewState {\n            scheduled_days: 0,\n            elapsed_days: 0,\n            ease_factor: INITIAL_EASE_FACTOR,\n            lapses: 0,\n            leeched: false,\n            memory_state: None,\n        }\n    }\n}\n\nimpl ReviewState {\n    pub(crate) fn days_late(&self) -> i32 {\n        self.elapsed_days as i32 - self.scheduled_days as i32\n    }\n\n    pub(crate) fn interval_kind(self) -> IntervalKind {\n        // fixme: maybe use elapsed days in the future? would only\n        // make sense for revlog's lastIvl, not for future interval\n        IntervalKind::InDays(self.scheduled_days)\n    }\n\n    pub(crate) fn revlog_kind(self) -> RevlogReviewKind {\n        if self.days_late() < 0 {\n            RevlogReviewKind::Filtered\n        } else {\n            RevlogReviewKind::Review\n        }\n    }\n\n    pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {\n        let (hard_interval, good_interval, easy_interval) = self.passing_review_intervals(ctx);\n\n        SchedulingStates {\n            current: self.into(),\n            again: self.answer_again(ctx),\n            hard: self.answer_hard(hard_interval, ctx).into(),\n            good: self.answer_good(good_interval, ctx).into(),\n            easy: self.answer_easy(easy_interval, ctx).into(),\n        }\n    }\n\n    pub(crate) fn failing_review_interval(\n        self,\n        ctx: &StateContext,\n    ) -> (f32, Option<FsrsMemoryState>) {\n        if let Some(states) = &ctx.fsrs_next_states {\n            // In FSRS, fuzz is applied when the card leaves the relearning\n            // stage\n            (states.again.interval, Some(states.again.memory.into()))\n        } else {\n            let (minimum, maximum) = ctx.min_and_max_review_intervals(ctx.minimum_lapse_interval);\n            let interval = ctx.with_review_fuzz(\n                (self.scheduled_days as f32).max(1.0) * ctx.lapse_multiplier,\n                minimum,\n                maximum,\n            );\n            (interval as f32, None)\n        }\n    }\n\n    fn answer_again(self, ctx: &StateContext) -> CardState {\n        let lapses = self.lapses + 1;\n        let leeched = leech_threshold_met(lapses, ctx.leech_threshold);\n        let (scheduled_days, memory_state) = self.failing_review_interval(ctx);\n        let again_review = ReviewState {\n            scheduled_days: scheduled_days.round().max(1.0) as u32,\n            elapsed_days: 0,\n            ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR),\n            lapses,\n            leeched,\n            memory_state,\n        };\n        let again_relearn = RelearnState {\n            learning: LearnState {\n                remaining_steps: ctx.relearn_steps.remaining_for_failed(),\n                scheduled_secs: (scheduled_days * 86_400.0) as u32,\n                elapsed_secs: 0,\n                memory_state,\n            },\n            review: again_review,\n        };\n\n        if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() {\n            RelearnState {\n                learning: LearnState {\n                    remaining_steps: ctx.relearn_steps.remaining_for_failed(),\n                    scheduled_secs: again_delay,\n                    elapsed_secs: 0,\n                    memory_state,\n                },\n                review: again_review,\n            }\n            .into()\n        } else if ctx.fsrs_allow_short_term\n            && (ctx.fsrs_short_term_with_steps_enabled || ctx.relearn_steps.is_empty())\n            && scheduled_days < 0.5\n        {\n            again_relearn.into()\n        } else {\n            again_review.into()\n        }\n    }\n\n    fn answer_hard(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {\n        ReviewState {\n            scheduled_days,\n            elapsed_days: 0,\n            ease_factor: (self.ease_factor + EASE_FACTOR_HARD_DELTA).max(MINIMUM_EASE_FACTOR),\n            memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),\n            ..self\n        }\n    }\n\n    fn answer_good(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {\n        ReviewState {\n            scheduled_days,\n            elapsed_days: 0,\n            memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into()),\n            ..self\n        }\n    }\n\n    fn answer_easy(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {\n        ReviewState {\n            scheduled_days,\n            elapsed_days: 0,\n            ease_factor: self.ease_factor + EASE_FACTOR_EASY_DELTA,\n            memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),\n            ..self\n        }\n    }\n\n    /// Return the intervals for hard, good and easy, each of which depends on\n    /// the previous.\n    fn passing_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) {\n        if let Some(states) = &ctx.fsrs_next_states {\n            self.passing_fsrs_review_intervals(ctx, states)\n        } else if self.days_late() < 0 {\n            self.passing_early_review_intervals(ctx)\n        } else {\n            self.passing_nonearly_review_intervals(ctx)\n        }\n    }\n\n    fn passing_fsrs_review_intervals(\n        self,\n        ctx: &StateContext,\n        states: &NextStates,\n    ) -> (u32, u32, u32) {\n        // If the interval is larger than last time, don't allow fuzz to go backwards\n        let greater_than_last = |interval: u32| {\n            if interval > self.scheduled_days {\n                self.scheduled_days + 1\n            } else {\n                // User may have changed their retention factor; don't limit\n                0\n            }\n        };\n        let hard = constrain_passing_interval(\n            ctx,\n            states.hard.interval,\n            greater_than_last(states.hard.interval.round() as u32).max(1),\n            true,\n        );\n        let good = constrain_passing_interval(\n            ctx,\n            states.good.interval,\n            greater_than_last(states.good.interval.round() as u32).max(hard + 1),\n            true,\n        );\n        let easy = constrain_passing_interval(\n            ctx,\n            states.easy.interval,\n            greater_than_last(states.easy.interval.round() as u32).max(good + 1),\n            true,\n        );\n        (hard, good, easy)\n    }\n\n    fn passing_nonearly_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) {\n        let current_interval = (self.scheduled_days as f32).max(1.0);\n        let days_late = self.days_late().max(0) as f32;\n\n        // hard\n        let hard_factor = ctx.hard_multiplier;\n        let hard_minimum = if hard_factor <= 1.0 {\n            0\n        } else {\n            self.scheduled_days + 1\n        };\n        let hard_interval =\n            constrain_passing_interval(ctx, current_interval * hard_factor, hard_minimum, true);\n        // good\n        let good_minimum = if hard_factor <= 1.0 {\n            self.scheduled_days + 1\n        } else {\n            hard_interval + 1\n        };\n        let good_interval = constrain_passing_interval(\n            ctx,\n            (current_interval + days_late / 2.0) * self.ease_factor,\n            good_minimum,\n            true,\n        );\n        // easy\n        let easy_interval = constrain_passing_interval(\n            ctx,\n            (current_interval + days_late) * self.ease_factor * ctx.easy_multiplier,\n            good_interval + 1,\n            true,\n        );\n\n        (hard_interval, good_interval, easy_interval)\n    }\n\n    /// Mostly direct port from the Python version for now, so we can confirm\n    /// implementation is correct.\n    /// FIXME: this needs reworking in the future; it overly penalizes reviews\n    /// done shortly before the due date.\n    fn passing_early_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) {\n        let scheduled = (self.scheduled_days as f32).max(1.0);\n        let elapsed = self.elapsed_days as f32;\n\n        let hard_interval = {\n            let factor = ctx.hard_multiplier;\n            let half_usual = factor / 2.0;\n            constrain_passing_interval(\n                ctx,\n                (elapsed * factor).max(scheduled * half_usual),\n                0,\n                false,\n            )\n        };\n\n        let good_interval =\n            constrain_passing_interval(ctx, (elapsed * self.ease_factor).max(scheduled), 0, false);\n\n        let easy_interval = {\n            let reduced_bonus = ctx.easy_multiplier - (ctx.easy_multiplier - 1.0) / 2.0;\n            constrain_passing_interval(\n                ctx,\n                (elapsed * self.ease_factor).max(scheduled) * reduced_bonus,\n                0,\n                false,\n            )\n        };\n\n        (hard_interval, good_interval, easy_interval)\n    }\n}\n\n/// True when lapses is at threshold, or every half threshold after that.\n/// Non-even thresholds round up the half threshold.\nfn leech_threshold_met(lapses: u32, threshold: u32) -> bool {\n    if threshold > 0 {\n        let half_threshold = (threshold as f32 / 2.0).ceil().max(1.0) as u32;\n        // at threshold, and every half threshold after that, rounding up\n        lapses >= threshold && (lapses - threshold) % half_threshold == 0\n    } else {\n        false\n    }\n}\n\n/// Transform the provided hard/good/easy interval.\n/// - Apply configured interval multiplier if not FSRS.\n/// - Apply fuzz.\n/// - Ensure it is at least `minimum`, and at least 1.\n/// - Ensure it is at or below the configured maximum interval.\nfn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, fuzz: bool) -> u32 {\n    let interval = if ctx.fsrs_next_states.is_some() {\n        interval\n    } else {\n        interval * ctx.interval_multiplier\n    };\n    let (minimum, maximum) = ctx.min_and_max_review_intervals(minimum);\n    if fuzz {\n        ctx.with_review_fuzz(interval, minimum, maximum)\n    } else {\n        (interval.round() as u32).clamp(minimum, maximum)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn leech_threshold() {\n        assert!(!leech_threshold_met(0, 3));\n        assert!(!leech_threshold_met(1, 3));\n        assert!(!leech_threshold_met(2, 3));\n        assert!(leech_threshold_met(3, 3));\n        assert!(!leech_threshold_met(4, 3));\n        assert!(leech_threshold_met(5, 3));\n        assert!(!leech_threshold_met(6, 3));\n        assert!(leech_threshold_met(7, 3));\n\n        assert!(!leech_threshold_met(7, 8));\n        assert!(leech_threshold_met(8, 8));\n        assert!(!leech_threshold_met(9, 8));\n        assert!(!leech_threshold_met(10, 8));\n        assert!(!leech_threshold_met(11, 8));\n        assert!(leech_threshold_met(12, 8));\n        assert!(!leech_threshold_met(13, 8));\n\n        // 0 means off\n        assert!(!leech_threshold_met(0, 0));\n\n        // no div by zero; half of 1 is 1\n        assert!(!leech_threshold_met(0, 1));\n        assert!(leech_threshold_met(1, 1));\n        assert!(leech_threshold_met(2, 1));\n        assert!(leech_threshold_met(3, 1));\n    }\n\n    #[test]\n    fn extreme_multiplier_fuzz() {\n        let mut ctx = StateContext::defaults_for_testing();\n        // our calculations should work correctly with a low ease or non-default\n        // multiplier\n        let state = ReviewState {\n            scheduled_days: 1,\n            elapsed_days: 1,\n            ease_factor: 1.3,\n            lapses: 0,\n            leeched: false,\n            memory_state: None,\n        };\n        ctx.fuzz_factor = Some(0.0);\n        assert_eq!(state.passing_review_intervals(&ctx), (2, 3, 4));\n\n        // this is a silly multiplier, but it shouldn't underflow\n        ctx.interval_multiplier = 0.1;\n        assert_eq!(state.passing_review_intervals(&ctx), (2, 3, 4));\n        ctx.fuzz_factor = Some(0.99);\n        assert_eq!(state.passing_review_intervals(&ctx), (2, 4, 6));\n\n        // maximum must be respected no matter what\n        ctx.interval_multiplier = 10.0;\n        ctx.maximum_review_interval = 5;\n        assert_eq!(state.passing_review_intervals(&ctx), (5, 5, 5));\n    }\n\n    #[test]\n    fn low_hard_multiplier_does_not_pull_good_down() {\n        let mut ctx = StateContext::defaults_for_testing();\n        // our calculations should work correctly with a low ease or non-default\n        // multiplier\n        ctx.hard_multiplier = 0.1;\n        let state = ReviewState {\n            scheduled_days: 2,\n            elapsed_days: 2,\n            ease_factor: 1.3,\n            lapses: 0,\n            leeched: false,\n            memory_state: None,\n        };\n        ctx.fuzz_factor = Some(0.0);\n        assert_eq!(state.passing_review_intervals(&ctx), (1, 3, 4));\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/states/steps.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nconst DAY: u32 = 60 * 60 * 24;\n\n#[derive(Clone, Copy, Debug, PartialEq)]\npub(crate) struct LearningSteps<'a> {\n    /// The steps in minutes.\n    steps: &'a [f32],\n}\n\nfn to_secs(v: f32) -> u32 {\n    (v * 60.0) as u32\n}\n\nimpl LearningSteps<'_> {\n    /// Takes `steps` as minutes.\n    pub(crate) fn new(steps: &[f32]) -> LearningSteps<'_> {\n        LearningSteps { steps }\n    }\n\n    /// Strip off 'learning today', and ensure index is in bounds.\n    fn get_index(self, remaining: u32) -> usize {\n        let total = self.steps.len();\n        total\n            .saturating_sub((remaining % 1000) as usize)\n            .min(total.saturating_sub(1))\n    }\n\n    fn secs_at_index(&self, index: usize) -> Option<u32> {\n        self.steps.get(index).copied().map(to_secs)\n    }\n\n    pub(crate) fn again_delay_secs_learn(&self) -> Option<u32> {\n        self.secs_at_index(0)\n    }\n\n    pub(crate) fn hard_delay_secs(self, remaining: u32) -> Option<u32> {\n        let idx = self.get_index(remaining);\n        self.secs_at_index(idx)\n            // if current is invalid, try first step\n            .or_else(|| self.steps.first().copied().map(to_secs))\n            .map(|current| {\n                if idx == 0 {\n                    self.hard_delay_secs_for_first_step(current)\n                } else {\n                    current\n                }\n            })\n    }\n\n    /// Special case the hard interval for the first step to avoid equality with\n    /// the again interval. Also ensure it's smaller than the good interval,\n    /// at least with reasonable settings.\n    fn hard_delay_secs_for_first_step(self, again_secs: u32) -> u32 {\n        if let Some(next) = self.secs_at_index(1) {\n            // average of first (again) and second (good) steps\n            maybe_round_in_days(again_secs.saturating_add(next) / 2)\n        } else {\n            // 50% more than the again secs, but at most one day more\n            // otherwise, a learning step of 3 days and a graduating interval of 4 days e.g.\n            // would lead to the hard interval being larger than the good interval\n            let secs = (again_secs.saturating_mul(3) / 2).min(again_secs.saturating_add(DAY));\n            maybe_round_in_days(secs)\n        }\n    }\n\n    pub(crate) fn good_delay_secs(self, remaining: u32) -> Option<u32> {\n        let idx = self.get_index(remaining);\n        self.secs_at_index(idx + 1)\n    }\n\n    pub(crate) fn current_delay_secs(self, remaining: u32) -> u32 {\n        let idx = self.get_index(remaining);\n        self.secs_at_index(idx).unwrap_or_default()\n    }\n\n    pub(crate) fn remaining_for_good(self, remaining: u32) -> u32 {\n        let idx = self.get_index(remaining);\n        self.steps.len().saturating_sub(idx + 1) as u32\n    }\n\n    pub(crate) fn remaining_for_failed(self) -> u32 {\n        self.steps.len() as u32\n    }\n\n    pub(crate) fn is_empty(&self) -> bool {\n        self.steps.is_empty()\n    }\n}\n\n/// If the given interval in seconds surpasses 1 day, rounds it to a whole\n/// number of days. Ensures that the user gets the same results earlier and\n/// later in the day. Returns seconds.\nfn maybe_round_in_days(secs: u32) -> u32 {\n    if secs > DAY {\n        ((secs as f32 / DAY as f32).round() as u32).saturating_mul(DAY)\n    } else {\n        secs\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    macro_rules! assert_delay_secs {\n        ($steps:expr, $remaining:expr, $again_delay:expr, $hard_delay:expr, $good_delay:expr) => {\n            let steps = LearningSteps::new(&$steps);\n            assert_eq!(steps.again_delay_secs_learn(), $again_delay);\n            assert_eq!(steps.hard_delay_secs($remaining), $hard_delay);\n            assert_eq!(steps.good_delay_secs($remaining), $good_delay);\n        };\n    }\n\n    #[test]\n    fn delay_secs() {\n        // if no other step, hard delay is 50% above again secs\n        assert_delay_secs!([10.0], 1, Some(600), Some(900), None);\n        // but at most one day more than again secs\n        assert_delay_secs!(\n            [(3 * DAY / 60) as f32],\n            1,\n            Some(3 * DAY),\n            Some(4 * DAY),\n            None\n        );\n\n        assert_delay_secs!([1.0, 10.0], 2, Some(60), Some(330), Some(600));\n        assert_delay_secs!([1.0, 10.0], 1, Some(60), Some(600), None);\n\n        assert_delay_secs!([1.0, 10.0, 100.0], 3, Some(60), Some(330), Some(600));\n        assert_delay_secs!([1.0, 10.0, 100.0], 2, Some(60), Some(600), Some(6000));\n        assert_delay_secs!([1.0, 10.0, 100.0], 1, Some(60), Some(6000), None);\n    }\n\n    #[test]\n    fn rounding_days() {\n        assert_eq!(maybe_round_in_days(DAY - 1), DAY - 1);\n        assert_eq!(maybe_round_in_days((1.5 * DAY as f32) as u32), 2 * DAY);\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/timespan.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_i18n::I18n;\n\n/// Short string like '4d' to place above answer buttons.\npub fn answer_button_time(seconds: f32, tr: &I18n) -> String {\n    let span = Timespan::from_secs(seconds).natural_span();\n    let amount = span.as_rounded_unit_for_answer_buttons();\n    match span.unit() {\n        TimespanUnit::Seconds => tr.scheduling_answer_button_time_seconds(amount),\n        TimespanUnit::Minutes => tr.scheduling_answer_button_time_minutes(amount),\n        TimespanUnit::Hours => tr.scheduling_answer_button_time_hours(amount),\n        TimespanUnit::Days => tr.scheduling_answer_button_time_days(amount),\n        TimespanUnit::Months => tr.scheduling_answer_button_time_months(amount),\n        TimespanUnit::Years => tr.scheduling_answer_button_time_years(amount),\n    }\n    .into()\n}\n\n/// Short string like '4d' to place above answer buttons.\n/// Times within the collapse time are represented like '<10m'\npub fn answer_button_time_collapsible(seconds: u32, collapse_secs: u32, tr: &I18n) -> String {\n    let string = answer_button_time(seconds as f32, tr);\n    if seconds == 0 {\n        tr.scheduling_end().into()\n    } else if seconds < collapse_secs {\n        format!(\"<{string}\")\n    } else {\n        string\n    }\n}\n\n/// Describe the given seconds using the largest appropriate unit.\n/// If precise is true, show to two decimal places, eg\n/// eg 70 seconds -> \"1.17 minutes\"\n/// If false, seconds and days are shown without decimals.\npub fn time_span(seconds: f32, tr: &I18n, precise: bool) -> String {\n    let span = Timespan::from_secs(seconds).natural_span();\n    let amount = if precise {\n        span.as_unit()\n    } else {\n        span.as_rounded_unit()\n    };\n    match span.unit() {\n        TimespanUnit::Seconds => tr.scheduling_time_span_seconds(amount),\n        TimespanUnit::Minutes => tr.scheduling_time_span_minutes(amount),\n        TimespanUnit::Hours => tr.scheduling_time_span_hours(amount),\n        TimespanUnit::Days => tr.scheduling_time_span_days(amount),\n        TimespanUnit::Months => tr.scheduling_time_span_months(amount),\n        TimespanUnit::Years => tr.scheduling_time_span_years(amount),\n    }\n    .into()\n}\n\nconst SECOND: f32 = 1.0;\nconst MINUTE: f32 = 60.0 * SECOND;\nconst HOUR: f32 = 60.0 * MINUTE;\nconst DAY: f32 = 24.0 * HOUR;\nconst YEAR: f32 = 365.0 * DAY;\nconst MONTH: f32 = YEAR / 12.0;\n\n#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]\npub(crate) enum TimespanUnit {\n    Seconds,\n    Minutes,\n    Hours,\n    Days,\n    Months,\n    Years,\n}\n\nimpl TimespanUnit {\n    pub fn as_str(self) -> &'static str {\n        match self {\n            TimespanUnit::Seconds => \"seconds\",\n            TimespanUnit::Minutes => \"minutes\",\n            TimespanUnit::Hours => \"hours\",\n            TimespanUnit::Days => \"days\",\n            TimespanUnit::Months => \"months\",\n            TimespanUnit::Years => \"years\",\n        }\n    }\n}\n\n#[derive(Clone, Copy)]\npub(crate) struct Timespan {\n    seconds: f32,\n    unit: TimespanUnit,\n}\n\nimpl Timespan {\n    pub fn from_secs(seconds: f32) -> Self {\n        Timespan {\n            seconds,\n            unit: TimespanUnit::Seconds,\n        }\n    }\n\n    /// Return the value as the configured unit, eg seconds=70/unit=Minutes\n    /// returns 1.17\n    pub fn as_unit(self) -> f32 {\n        let s = self.seconds;\n        match self.unit {\n            TimespanUnit::Seconds => s,\n            TimespanUnit::Minutes => s / MINUTE,\n            TimespanUnit::Hours => s / HOUR,\n            TimespanUnit::Days => s / DAY,\n            TimespanUnit::Months => s / MONTH,\n            TimespanUnit::Years => s / YEAR,\n        }\n    }\n\n    pub fn to_unit(self, unit: TimespanUnit) -> Timespan {\n        Timespan {\n            seconds: self.seconds,\n            unit,\n        }\n    }\n\n    /// Round seconds and days to integers, otherwise\n    /// truncates to one decimal place.\n    pub fn as_rounded_unit(self) -> f32 {\n        match self.unit {\n            // seconds/minutes/days as integer\n            TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(),\n            // other values shown to 1 decimal place\n            _ => (self.as_unit() * 10.0).round() / 10.0,\n        }\n    }\n\n    /// Round seconds, minutes and days to integers, otherwise\n    /// truncates to one decimal place.\n    pub fn as_rounded_unit_for_answer_buttons(self) -> f32 {\n        match self.unit {\n            // seconds/minutes/days as integer\n            TimespanUnit::Seconds | TimespanUnit::Minutes | TimespanUnit::Days => {\n                self.as_unit().round()\n            }\n            // other values shown to 1 decimal place\n            _ => (self.as_unit() * 10.0).round() / 10.0,\n        }\n    }\n\n    pub fn unit(self) -> TimespanUnit {\n        self.unit\n    }\n\n    /// Return a new timespan in the most appropriate unit, eg\n    /// 70 secs -> timespan in minutes\n    pub fn natural_span(self) -> Timespan {\n        let secs = self.seconds.abs();\n        let unit = if secs < MINUTE {\n            TimespanUnit::Seconds\n        } else if secs < HOUR {\n            TimespanUnit::Minutes\n        } else if secs < DAY {\n            TimespanUnit::Hours\n        } else if secs < MONTH {\n            TimespanUnit::Days\n        } else if secs < YEAR {\n            TimespanUnit::Months\n        } else {\n            TimespanUnit::Years\n        };\n\n        Timespan {\n            seconds: self.seconds,\n            unit,\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_i18n::I18n;\n\n    use crate::scheduler::timespan::answer_button_time;\n    use crate::scheduler::timespan::time_span;\n    use crate::scheduler::timespan::MONTH;\n\n    #[test]\n    fn answer_buttons() {\n        let tr = I18n::template_only();\n        assert_eq!(answer_button_time(30.0, &tr), \"30s\");\n        assert_eq!(answer_button_time(70.0, &tr), \"1m\");\n        assert_eq!(answer_button_time(1.1 * MONTH, &tr), \"1.1mo\");\n    }\n\n    #[test]\n    fn time_spans() {\n        let tr = I18n::template_only();\n        assert_eq!(time_span(1.0, &tr, false), \"1 second\");\n        assert_eq!(time_span(30.3, &tr, false), \"30 seconds\");\n        assert_eq!(time_span(30.3, &tr, true), \"30.3 seconds\");\n        assert_eq!(time_span(90.0, &tr, false), \"1.5 minutes\");\n        assert_eq!(time_span(45.0 * 86_400.0, &tr, false), \"1.5 months\");\n        assert_eq!(time_span(364.0 * 86_400.0, &tr, false), \"12 months\");\n        assert_eq!(time_span(364.0 * 86_400.0, &tr, true), \"11.97 months\");\n        assert_eq!(time_span(365.0 * 86_400.0, &tr, false), \"1 year\");\n        assert_eq!(time_span(365.0 * 86_400.0, &tr, true), \"1 year\");\n        assert_eq!(time_span(365.0 * 86_400.0 * 1.5, &tr, false), \"1.5 years\");\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/timing.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse chrono::DateTime;\nuse chrono::Datelike;\nuse chrono::Duration;\nuse chrono::FixedOffset;\nuse chrono::Timelike;\n\nuse crate::prelude::*;\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\npub struct SchedTimingToday {\n    pub now: TimestampSecs,\n    /// The number of days that have passed since the collection was created.\n    pub days_elapsed: u32,\n    /// Timestamp of the next day rollover.\n    pub next_day_at: TimestampSecs,\n}\n\n/// Timing information for the current day.\n/// - creation_secs is a UNIX timestamp of the collection creation time\n/// - creation_utc_offset is the UTC offset at collection creation time\n/// - current_secs is a timestamp of the current time\n/// - current_utc_offset is the current UTC offset\n/// - rollover_hour is the hour of the day the rollover happens (eg 4 for 4am)\npub fn sched_timing_today_v2_new(\n    creation_secs: TimestampSecs,\n    creation_utc_offset: FixedOffset,\n    current_secs: TimestampSecs,\n    current_utc_offset: FixedOffset,\n    rollover_hour: u8,\n) -> Result<SchedTimingToday> {\n    // get date(times) based on timezone offsets\n    let created_datetime = creation_secs.datetime(creation_utc_offset)?;\n    let now_datetime = current_secs.datetime(current_utc_offset)?;\n\n    // rollover\n    let rollover_today_datetime = rollover_datetime(now_datetime, rollover_hour);\n    let rollover_passed = rollover_today_datetime <= now_datetime;\n    let next_day_at = TimestampSecs(if rollover_passed {\n        (rollover_today_datetime + Duration::days(1)).timestamp()\n    } else {\n        rollover_today_datetime.timestamp()\n    });\n\n    // day count\n    let days_elapsed = days_elapsed(created_datetime, now_datetime, rollover_passed);\n\n    Ok(SchedTimingToday {\n        now: current_secs,\n        days_elapsed,\n        next_day_at,\n    })\n}\n\nfn rollover_datetime(date: DateTime<FixedOffset>, rollover_hour: u8) -> DateTime<FixedOffset> {\n    date.with_hour((rollover_hour % 24) as u32)\n        .unwrap()\n        .with_minute(0)\n        .unwrap()\n        .with_second(0)\n        .unwrap()\n        .with_nanosecond(0)\n        .unwrap()\n}\n\n/// The number of times the day rolled over between two dates.\nfn days_elapsed(\n    start_date: DateTime<FixedOffset>,\n    end_date: DateTime<FixedOffset>,\n    rollover_passed: bool,\n) -> u32 {\n    let days = end_date.num_days_from_ce() - start_date.num_days_from_ce();\n\n    // current day doesn't count before rollover time\n    let days = if rollover_passed { days } else { days - 1 };\n\n    // minimum of 0\n    days.max(0) as u32\n}\n\n/// Build a FixedOffset struct, capping minutes_west if out of bounds.\npub(crate) fn fixed_offset_from_minutes(minutes_west: i32) -> FixedOffset {\n    let bounded_minutes = minutes_west.clamp(-23 * 60, 23 * 60);\n    FixedOffset::west_opt(bounded_minutes * 60).unwrap()\n}\n\n/// For the given timestamp, return minutes west of UTC in the\n/// local timezone.\n/// eg, Australia at +10 hours is -600.\n/// Includes the daylight savings offset if applicable.\npub fn local_minutes_west_for_stamp(stamp: TimestampSecs) -> Result<i32> {\n    Ok(stamp.local_datetime()?.offset().utc_minus_local() / 60)\n}\n\npub(crate) fn v1_creation_date() -> i64 {\n    let now = TimestampSecs::now();\n    v1_creation_date_inner(now, local_minutes_west_for_stamp(now).unwrap())\n}\n\nfn v1_creation_date_inner(now: TimestampSecs, mins_west: i32) -> i64 {\n    let offset = fixed_offset_from_minutes(mins_west);\n    let now_dt = now.datetime(offset).unwrap();\n    let four_am_dt = rollover_datetime(now_dt, 4);\n    let four_am_stamp = four_am_dt.timestamp();\n\n    if four_am_dt > now_dt {\n        four_am_stamp - 86_400\n    } else {\n        four_am_stamp\n    }\n}\n\nfn sched_timing_today_v1(crt: TimestampSecs, now: TimestampSecs) -> SchedTimingToday {\n    let days_elapsed = (now.0 - crt.0) / 86_400;\n    let next_day_at = TimestampSecs(crt.0 + (days_elapsed + 1) * 86_400);\n    SchedTimingToday {\n        now,\n        days_elapsed: days_elapsed as u32,\n        next_day_at,\n    }\n}\n\nfn sched_timing_today_v2_legacy(\n    crt: TimestampSecs,\n    rollover: u8,\n    now: TimestampSecs,\n    current_utc_offset: FixedOffset,\n) -> Result<SchedTimingToday> {\n    let crt_at_rollover =\n        rollover_datetime(crt.datetime(current_utc_offset)?, rollover).timestamp();\n    let days_elapsed = (now.0 - crt_at_rollover) / 86_400;\n\n    let mut next_day_at =\n        TimestampSecs(rollover_datetime(now.datetime(current_utc_offset)?, rollover).timestamp());\n    if next_day_at < now {\n        next_day_at = next_day_at.adding_secs(86_400);\n    }\n\n    Ok(SchedTimingToday {\n        now,\n        days_elapsed: days_elapsed as u32,\n        next_day_at,\n    })\n}\n\n// ----------------------------------\n\n/// Decide which scheduler timing to use based on the provided input,\n/// and return the relevant timing info.\npub(crate) fn sched_timing_today(\n    creation_secs: TimestampSecs,\n    current_secs: TimestampSecs,\n    creation_utc_offset: Option<FixedOffset>,\n    current_utc_offset: FixedOffset,\n    rollover_hour: Option<u8>,\n) -> Result<SchedTimingToday> {\n    match (rollover_hour, creation_utc_offset) {\n        (None, _) => {\n            // if rollover unset, v1 scheduler\n            Ok(sched_timing_today_v1(creation_secs, current_secs))\n        }\n        (Some(rollover), None) => {\n            // if creationOffset unset, v2 scheduler with legacy cutoff handling\n            sched_timing_today_v2_legacy(creation_secs, rollover, current_secs, current_utc_offset)\n        }\n        (Some(rollover), Some(creation_utc_offset)) => {\n            // v2 scheduler, new cutoff handling\n            sched_timing_today_v2_new(\n                creation_secs,\n                creation_utc_offset,\n                current_secs,\n                current_utc_offset,\n                rollover,\n            )\n        }\n    }\n}\n\n/// True if provided due number looks like a seconds-based timestamp.\npub fn is_unix_epoch_timestamp(due: i32) -> bool {\n    due > 1_000_000_000\n}\n\n#[cfg(test)]\nmod test {\n    use chrono::FixedOffset;\n    use chrono::Local;\n    use chrono::TimeZone;\n\n    use super::*;\n\n    // test helper\n    impl SchedTimingToday {\n        /// Check if less than 25 minutes until the rollover\n        pub fn near_cutoff(&self) -> bool {\n            let near = TimestampSecs::now().adding_secs(60 * 25) > self.next_day_at;\n            if near {\n                println!(\"this would fail near the rollover time\");\n            }\n            near\n        }\n    }\n\n    // static timezone for tests\n    const AEST_MINS_WEST: i32 = -600;\n\n    fn aest_offset() -> FixedOffset {\n        FixedOffset::west_opt(AEST_MINS_WEST * 60).unwrap()\n    }\n\n    #[test]\n    fn fixed_offset() {\n        let offset = fixed_offset_from_minutes(AEST_MINS_WEST);\n        assert_eq!(offset.utc_minus_local(), AEST_MINS_WEST * 60);\n    }\n\n    // helper\n    fn elap(start: i64, end: i64, start_west: i32, end_west: i32, rollhour: u8) -> u32 {\n        let start = TimestampSecs(start);\n        let end = TimestampSecs(end);\n        let start_west = FixedOffset::west_opt(start_west * 60).unwrap();\n        let end_west = FixedOffset::west_opt(end_west * 60).unwrap();\n        let today = sched_timing_today_v2_new(start, start_west, end, end_west, rollhour).unwrap();\n        today.days_elapsed\n    }\n\n    #[test]\n    fn days_elapsed() {\n        let local_offset = local_minutes_west_for_stamp(TimestampSecs::now()).unwrap();\n\n        let created_dt = FixedOffset::west_opt(local_offset * 60)\n            .unwrap()\n            .with_ymd_and_hms(2019, 12, 1, 2, 0, 0)\n            .latest()\n            .unwrap();\n        let crt = created_dt.timestamp();\n\n        // days can't be negative\n        assert_eq!(elap(crt, crt, local_offset, local_offset, 4), 0);\n        assert_eq!(elap(crt, crt - 86_400, local_offset, local_offset, 4), 0);\n\n        // 2am the next day is still the same day\n        assert_eq!(elap(crt, crt + 24 * 3600, local_offset, local_offset, 4), 0);\n\n        // day rolls over at 4am\n        assert_eq!(elap(crt, crt + 26 * 3600, local_offset, local_offset, 4), 1);\n\n        // the longest extra delay is +23, or 19 hours past the 4 hour default\n        assert_eq!(\n            elap(crt, crt + (26 + 18) * 3600, local_offset, local_offset, 23),\n            0\n        );\n        assert_eq!(\n            elap(crt, crt + (26 + 19) * 3600, local_offset, local_offset, 23),\n            1\n        );\n\n        let mdt = FixedOffset::west_opt(6 * 60 * 60).unwrap();\n        let mdt_offset = mdt.utc_minus_local() / 60;\n        let mst = FixedOffset::west_opt(7 * 60 * 60).unwrap();\n        let mst_offset = mst.utc_minus_local() / 60;\n\n        // a collection created @ midnight in MDT in the past\n        let crt = mdt\n            .with_ymd_and_hms(2018, 8, 6, 0, 0, 0)\n            .latest()\n            .unwrap()\n            .timestamp();\n        // with the current time being MST\n        let now = mst\n            .with_ymd_and_hms(2019, 12, 26, 20, 0, 0)\n            .latest()\n            .unwrap()\n            .timestamp();\n        assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 507);\n        // the previous implementation generated a different elapsed number of days with\n        // a change to DST, but the number shouldn't change\n        assert_eq!(elap(crt, now, mdt_offset, mdt_offset, 4), 507);\n\n        // collection created at 3am on the 6th, so day 1 starts at 4am on the 7th, and\n        // day 3 on the 9th.\n        let crt = mdt\n            .with_ymd_and_hms(2018, 8, 6, 3, 0, 0)\n            .latest()\n            .unwrap()\n            .timestamp();\n        let now = mst\n            .with_ymd_and_hms(2018, 8, 9, 1, 59, 59)\n            .latest()\n            .unwrap()\n            .timestamp();\n        assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2);\n        let now = mst\n            .with_ymd_and_hms(2018, 8, 9, 3, 59, 59)\n            .latest()\n            .unwrap()\n            .timestamp();\n        assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2);\n        let now = mst\n            .with_ymd_and_hms(2018, 8, 9, 4, 0, 0)\n            .latest()\n            .unwrap()\n            .timestamp();\n        assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 3);\n\n        // try a bunch of combinations of creation time, current time, and rollover hour\n        let hours_of_interest = &[0, 1, 4, 12, 22, 23];\n        for creation_hour in hours_of_interest {\n            let crt_dt = mdt\n                .with_ymd_and_hms(2018, 8, 6, *creation_hour, 0, 0)\n                .latest()\n                .unwrap();\n            let crt_stamp = crt_dt.timestamp();\n            let crt_offset = mdt_offset;\n\n            for current_day in 0..=3 {\n                for current_hour in hours_of_interest {\n                    for rollover_hour in hours_of_interest {\n                        let end_dt = mdt\n                            .with_ymd_and_hms(2018, 8, 6 + current_day, *current_hour, 0, 0)\n                            .latest()\n                            .unwrap();\n                        let end_stamp = end_dt.timestamp();\n                        let end_offset = mdt_offset;\n                        let elap_day = if *current_hour < *rollover_hour {\n                            current_day.max(1) - 1\n                        } else {\n                            current_day\n                        };\n\n                        assert_eq!(\n                            elap(\n                                crt_stamp,\n                                end_stamp,\n                                crt_offset,\n                                end_offset,\n                                *rollover_hour as u8\n                            ),\n                            elap_day\n                        );\n                    }\n                }\n            }\n        }\n    }\n\n    #[test]\n    fn next_day_at() {\n        let rollhour = 4;\n        let crt = Local\n            .with_ymd_and_hms(2019, 1, 1, 2, 0, 0)\n            .latest()\n            .unwrap();\n\n        // before the rollover, the next day should be later on the same day\n        let now = Local\n            .with_ymd_and_hms(2019, 1, 3, 2, 0, 0)\n            .latest()\n            .unwrap();\n        let next_day_at = Local\n            .with_ymd_and_hms(2019, 1, 3, rollhour, 0, 0)\n            .latest()\n            .unwrap();\n        let today = sched_timing_today_v2_new(\n            TimestampSecs(crt.timestamp()),\n            *crt.offset(),\n            TimestampSecs(now.timestamp()),\n            *now.offset(),\n            rollhour as u8,\n        )\n        .unwrap();\n        assert_eq!(today.next_day_at.0, next_day_at.timestamp());\n\n        // after the rollover, the next day should be the next day\n        let now = Local\n            .with_ymd_and_hms(2019, 1, 3, rollhour, 0, 0)\n            .latest()\n            .unwrap();\n        let next_day_at = Local\n            .with_ymd_and_hms(2019, 1, 4, rollhour, 0, 0)\n            .latest()\n            .unwrap();\n        let today = sched_timing_today_v2_new(\n            TimestampSecs(crt.timestamp()),\n            *crt.offset(),\n            TimestampSecs(now.timestamp()),\n            *now.offset(),\n            rollhour as u8,\n        )\n        .unwrap();\n        assert_eq!(today.next_day_at.0, next_day_at.timestamp());\n\n        // after the rollover, the next day should be the next day\n        let now = Local\n            .with_ymd_and_hms(2019, 1, 3, rollhour + 3, 0, 0)\n            .latest()\n            .unwrap();\n        let next_day_at = Local\n            .with_ymd_and_hms(2019, 1, 4, rollhour, 0, 0)\n            .latest()\n            .unwrap();\n        let today = sched_timing_today_v2_new(\n            TimestampSecs(crt.timestamp()),\n            *crt.offset(),\n            TimestampSecs(now.timestamp()),\n            *now.offset(),\n            rollhour as u8,\n        )\n        .unwrap();\n        assert_eq!(today.next_day_at.0, next_day_at.timestamp());\n    }\n\n    #[test]\n    fn legacy_timing() {\n        let now = TimestampSecs(1584491078);\n\n        assert_eq!(\n            sched_timing_today_v1(TimestampSecs(1575226800), now),\n            SchedTimingToday {\n                now,\n                days_elapsed: 107,\n                next_day_at: TimestampSecs(1584558000)\n            }\n        );\n\n        assert_eq!(\n            sched_timing_today_v2_legacy(TimestampSecs(1533564000), 0, now, aest_offset()),\n            Ok(SchedTimingToday {\n                now,\n                days_elapsed: 589,\n                next_day_at: TimestampSecs(1584540000)\n            })\n        );\n\n        assert_eq!(\n            sched_timing_today_v2_legacy(TimestampSecs(1524038400), 4, now, aest_offset()),\n            Ok(SchedTimingToday {\n                now,\n                days_elapsed: 700,\n                next_day_at: TimestampSecs(1584554400)\n            })\n        );\n    }\n\n    #[test]\n    fn legacy_creation_stamp() {\n        let offset = fixed_offset_from_minutes(AEST_MINS_WEST);\n\n        let now = TimestampSecs(\n            offset\n                .with_ymd_and_hms(2020, 5, 10, 9, 30, 30)\n                .latest()\n                .unwrap()\n                .timestamp(),\n        );\n        assert_eq!(\n            v1_creation_date_inner(now, AEST_MINS_WEST),\n            offset\n                .with_ymd_and_hms(2020, 5, 10, 4, 0, 0)\n                .latest()\n                .unwrap()\n                .timestamp()\n        );\n\n        let now = TimestampSecs(\n            offset\n                .with_ymd_and_hms(2020, 5, 10, 1, 30, 30)\n                .latest()\n                .unwrap()\n                .timestamp(),\n        );\n        assert_eq!(\n            v1_creation_date_inner(now, AEST_MINS_WEST),\n            offset\n                .with_ymd_and_hms(2020, 5, 9, 4, 0, 0)\n                .latest()\n                .unwrap()\n                .timestamp()\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/scheduler/upgrade.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse super::timing::local_minutes_west_for_stamp;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::config::SchedulerVersion;\nuse crate::prelude::*;\nuse crate::search::SortMode;\n\nstruct V1FilteredDeckInfo {\n    /// True if the filtered deck had rescheduling enabled.\n    reschedule: bool,\n    /// If the filtered deck had custom steps enabled, `original_step_count`\n    /// contains the step count of the home deck, which will be used to ensure\n    /// the remaining steps of the card are not out of bounds.\n    original_step_count: Option<u32>,\n}\n\nimpl Card {\n    /// Update relearning cards and cards in filtered decks.\n    /// `filtered_info` should be provided if card is in a filtered deck.\n    fn upgrade_to_v2(&mut self, filtered_info: Option<V1FilteredDeckInfo>) {\n        // relearning cards have their own type\n        if self.ctype == CardType::Review\n            && matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn)\n        {\n            self.ctype = CardType::Relearn;\n        }\n\n        // filtered deck handling\n        if let Some(info) = filtered_info {\n            // cap remaining count to home deck\n            if let Some(step_count) = info.original_step_count {\n                self.remaining_steps = self.remaining_steps.min(step_count);\n            }\n\n            if info.reschedule {\n                // only new cards should be in the new queue\n                if self.queue == CardQueue::New && self.ctype != CardType::New {\n                    self.restore_queue_from_type();\n                }\n            } else {\n                // preview cards start in the review queue in v2\n                if self.queue == CardQueue::New {\n                    self.queue = CardQueue::Review;\n                }\n\n                // to ensure learning cards are reset to new on exit, we must\n                // make them new now\n                if self.ctype == CardType::Learn {\n                    self.queue = CardQueue::PreviewRepeat;\n                    self.ctype = CardType::New;\n                }\n            }\n        }\n    }\n}\n\nfn get_filter_info_for_card(\n    card: &Card,\n    decks: &HashMap<DeckId, Deck>,\n    configs: &HashMap<DeckConfigId, DeckConfig>,\n) -> Option<V1FilteredDeckInfo> {\n    if card.original_deck_id.0 == 0 {\n        None\n    } else {\n        let (had_custom_steps, reschedule) = if let Some(deck) = decks.get(&card.deck_id) {\n            if let DeckKind::Filtered(filtered) = &deck.kind {\n                (!filtered.delays.is_empty(), filtered.reschedule)\n            } else {\n                // not a filtered deck, give up\n                return None;\n            }\n        } else {\n            // missing filtered deck, give up\n            return None;\n        };\n\n        let original_step_count = if had_custom_steps {\n            let home_conf_id = decks\n                .get(&card.original_deck_id)\n                .and_then(|deck| deck.config_id())\n                .unwrap_or(DeckConfigId(1));\n            Some(\n                configs\n                    .get(&home_conf_id)\n                    .map(|config| {\n                        if card.ctype == CardType::Review {\n                            config.inner.relearn_steps.len()\n                        } else {\n                            config.inner.learn_steps.len()\n                        }\n                    })\n                    .unwrap_or(0) as u32,\n            )\n        } else {\n            None\n        };\n\n        Some(V1FilteredDeckInfo {\n            reschedule,\n            original_step_count,\n        })\n    }\n}\n\nimpl Collection {\n    /// Expects an existing transaction. No-op if already on v2.\n    pub(crate) fn upgrade_to_v2_scheduler(&mut self) -> Result<()> {\n        if self.scheduler_version() == SchedulerVersion::V2 {\n            // nothing to do\n            return Ok(());\n        }\n        self.storage.upgrade_revlog_to_v2()?;\n        self.upgrade_cards_to_v2()?;\n        self.set_scheduler_version_config_key(SchedulerVersion::V2)?;\n\n        // enable new timezone code by default\n        let created = self.storage.creation_stamp()?;\n        if self.get_creation_utc_offset().is_none() {\n            self.set_creation_utc_offset(Some(local_minutes_west_for_stamp(created)?))?;\n        }\n\n        // force full sync\n        self.set_schema_modified()\n    }\n\n    fn upgrade_cards_to_v2(&mut self) -> Result<()> {\n        let guard = self.search_cards_into_table(\n            // can't add 'is:learn' here, as it matches on card type, not card queue\n            \"deck:filtered OR is:review\",\n            SortMode::NoOrder,\n        )?;\n        if guard.cards > 0 {\n            let decks = guard.col.storage.get_decks_map()?;\n            let configs = guard.col.storage.get_deck_config_map()?;\n            guard.col.storage.for_each_card_in_search(|mut card| {\n                let filtered_info = get_filter_info_for_card(&card, &decks, &configs);\n                card.upgrade_to_v2(filtered_info);\n                guard.col.storage.update_card(&card)\n            })?;\n        }\n        Ok(())\n    }\n}\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn v2_card() {\n        let mut c = Card {\n            ctype: CardType::Review,\n            queue: CardQueue::DayLearn,\n            ..Default::default()\n        };\n        // relearning cards should be reclassified\n        c.upgrade_to_v2(None);\n        assert_eq!(c.ctype, CardType::Relearn);\n\n        // check step capping\n        c.remaining_steps = 5005;\n        c.upgrade_to_v2(Some(V1FilteredDeckInfo {\n            reschedule: true,\n            original_step_count: Some(2),\n        }));\n        assert_eq!(c.remaining_steps, 2);\n\n        // with rescheduling off, relearning cards don't need changing\n        c.upgrade_to_v2(Some(V1FilteredDeckInfo {\n            reschedule: false,\n            original_step_count: None,\n        }));\n        assert_eq!(c.ctype, CardType::Relearn);\n        assert_eq!(c.queue, CardQueue::DayLearn);\n\n        // but learning cards are reset to new\n        c.ctype = CardType::Learn;\n        c.upgrade_to_v2(Some(V1FilteredDeckInfo {\n            reschedule: false,\n            original_step_count: None,\n        }));\n        assert_eq!(c.ctype, CardType::New);\n        assert_eq!(c.queue, CardQueue::PreviewRepeat);\n\n        // (early) reviews should be moved back from the new queue\n        c.ctype = CardType::Review;\n        c.queue = CardQueue::New;\n        c.upgrade_to_v2(Some(V1FilteredDeckInfo {\n            reschedule: true,\n            original_step_count: None,\n        }));\n        assert_eq!(c.ctype, CardType::Review);\n        assert_eq!(c.queue, CardQueue::Review);\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/builder.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::mem;\n\nuse itertools::Itertools;\n\nuse super::writer::write_nodes;\nuse super::FieldSearchMode;\nuse super::Node;\nuse super::SearchNode;\nuse super::StateKind;\nuse super::TemplateKind;\nuse crate::prelude::*;\nuse crate::storage::comma_separated_ids;\nuse crate::text::escape_anki_wildcards_for_search_node;\n\npub trait Negated {\n    fn negated(self) -> Node;\n}\n\npub trait JoinSearches {\n    /// Concatenates two sets of [Node]s, inserting [Node::And], and grouping,\n    /// if appropriate.\n    fn and(self, other: impl Into<SearchBuilder>) -> SearchBuilder;\n    /// Concatenates two sets of [Node]s, inserting [Node::Or], and grouping, if\n    /// appropriate.\n    fn or(self, other: impl Into<SearchBuilder>) -> SearchBuilder;\n    /// Concatenates two sets of [Node]s, inserting [Node::And] if appropriate,\n    /// but without grouping either set.\n    fn and_flat(self, other: impl Into<SearchBuilder>) -> SearchBuilder;\n    /// Concatenates two sets of [Node]s, inserting [Node::Or] if appropriate,\n    /// but without grouping either set.\n    fn or_flat(self, other: impl Into<SearchBuilder>) -> SearchBuilder;\n}\n\nimpl<T: Into<Node>> Negated for T {\n    fn negated(self) -> Node {\n        let node: Node = self.into();\n        if let Node::Not(inner) = node {\n            *inner\n        } else {\n            Node::Not(Box::new(node))\n        }\n    }\n}\n\nimpl<T: Into<SearchBuilder>> JoinSearches for T {\n    fn and(self, other: impl Into<SearchBuilder>) -> SearchBuilder {\n        self.into().join_other(other.into(), Node::And, true)\n    }\n\n    fn or(self, other: impl Into<SearchBuilder>) -> SearchBuilder {\n        self.into().join_other(other.into(), Node::Or, true)\n    }\n\n    fn and_flat(self, other: impl Into<SearchBuilder>) -> SearchBuilder {\n        self.into().join_other(other.into(), Node::And, false)\n    }\n\n    fn or_flat(self, other: impl Into<SearchBuilder>) -> SearchBuilder {\n        self.into().join_other(other.into(), Node::Or, false)\n    }\n}\n\n/// Helper to programmatically build searches.\n#[derive(Debug, PartialEq, Clone)]\npub struct SearchBuilder(Vec<Node>);\n\nimpl SearchBuilder {\n    pub fn new() -> Self {\n        Self(vec![])\n    }\n\n    /// Construct [SearchBuilder] with this [Node], or its inner [Node]s,\n    /// if it is a [Node::Group]\n    pub fn from_root(node: Node) -> Self {\n        match node {\n            Node::Group(nodes) => Self(nodes),\n            _ => Self(vec![node]),\n        }\n    }\n\n    /// Construct [SearchBuilder] where given [Node]s are joined by [Node::And].\n    pub fn all(iter: impl IntoIterator<Item = impl Into<Node>>) -> Self {\n        Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::And).collect())\n    }\n\n    /// Construct [SearchBuilder] where given [Node]s are joined by [Node::Or].\n    pub fn any(iter: impl IntoIterator<Item = impl Into<Node>>) -> Self {\n        Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::Or).collect())\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.0.is_empty()\n    }\n\n    pub fn len(&self) -> usize {\n        self.0.len()\n    }\n\n    fn join_other(mut self, mut other: Self, joiner: Node, group: bool) -> Self {\n        if group {\n            self = self.group();\n            other = other.group();\n        }\n        if !(self.is_empty() || other.is_empty()) {\n            self.0.push(joiner);\n        }\n        self.0.append(&mut other.0);\n        self\n    }\n\n    /// Wrap [Node]s in [Node::Group] if there is more than 1.\n    pub fn group(mut self) -> Self {\n        if self.len() > 1 {\n            self.0 = vec![Node::Group(mem::take(&mut self.0))];\n        }\n        self\n    }\n\n    pub fn write(&self) -> String {\n        write_nodes(&self.0)\n    }\n\n    /// Construct [SearchBuilder] matching any given deck, excluding children.\n    pub fn from_decks(decks: &[DeckId]) -> Self {\n        SearchNode::DeckIdsWithoutChildren(comma_separated_ids(decks)).into()\n    }\n\n    /// Construct [SearchBuilder] matching learning, but not relearning cards.\n    pub fn learning_cards() -> Self {\n        StateKind::Learning.and(StateKind::Review.negated())\n    }\n\n    /// Construct [SearchBuilder] matching relearning cards.\n    pub fn relearning_cards() -> Self {\n        StateKind::Learning.and(StateKind::Review)\n    }\n}\n\nimpl<T: Into<Node>> From<T> for SearchBuilder {\n    fn from(node: T) -> Self {\n        Self(vec![node.into()])\n    }\n}\n\nimpl TryIntoSearch for SearchBuilder {\n    fn try_into_search(self) -> Result<Node, AnkiError> {\n        Ok(self.group().0.remove(0))\n    }\n}\n\nimpl Default for SearchBuilder {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl SearchNode {\n    pub fn from_deck_id(did: impl Into<DeckId>, with_children: bool) -> Self {\n        if with_children {\n            Self::DeckIdWithChildren(did.into())\n        } else {\n            Self::DeckIdsWithoutChildren(did.into().to_string())\n        }\n    }\n\n    /// Construct [SearchNode] from an unescaped deck name.\n    pub fn from_deck_name(name: &str) -> Self {\n        Self::Deck(escape_anki_wildcards_for_search_node(name))\n    }\n\n    /// Construct [SearchNode] from an unescaped tag name.\n    pub fn from_tag_name(name: &str) -> Self {\n        Self::Tag {\n            tag: escape_anki_wildcards_for_search_node(name),\n            mode: FieldSearchMode::Normal,\n        }\n    }\n\n    /// Construct [SearchNode] from an unescaped notetype name.\n    pub fn from_notetype_name(name: &str) -> Self {\n        Self::Notetype(escape_anki_wildcards_for_search_node(name))\n    }\n\n    /// Construct [SearchNode] from an unescaped template name.\n    pub fn from_template_name(name: &str) -> Self {\n        Self::CardTemplate(TemplateKind::Name(escape_anki_wildcards_for_search_node(\n            name,\n        )))\n    }\n\n    pub fn from_note_ids<I: IntoIterator<Item = N>, N: Into<NoteId>>(ids: I) -> Self {\n        Self::NoteIds(ids.into_iter().map(Into::into).join(\",\"))\n    }\n\n    pub fn from_card_ids<I: IntoIterator<Item = C>, C: Into<CardId>>(ids: I) -> Self {\n        Self::CardIds(ids.into_iter().map(Into::into).join(\",\"))\n    }\n}\n\nimpl<T: Into<SearchNode>> From<T> for Node {\n    fn from(node: T) -> Self {\n        Self::Search(node.into())\n    }\n}\n\nimpl From<NotetypeId> for SearchNode {\n    fn from(id: NotetypeId) -> Self {\n        SearchNode::NotetypeId(id)\n    }\n}\n\nimpl From<TemplateKind> for SearchNode {\n    fn from(k: TemplateKind) -> Self {\n        SearchNode::CardTemplate(k)\n    }\n}\n\nimpl From<NoteId> for SearchNode {\n    fn from(n: NoteId) -> Self {\n        SearchNode::NoteIds(format!(\"{n}\"))\n    }\n}\n\nimpl From<StateKind> for SearchNode {\n    fn from(k: StateKind) -> Self {\n        SearchNode::State(k)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn negating() {\n        let node = Node::Search(SearchNode::UnqualifiedText(\"foo\".to_string()));\n        let neg_node = Node::Not(Box::new(Node::Search(SearchNode::UnqualifiedText(\n            \"foo\".to_string(),\n        ))));\n        assert_eq!(node.clone().negated(), neg_node);\n        assert_eq!(node.clone().negated().negated(), node);\n\n        assert_eq!(\n            StateKind::Due.negated(),\n            Node::Not(Box::new(Node::Search(SearchNode::State(StateKind::Due))))\n        )\n    }\n\n    #[test]\n    fn joining() {\n        assert_eq!(\n            StateKind::Due\n                .or(StateKind::New)\n                .and(SearchBuilder::any((1..4).map(SearchNode::Flag)))\n                .write(),\n            \"(is:due OR is:new) (flag:1 OR flag:2 OR flag:3)\"\n        );\n        assert_eq!(\n            StateKind::Due\n                .or(StateKind::New)\n                .and_flat(SearchBuilder::any((1..4).map(SearchNode::Flag)))\n                .write(),\n            \"is:due OR is:new flag:1 OR flag:2 OR flag:3\"\n        );\n        assert_eq!(\n            StateKind::Due\n                .or(StateKind::New)\n                .or(StateKind::Learning)\n                .or(StateKind::Review)\n                .write(),\n            \"((is:due OR is:new) OR is:learn) OR is:review\"\n        );\n        assert_eq!(\n            StateKind::Due\n                .or_flat(StateKind::New)\n                .or_flat(StateKind::Learning)\n                .or_flat(StateKind::Review)\n                .write(),\n            \"is:due OR is:new OR is:learn OR is:review\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/card_mod_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nGROUP BY nid\nORDER BY MAX(mod);"
  },
  {
    "path": "rslib/src/search/deck_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  did integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (did)\nSELECT id\nFROM decks\nORDER BY name;"
  },
  {
    "path": "rslib/src/search/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod builder;\nmod parser;\nmod service;\nmod sqlwriter;\npub(crate) mod writer;\n\nuse std::borrow::Cow;\n\npub use builder::JoinSearches;\npub use builder::Negated;\npub use builder::SearchBuilder;\npub use parser::parse as parse_search;\npub use parser::FieldSearchMode;\npub use parser::Node;\npub use parser::PropertyKind;\npub use parser::RatingKind;\npub use parser::SearchNode;\npub use parser::StateKind;\npub use parser::TemplateKind;\nuse rusqlite::params_from_iter;\nuse rusqlite::types::FromSql;\nuse sqlwriter::RequiredTable;\nuse sqlwriter::SqlWriter;\npub use writer::replace_search_node;\n\nuse crate::browser_table::Column;\nuse crate::card::CardType;\nuse crate::prelude::*;\nuse crate::scheduler::timing::SchedTimingToday;\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\npub enum ReturnItemType {\n    Cards,\n    Notes,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub enum SortMode {\n    NoOrder,\n    Builtin { column: Column, reverse: bool },\n    Custom(String),\n}\n\npub trait AsReturnItemType {\n    fn as_return_item_type() -> ReturnItemType;\n}\n\nimpl AsReturnItemType for CardId {\n    fn as_return_item_type() -> ReturnItemType {\n        ReturnItemType::Cards\n    }\n}\n\nimpl AsReturnItemType for NoteId {\n    fn as_return_item_type() -> ReturnItemType {\n        ReturnItemType::Notes\n    }\n}\n\nimpl ReturnItemType {\n    fn required_table(&self) -> RequiredTable {\n        match self {\n            ReturnItemType::Cards => RequiredTable::Cards,\n            ReturnItemType::Notes => RequiredTable::Notes,\n        }\n    }\n}\n\nimpl SortMode {\n    fn required_table(&self) -> RequiredTable {\n        match self {\n            SortMode::NoOrder => RequiredTable::CardsOrNotes,\n            SortMode::Builtin { column, .. } => column.required_table(),\n            SortMode::Custom(ref text) => {\n                if text.contains(\"n.\") {\n                    if text.contains(\"c.\") {\n                        RequiredTable::CardsAndNotes\n                    } else {\n                        RequiredTable::Notes\n                    }\n                } else {\n                    RequiredTable::Cards\n                }\n            }\n        }\n    }\n}\n\nimpl Column {\n    fn required_table(self) -> RequiredTable {\n        match self {\n            Column::Cards\n            | Column::NoteCreation\n            | Column::NoteMod\n            | Column::Notetype\n            | Column::SortField\n            | Column::Tags => RequiredTable::Notes,\n            _ => RequiredTable::CardsOrNotes,\n        }\n    }\n}\n\npub trait TryIntoSearch {\n    fn try_into_search(self) -> Result<Node, AnkiError>;\n}\n\nimpl TryIntoSearch for &str {\n    fn try_into_search(self) -> Result<Node, AnkiError> {\n        parser::parse(self).map(Node::Group)\n    }\n}\n\nimpl TryIntoSearch for &String {\n    fn try_into_search(self) -> Result<Node, AnkiError> {\n        parser::parse(self).map(Node::Group)\n    }\n}\n\nimpl<T> TryIntoSearch for T\nwhere\n    T: Into<Node>,\n{\n    fn try_into_search(self) -> Result<Node, AnkiError> {\n        Ok(self.into())\n    }\n}\n\npub struct CardTableGuard<'a> {\n    pub col: &'a mut Collection,\n    pub cards: usize,\n}\n\nimpl Drop for CardTableGuard<'_> {\n    fn drop(&mut self) {\n        if let Err(err) = self.col.storage.clear_searched_cards_table() {\n            println!(\"{err:?}\");\n        }\n    }\n}\n\npub struct NoteTableGuard<'a> {\n    pub col: &'a mut Collection,\n    pub notes: usize,\n}\n\nimpl Drop for NoteTableGuard<'_> {\n    fn drop(&mut self) {\n        if let Err(err) = self.col.storage.clear_searched_notes_table() {\n            println!(\"{err:?}\");\n        }\n    }\n}\n\nimpl Collection {\n    pub fn search_cards<N>(&mut self, search: N, mode: SortMode) -> Result<Vec<CardId>>\n    where\n        N: TryIntoSearch,\n    {\n        self.search(search, mode)\n    }\n\n    pub fn search_notes<N>(&mut self, search: N, mode: SortMode) -> Result<Vec<NoteId>>\n    where\n        N: TryIntoSearch,\n    {\n        self.search(search, mode)\n    }\n\n    pub fn search_notes_unordered<N>(&mut self, search: N) -> Result<Vec<NoteId>>\n    where\n        N: TryIntoSearch,\n    {\n        self.search(search, SortMode::NoOrder)\n    }\n}\n\nimpl Collection {\n    fn search<T, N>(&mut self, search: N, mode: SortMode) -> Result<Vec<T>>\n    where\n        N: TryIntoSearch,\n        T: FromSql + AsReturnItemType,\n    {\n        let item_type = T::as_return_item_type();\n        let top_node = search.try_into_search()?;\n        let writer = SqlWriter::new(self, item_type);\n\n        let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;\n        self.add_order(&mut sql, item_type, mode)?;\n\n        let mut stmt = self.storage.db.prepare(&sql)?;\n        let ids: Vec<_> = stmt\n            .query_map(params_from_iter(args.iter()), |row| row.get(0))?\n            .collect::<std::result::Result<_, _>>()?;\n\n        Ok(ids)\n    }\n\n    fn add_order(\n        &mut self,\n        sql: &mut String,\n        item_type: ReturnItemType,\n        mode: SortMode,\n    ) -> Result<()> {\n        match mode {\n            SortMode::NoOrder => (),\n            SortMode::Builtin { column, reverse } => {\n                prepare_sort(self, column, item_type)?;\n                sql.push_str(\" order by \");\n                write_order(sql, item_type, column, reverse, self.timing_today()?)?;\n            }\n            SortMode::Custom(order_clause) => {\n                sql.push_str(\" order by \");\n                sql.push_str(&order_clause);\n            }\n        }\n        Ok(())\n    }\n\n    /// Place the matched card ids into a temporary 'search_cids' table\n    /// instead of returning them. Returns a guard with a collection reference\n    /// and the number of added cards. When the guard is dropped, the temporary\n    /// table is cleaned up.\n    pub(crate) fn search_cards_into_table(\n        &mut self,\n        search: impl TryIntoSearch,\n        mode: SortMode,\n    ) -> Result<CardTableGuard<'_>> {\n        let top_node = search.try_into_search()?;\n        let writer = SqlWriter::new(self, ReturnItemType::Cards);\n        let want_order = mode != SortMode::NoOrder;\n\n        let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;\n        self.add_order(&mut sql, ReturnItemType::Cards, mode)?;\n\n        if want_order {\n            self.storage\n                .setup_searched_cards_table_to_preserve_order()?;\n        } else {\n            self.storage.setup_searched_cards_table()?;\n        }\n        let sql = format!(\"insert into search_cids {sql}\");\n\n        let cards = self\n            .storage\n            .db\n            .prepare(&sql)?\n            .execute(params_from_iter(args))?;\n\n        Ok(CardTableGuard { cards, col: self })\n    }\n\n    pub(crate) fn all_cards_for_search(&mut self, search: impl TryIntoSearch) -> Result<Vec<Card>> {\n        let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;\n        guard.col.storage.all_searched_cards()\n    }\n\n    pub(crate) fn all_cards_for_search_in_order(\n        &mut self,\n        search: impl TryIntoSearch,\n        mode: SortMode,\n    ) -> Result<Vec<Card>> {\n        let guard = self.search_cards_into_table(search, mode)?;\n        guard.col.storage.all_searched_cards_in_search_order()\n    }\n\n    pub(crate) fn all_cards_for_ids(\n        &self,\n        cards: &[CardId],\n        preserve_order: bool,\n    ) -> Result<Vec<Card>> {\n        self.storage.with_searched_cards_table(preserve_order, || {\n            self.storage.set_search_table_to_card_ids(cards)?;\n            if preserve_order {\n                self.storage.all_searched_cards_in_search_order()\n            } else {\n                self.storage.all_searched_cards()\n            }\n        })\n    }\n\n    pub(crate) fn for_each_card_in_search(\n        &mut self,\n        search: impl TryIntoSearch,\n        mut func: impl FnMut(&Collection, Card) -> Result<()>,\n    ) -> Result<()> {\n        let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;\n        guard\n            .col\n            .storage\n            .for_each_card_in_search(|card| func(guard.col, card))\n    }\n\n    /// Place the matched card ids into a temporary 'search_nids' table\n    /// instead of returning them. Returns a guard with a collection reference\n    /// and the number of added notes. When the guard is dropped, the temporary\n    /// table is cleaned up.\n    pub(crate) fn search_notes_into_table(\n        &mut self,\n        search: impl TryIntoSearch,\n    ) -> Result<NoteTableGuard<'_>> {\n        let top_node = search.try_into_search()?;\n        let writer = SqlWriter::new(self, ReturnItemType::Notes);\n        let mode = SortMode::NoOrder;\n\n        let (sql, args) = writer.build_query(&top_node, mode.required_table())?;\n\n        self.storage.setup_searched_notes_table()?;\n        let sql = format!(\"insert into search_nids {sql}\");\n\n        let notes = self\n            .storage\n            .db\n            .prepare(&sql)?\n            .execute(params_from_iter(args))?;\n\n        Ok(NoteTableGuard { notes, col: self })\n    }\n\n    /// Place the ids of cards with notes in 'search_nids' into 'search_cids'.\n    /// Returns number of added cards.\n    pub(crate) fn search_cards_of_notes_into_table(&mut self) -> Result<CardTableGuard<'_>> {\n        self.storage.setup_searched_cards_table()?;\n        let cards = self.storage.search_cards_of_notes_into_table()?;\n        Ok(CardTableGuard { cards, col: self })\n    }\n}\n\n/// Add the order clause to the sql.\nfn write_order(\n    sql: &mut String,\n    item_type: ReturnItemType,\n    column: Column,\n    reverse: bool,\n    timing: SchedTimingToday,\n) -> Result<()> {\n    let order = match item_type {\n        ReturnItemType::Cards => card_order_from_sort_column(column, timing),\n        ReturnItemType::Notes => note_order_from_sort_column(column),\n    };\n    require!(!order.is_empty(), \"Can't sort {item_type:?} by {column:?}.\");\n    if reverse {\n        sql.push_str(\n            &order\n                .to_ascii_lowercase()\n                .replace(\" desc\", \"\")\n                .replace(\" asc\", \" desc\"),\n        )\n    } else {\n        sql.push_str(&order);\n    }\n    Ok(())\n}\n\nfn card_order_from_sort_column(column: Column, timing: SchedTimingToday) -> Cow<'static, str> {\n    match column {\n        Column::CardMod => \"c.mod asc\".into(),\n        Column::Cards => concat!(\n            \"coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),\",\n            // need to fall back on ord 0 for cloze cards\n            \"(select pos from sort_order where ntid = n.mid and ord = 0)) asc, ord asc\"\n        )\n        .into(),\n        Column::Deck => \"(select pos from sort_order where did = c.did) asc\".into(),\n        Column::Due => format!(\"(case when c.due > 1000000000 or c.type = {} then due else (due - {}) * 86400 + {} end) asc\", CardType::New as i8, timing.days_elapsed, TimestampSecs::now().0).into(),\n        Column::Ease => format!(\"c.type = {} asc, c.factor asc\", CardType::New as i8).into(),\n        Column::Interval => \"c.ivl asc\".into(),\n        Column::Lapses => \"c.lapses asc\".into(),\n        Column::NoteCreation => \"n.id asc, c.ord asc\".into(),\n        Column::NoteMod => \"n.mod asc, c.ord asc\".into(),\n        Column::Notetype => \"(select pos from sort_order where ntid = n.mid) asc\".into(),\n        Column::OriginalPosition => \"(select pos from sort_order where nid = c.nid) asc\".into(),\n        Column::Reps => \"c.reps asc\".into(),\n        Column::SortField => \"n.sfld collate nocase asc, c.ord asc\".into(),\n        Column::Tags => \"n.tags asc\".into(),\n        Column::Answer | Column::Custom | Column::Question => \"\".into(),\n        Column::Stability => \"extract_fsrs_variable(c.data, 's') asc\".into(),\n        Column::Difficulty => \"extract_fsrs_variable(c.data, 'd') asc\".into(),\n        Column::Retrievability => format!(\n            \"extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {}, {}, {}) asc\",\n            timing.days_elapsed,\n            timing.next_day_at.0,\n            timing.now.0,\n        )\n        .into(),\n    }\n}\n\nfn note_order_from_sort_column(column: Column) -> Cow<'static, str> {\n    match column {\n        Column::CardMod\n        | Column::Cards\n        | Column::Deck\n        | Column::Due\n        | Column::Ease\n        | Column::Interval\n        | Column::Lapses\n        | Column::OriginalPosition\n        | Column::Reps => \"(select pos from sort_order where nid = n.id) asc\".into(),\n        Column::NoteCreation => \"n.id asc\".into(),\n        Column::NoteMod => \"n.mod asc\".into(),\n        Column::Notetype => \"(select pos from sort_order where ntid = n.mid) asc\".into(),\n        Column::SortField => \"n.sfld collate nocase asc\".into(),\n        Column::Tags => \"n.tags asc\".into(),\n        Column::Answer\n        | Column::Custom\n        | Column::Question\n        | Column::Stability\n        | Column::Difficulty\n        | Column::Retrievability => \"\".into(),\n    }\n}\n\nfn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> {\n    let temp_string;\n    let sql = match item_type {\n        ReturnItemType::Cards => match column {\n            Column::Cards => include_str!(\"template_order.sql\"),\n            Column::Deck => include_str!(\"deck_order.sql\"),\n            Column::Notetype => include_str!(\"notetype_order.sql\"),\n            Column::OriginalPosition => include_str!(\"note_original_position_order.sql\"),\n            _ => return Ok(()),\n        },\n        ReturnItemType::Notes => match column {\n            Column::Cards => include_str!(\"note_cards_order.sql\"),\n            Column::CardMod => include_str!(\"card_mod_order.sql\"),\n            Column::Deck => include_str!(\"note_decks_order.sql\"),\n            Column::Due => {\n                temp_string = format!(\"{} ORDER BY MIN({});\", include_str!(\"note_due_order.sql\"), format_args!(\"CASE WHEN due > 1000000000 OR type = {ctype} THEN due ELSE (due - {today}) * 86400 + {current_timestamp} END\", ctype = CardType::New as i8, today = col.timing_today()?.days_elapsed, current_timestamp = TimestampSecs::now().0));\n                &temp_string\n            }\n            Column::Ease => include_str!(\"note_ease_order.sql\"),\n            Column::Interval => include_str!(\"note_interval_order.sql\"),\n            Column::Lapses => include_str!(\"note_lapses_order.sql\"),\n            Column::OriginalPosition => include_str!(\"note_original_position_order.sql\"),\n            Column::Reps => include_str!(\"note_reps_order.sql\"),\n            Column::Notetype => include_str!(\"notetype_order.sql\"),\n            _ => return Ok(()),\n        },\n    };\n\n    col.storage.db.execute_batch(sql)?;\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod test {\n    use anki_proto::search::browser_columns::Sorting;\n    use strum::IntoEnumIterator;\n\n    use super::*;\n\n    impl SchedTimingToday {\n        pub(crate) fn zero() -> Self {\n            SchedTimingToday {\n                now: TimestampSecs(0),\n                days_elapsed: 0,\n                next_day_at: TimestampSecs(0),\n            }\n        }\n    }\n\n    #[test]\n    fn column_default_sort_order_should_match_order_by_clause() {\n        let timing = SchedTimingToday::zero();\n        for column in Column::iter() {\n            assert_eq!(\n                card_order_from_sort_column(column, timing).is_empty(),\n                matches!(column.default_cards_order(), Sorting::None)\n            );\n            assert_eq!(\n                note_order_from_sort_column(column).is_empty(),\n                matches!(column.default_notes_order(), Sorting::None)\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/note_cards_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nGROUP BY nid\nORDER BY COUNT(*);"
  },
  {
    "path": "rslib/src/search/note_decks_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\n  JOIN (\n    SELECT id,\n      row_number() OVER(\n        ORDER BY name\n      ) AS pos\n    FROM decks\n  ) decks ON cards.did = decks.id\nGROUP BY nid\nORDER BY COUNT(DISTINCT did),\n  decks.pos;"
  },
  {
    "path": "rslib/src/search/note_due_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nWHERE (\n    odid = 0\n    AND type != 0\n    AND queue > 0\n  )\nGROUP BY nid"
  },
  {
    "path": "rslib/src/search/note_ease_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nWHERE type != 0\nGROUP BY nid\nORDER BY AVG(factor);"
  },
  {
    "path": "rslib/src/search/note_interval_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nWHERE type IN (2, 3)\nGROUP BY nid\nORDER BY AVG(ivl);"
  },
  {
    "path": "rslib/src/search/note_lapses_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nGROUP BY nid\nORDER BY SUM(lapses);"
  },
  {
    "path": "rslib/src/search/note_original_position_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nGROUP BY nid\nORDER BY COALESCE(\n    extract_original_position(data),\n    CASE\n      WHEN type == 0 THEN due\n      ELSE 0\n    END\n  );"
  },
  {
    "path": "rslib/src/search/note_reps_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  nid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (nid)\nSELECT nid\nFROM cards\nGROUP BY nid\nORDER BY SUM(reps);"
  },
  {
    "path": "rslib/src/search/notetype_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  ntid integer NOT NULL UNIQUE\n);\nINSERT INTO sort_order (ntid)\nSELECT id\nFROM notetypes\nORDER BY name;"
  },
  {
    "path": "rslib/src/search/parser.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::LazyLock;\n\nuse anki_proto::search::search_node::FieldSearchMode as FieldSearchModeProto;\nuse nom::branch::alt;\nuse nom::bytes::complete::escaped;\nuse nom::bytes::complete::is_not;\nuse nom::bytes::complete::tag;\nuse nom::character::complete::alphanumeric1;\nuse nom::character::complete::anychar;\nuse nom::character::complete::char;\nuse nom::character::complete::none_of;\nuse nom::character::complete::one_of;\nuse nom::combinator::map;\nuse nom::combinator::recognize;\nuse nom::combinator::verify;\nuse nom::error::ErrorKind as NomErrorKind;\nuse nom::multi::many0;\nuse nom::sequence::preceded;\nuse nom::sequence::separated_pair;\nuse nom::Parser;\nuse regex::Captures;\nuse regex::Regex;\n\nuse crate::error::ParseError;\nuse crate::error::Result;\nuse crate::error::SearchErrorKind as FailKind;\nuse crate::prelude::*;\ntype IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err<ParseError<'a>>>;\ntype ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>;\n\nfn parse_failure(input: &str, kind: FailKind) -> nom::Err<ParseError<'_>> {\n    nom::Err::Failure(ParseError::Anki(input, kind))\n}\n\nfn parse_error(input: &str) -> nom::Err<ParseError<'_>> {\n    nom::Err::Error(ParseError::Anki(input, FailKind::Other { info: None }))\n}\n\n#[derive(Debug, PartialEq, Clone)]\npub enum Node {\n    And,\n    Or,\n    Not(Box<Node>),\n    Group(Vec<Node>),\n    Search(SearchNode),\n}\n\n#[derive(Copy, Debug, PartialEq, Eq, Clone)]\npub enum FieldSearchMode {\n    Normal,\n    Regex,\n    NoCombining,\n}\n\nimpl From<FieldSearchModeProto> for FieldSearchMode {\n    fn from(mode: FieldSearchModeProto) -> Self {\n        match mode {\n            FieldSearchModeProto::Normal => Self::Normal,\n            FieldSearchModeProto::Regex => Self::Regex,\n            FieldSearchModeProto::Nocombining => Self::NoCombining,\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Clone)]\npub enum SearchNode {\n    // text without a colon\n    UnqualifiedText(String),\n    // foo:bar, where foo doesn't match a term below\n    SingleField {\n        field: String,\n        text: String,\n        mode: FieldSearchMode,\n    },\n    AddedInDays(u32),\n    EditedInDays(u32),\n    CardTemplate(TemplateKind),\n    Deck(String),\n    /// Matches cards in a list of deck ids. Cards are matched even if they are\n    /// in a filtered deck.\n    DeckIdsWithoutChildren(String),\n    /// Matches cards in a deck or its children (original_deck_id is not\n    /// checked, so filtered cards are not matched).\n    DeckIdWithChildren(DeckId),\n    IntroducedInDays(u32),\n    NotetypeId(NotetypeId),\n    Notetype(String),\n    Rated {\n        days: u32,\n        ease: RatingKind,\n    },\n    Tag {\n        tag: String,\n        mode: FieldSearchMode,\n    },\n    Duplicates {\n        notetype_id: NotetypeId,\n        text: String,\n    },\n    State(StateKind),\n    Flag(u8),\n    NoteIds(String),\n    CardIds(String),\n    Property {\n        operator: String,\n        kind: PropertyKind,\n    },\n    WholeCollection,\n    Regex(String),\n    NoCombining(String),\n    StripClozes(String),\n    WordBoundary(String),\n    CustomData(String),\n    Preset(String),\n}\n\n#[derive(Debug, PartialEq, Clone)]\npub enum PropertyKind {\n    Due(i32),\n    Interval(u32),\n    Reps(u32),\n    Lapses(u32),\n    Ease(f32),\n    Position(u32),\n    Rated(i32, RatingKind),\n    Stability(f32),\n    Difficulty(f32),\n    Retrievability(f32),\n    CustomDataNumber { key: String, value: f32 },\n    CustomDataString { key: String, value: String },\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub enum StateKind {\n    New,\n    Review,\n    Learning,\n    Due,\n    Buried,\n    UserBuried,\n    SchedBuried,\n    Suspended,\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub enum TemplateKind {\n    Ordinal(u16),\n    Name(String),\n}\n\n#[derive(Debug, PartialEq, Eq, Clone)]\npub enum RatingKind {\n    AnswerButton(u8),\n    AnyAnswerButton,\n    ManualReschedule,\n}\n\n/// Parse the input string into a list of nodes.\npub fn parse(input: &str) -> Result<Vec<Node>> {\n    let input = input.trim();\n    if input.is_empty() {\n        return Ok(vec![Node::Search(SearchNode::WholeCollection)]);\n    }\n\n    match group_inner(input) {\n        Ok((\"\", nodes)) => Ok(nodes),\n        // unmatched ) is only char not consumed by any node parser\n        Ok((remaining, _)) => Err(parse_failure(remaining, FailKind::UnopenedGroup).into()),\n        Err(err) => Err(err.into()),\n    }\n}\n\n/// Zero or more nodes inside brackets, eg 'one OR two -three'.\n/// Empty vec must be handled by caller.\nfn group_inner(input: &str) -> IResult<'_, Vec<Node>> {\n    let mut remaining = input;\n    let mut nodes = vec![];\n\n    loop {\n        match node(remaining) {\n            Ok((rem, node)) => {\n                remaining = rem;\n\n                if nodes.len() % 2 == 0 {\n                    // before adding the node, if the length is even then the node\n                    // must not be a boolean\n                    if node == Node::And {\n                        return Err(parse_failure(input, FailKind::MisplacedAnd));\n                    } else if node == Node::Or {\n                        return Err(parse_failure(input, FailKind::MisplacedOr));\n                    }\n                } else {\n                    // if the length is odd, the next item must be a boolean. if it's\n                    // not, add an implicit and\n                    if !matches!(node, Node::And | Node::Or) {\n                        nodes.push(Node::And);\n                    }\n                }\n                nodes.push(node);\n            }\n            Err(e) => match e {\n                nom::Err::Error(_) => break,\n                _ => return Err(e),\n            },\n        };\n    }\n\n    if let Some(last) = nodes.last() {\n        match last {\n            Node::And => return Err(parse_failure(input, FailKind::MisplacedAnd)),\n            Node::Or => return Err(parse_failure(input, FailKind::MisplacedOr)),\n            _ => (),\n        }\n    }\n    let (remaining, _) = whitespace0(remaining)?;\n\n    Ok((remaining, nodes))\n}\n\nfn whitespace0(s: &str) -> IResult<'_, Vec<char>> {\n    many0(one_of(\" \\u{3000}\")).parse(s)\n}\n\n/// Optional leading space, then a (negated) group or text\nfn node(s: &str) -> IResult<'_, Node> {\n    preceded(whitespace0, alt((negated_node, group, text))).parse(s)\n}\n\nfn negated_node(s: &str) -> IResult<'_, Node> {\n    map(preceded(char('-'), alt((group, text))), |node| {\n        Node::Not(Box::new(node))\n    })\n    .parse(s)\n}\n\n/// One or more nodes surrounded by brackets, eg (one OR two)\nfn group(s: &str) -> IResult<'_, Node> {\n    let (opened, _) = char('(')(s)?;\n    let (tail, inner) = group_inner(opened)?;\n    if let Some(remaining) = tail.strip_prefix(')') {\n        if inner.is_empty() {\n            Err(parse_failure(s, FailKind::EmptyGroup))\n        } else {\n            Ok((remaining, Node::Group(inner)))\n        }\n    } else {\n        Err(parse_failure(s, FailKind::UnclosedGroup))\n    }\n}\n\n/// Either quoted or unquoted text\nfn text(s: &str) -> IResult<'_, Node> {\n    alt((quoted_term, partially_quoted_term, unquoted_term)).parse(s)\n}\n\n/// Quoted text, including the outer double quotes.\nfn quoted_term(s: &str) -> IResult<'_, Node> {\n    let (remaining, term) = quoted_term_str(s)?;\n    Ok((remaining, Node::Search(search_node_for_text(term)?)))\n}\n\n/// eg deck:\"foo bar\" - quotes must come after the :\nfn partially_quoted_term(s: &str) -> IResult<'_, Node> {\n    let (remaining, (key, val)) = separated_pair(\n        escaped(is_not(\"\\\"(): \\u{3000}\\\\\"), '\\\\', none_of(\" \\u{3000}\")),\n        char(':'),\n        quoted_term_str,\n    )\n    .parse(s)?;\n    Ok((\n        remaining,\n        Node::Search(search_node_for_text_with_argument(key, val)?),\n    ))\n}\n\n/// Unquoted text, terminated by whitespace or unescaped \", ( or )\nfn unquoted_term(s: &str) -> IResult<'_, Node> {\n    match escaped(is_not(\"\\\"() \\u{3000}\\\\\"), '\\\\', none_of(\" \\u{3000}\"))(s) {\n        Ok((tail, term)) => {\n            if term.is_empty() {\n                Err(parse_error(s))\n            } else if term.eq_ignore_ascii_case(\"and\") {\n                Ok((tail, Node::And))\n            } else if term.eq_ignore_ascii_case(\"or\") {\n                Ok((tail, Node::Or))\n            } else {\n                Ok((tail, Node::Search(search_node_for_text(term)?)))\n            }\n        }\n        Err(err) => {\n            if let nom::Err::Error((c, NomErrorKind::NoneOf)) = err {\n                Err(parse_failure(\n                    s,\n                    FailKind::UnknownEscape {\n                        provided: format!(\"\\\\{c}\"),\n                    },\n                ))\n            } else if \"\\\"() \\u{3000}\".contains(s.chars().next().unwrap()) {\n                Err(parse_error(s))\n            } else {\n                // input ends in an odd number of backslashes\n                Err(parse_failure(\n                    s,\n                    FailKind::UnknownEscape {\n                        provided: '\\\\'.to_string(),\n                    },\n                ))\n            }\n        }\n    }\n}\n\n/// Non-empty string delimited by unescaped double quotes.\nfn quoted_term_str(s: &str) -> IResult<'_, &str> {\n    let (opened, _) = char('\"')(s)?;\n    if let Ok((tail, inner)) =\n        escaped::<_, ParseError, _, _>(is_not(r#\"\"\\\"#), '\\\\', anychar).parse(opened)\n    {\n        if let Ok((remaining, _)) = char::<_, ParseError>('\"')(tail) {\n            Ok((remaining, inner))\n        } else {\n            Err(parse_failure(s, FailKind::UnclosedQuote))\n        }\n    } else {\n        Err(parse_failure(\n            s,\n            match opened.chars().next().unwrap() {\n                '\"' => FailKind::EmptyQuote,\n                // no unescaped \" and a trailing \\\n                _ => FailKind::UnclosedQuote,\n            },\n        ))\n    }\n}\n\n/// Determine if text is a qualified search, and handle escaped chars.\n/// Expect well-formed input: unempty and no trailing \\.\nfn search_node_for_text(s: &str) -> ParseResult<'_, SearchNode> {\n    // leading : is only possible error for well-formed input\n    let (tail, head) = verify(escaped(is_not(r\":\\\"), '\\\\', anychar), |t: &str| {\n        !t.is_empty()\n    })\n    .parse(s)\n    .map_err(|_: nom::Err<ParseError>| parse_failure(s, FailKind::MissingKey))?;\n    if tail.is_empty() {\n        Ok(SearchNode::UnqualifiedText(unescape(head)?))\n    } else {\n        search_node_for_text_with_argument(head, &tail[1..])\n    }\n}\n\n/// Convert a colon-separated key/val pair into the relevant search type.\nfn search_node_for_text_with_argument<'a>(\n    key: &'a str,\n    val: &'a str,\n) -> ParseResult<'a, SearchNode> {\n    Ok(match key.to_ascii_lowercase().as_str() {\n        \"deck\" => SearchNode::Deck(unescape(val)?),\n        \"note\" => SearchNode::Notetype(unescape(val)?),\n        \"tag\" => parse_tag(val)?,\n        \"card\" => parse_template(val)?,\n        \"flag\" => parse_flag(val)?,\n        \"resched\" => parse_resched(val)?,\n        \"prop\" => parse_prop(val)?,\n        \"added\" => parse_added(val)?,\n        \"edited\" => parse_edited(val)?,\n        \"introduced\" => parse_introduced(val)?,\n        \"rated\" => parse_rated(val)?,\n        \"is\" => parse_state(val)?,\n        \"did\" => SearchNode::DeckIdsWithoutChildren(check_id_list(val, key)?.into()),\n        \"mid\" => parse_mid(val)?,\n        \"nid\" => SearchNode::NoteIds(check_id_list(val, key)?.into()),\n        \"cid\" => SearchNode::CardIds(check_id_list(val, key)?.into()),\n        \"re\" => SearchNode::Regex(unescape_quotes(val)),\n        \"nc\" => SearchNode::NoCombining(unescape(val)?),\n        \"sc\" => SearchNode::StripClozes(unescape(val)?),\n        \"w\" => SearchNode::WordBoundary(unescape(val)?),\n        \"dupe\" => parse_dupe(val)?,\n        \"has-cd\" => SearchNode::CustomData(unescape(val)?),\n        \"preset\" => SearchNode::Preset(val.into()),\n        // anything else is a field search\n        _ => parse_single_field(key, val)?,\n    })\n}\n\nfn parse_tag(s: &str) -> ParseResult<'_, SearchNode> {\n    Ok(if let Some(re) = s.strip_prefix(\"re:\") {\n        SearchNode::Tag {\n            tag: unescape_quotes(re),\n            mode: FieldSearchMode::Regex,\n        }\n    } else if let Some(nc) = s.strip_prefix(\"nc:\") {\n        SearchNode::Tag {\n            tag: unescape(nc)?,\n            mode: FieldSearchMode::NoCombining,\n        }\n    } else {\n        SearchNode::Tag {\n            tag: unescape(s)?,\n            mode: FieldSearchMode::Normal,\n        }\n    })\n}\n\nfn parse_template(s: &str) -> ParseResult<'_, SearchNode> {\n    Ok(SearchNode::CardTemplate(match s.parse::<u16>() {\n        Ok(n) => TemplateKind::Ordinal(n.max(1) - 1),\n        Err(_) => TemplateKind::Name(unescape(s)?),\n    }))\n}\n\n/// flag:0-7\nfn parse_flag(s: &str) -> ParseResult<'_, SearchNode> {\n    if let Ok(flag) = s.parse::<u8>() {\n        if flag > 7 {\n            Err(parse_failure(s, FailKind::InvalidFlag))\n        } else {\n            Ok(SearchNode::Flag(flag))\n        }\n    } else {\n        Err(parse_failure(s, FailKind::InvalidFlag))\n    }\n}\n\n/// eg resched:3\nfn parse_resched(s: &str) -> ParseResult<'_, SearchNode> {\n    parse_u32(s, \"resched:\").map(|days| SearchNode::Rated {\n        days,\n        ease: RatingKind::ManualReschedule,\n    })\n}\n\n/// eg prop:ivl>3, prop:ease!=2.5\nfn parse_prop(prop_clause: &str) -> ParseResult<'_, SearchNode> {\n    let (tail, prop) = alt((\n        tag(\"ivl\"),\n        tag(\"due\"),\n        tag(\"reps\"),\n        tag(\"lapses\"),\n        tag(\"ease\"),\n        tag(\"pos\"),\n        tag(\"rated\"),\n        tag(\"resched\"),\n        tag(\"s\"),\n        tag(\"d\"),\n        tag(\"r\"),\n        recognize(preceded(tag(\"cdn:\"), alphanumeric1)),\n        recognize(preceded(tag(\"cds:\"), alphanumeric1)),\n    ))\n    .parse(prop_clause)\n    .map_err(|_: nom::Err<ParseError>| {\n        parse_failure(\n            prop_clause,\n            FailKind::InvalidPropProperty {\n                provided: prop_clause.into(),\n            },\n        )\n    })?;\n\n    let (num, operator) = alt((\n        tag(\"<=\"),\n        tag(\">=\"),\n        tag(\"!=\"),\n        tag(\"=\"),\n        tag(\"<\"),\n        tag(\">\"),\n    ))\n    .parse(tail)\n    .map_err(|_: nom::Err<ParseError>| {\n        parse_failure(\n            prop_clause,\n            FailKind::InvalidPropOperator {\n                provided: prop.to_string(),\n            },\n        )\n    })?;\n\n    let kind = match prop {\n        \"ease\" => PropertyKind::Ease(parse_f32(num, prop_clause)?),\n        \"due\" => PropertyKind::Due(parse_i32(num, prop_clause)?),\n        \"rated\" => parse_prop_rated(num, prop_clause)?,\n        \"resched\" => PropertyKind::Rated(\n            parse_negative_i32(num, prop_clause)?,\n            RatingKind::ManualReschedule,\n        ),\n        \"ivl\" => PropertyKind::Interval(parse_u32(num, prop_clause)?),\n        \"reps\" => PropertyKind::Reps(parse_u32(num, prop_clause)?),\n        \"lapses\" => PropertyKind::Lapses(parse_u32(num, prop_clause)?),\n        \"pos\" => PropertyKind::Position(parse_u32(num, prop_clause)?),\n        \"s\" => PropertyKind::Stability(parse_f32(num, prop_clause)?),\n        \"d\" => PropertyKind::Difficulty(parse_f32(num, prop_clause)?),\n        \"r\" => PropertyKind::Retrievability(parse_f32(num, prop_clause)?),\n        prop if prop.starts_with(\"cdn:\") => PropertyKind::CustomDataNumber {\n            key: prop.strip_prefix(\"cdn:\").unwrap().into(),\n            value: parse_f32(num, prop_clause)?,\n        },\n        prop if prop.starts_with(\"cds:\") => PropertyKind::CustomDataString {\n            key: prop.strip_prefix(\"cds:\").unwrap().into(),\n            value: num.into(),\n        },\n        _ => unreachable!(),\n    };\n\n    Ok(SearchNode::Property {\n        operator: operator.to_string(),\n        kind,\n    })\n}\n\nfn parse_u32<'a>(num: &str, context: &'a str) -> ParseResult<'a, u32> {\n    num.parse().map_err(|_e| {\n        parse_failure(\n            context,\n            FailKind::InvalidPositiveWholeNumber {\n                context: context.into(),\n                provided: num.into(),\n            },\n        )\n    })\n}\n\nfn parse_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> {\n    num.parse().map_err(|_e| {\n        parse_failure(\n            context,\n            FailKind::InvalidWholeNumber {\n                context: context.into(),\n                provided: num.into(),\n            },\n        )\n    })\n}\n\nfn parse_negative_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> {\n    num.parse()\n        .map_err(|_| ())\n        .and_then(|n| if n > 0 { Err(()) } else { Ok(n) })\n        .map_err(|_| {\n            parse_failure(\n                context,\n                FailKind::InvalidNegativeWholeNumber {\n                    context: context.into(),\n                    provided: num.into(),\n                },\n            )\n        })\n}\n\nfn parse_f32<'a>(num: &str, context: &'a str) -> ParseResult<'a, f32> {\n    num.parse().map_err(|_e| {\n        parse_failure(\n            context,\n            FailKind::InvalidNumber {\n                context: context.into(),\n                provided: num.into(),\n            },\n        )\n    })\n}\n\nfn parse_i64<'a>(num: &str, context: &'a str) -> ParseResult<'a, i64> {\n    num.parse().map_err(|_e| {\n        parse_failure(\n            context,\n            FailKind::InvalidWholeNumber {\n                context: context.into(),\n                provided: num.into(),\n            },\n        )\n    })\n}\n\nfn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, RatingKind> {\n    Ok(if let Some(num) = num {\n        RatingKind::AnswerButton(\n            num.parse()\n                .map_err(|_| ())\n                .and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) })\n                .map_err(|_| {\n                    parse_failure(\n                        context,\n                        FailKind::InvalidAnswerButton {\n                            context: context.into(),\n                            provided: num.into(),\n                        },\n                    )\n                })?,\n        )\n    } else {\n        RatingKind::AnyAnswerButton\n    })\n}\n\nfn parse_prop_rated<'a>(num: &str, context: &'a str) -> ParseResult<'a, PropertyKind> {\n    let mut it = num.splitn(2, ':');\n    let days = parse_negative_i32(it.next().unwrap(), context)?;\n    let button = parse_answer_button(it.next(), context)?;\n    Ok(PropertyKind::Rated(days, button))\n}\n\n/// eg added:1\nfn parse_added(s: &str) -> ParseResult<'_, SearchNode> {\n    parse_u32(s, \"added:\").map(|n| SearchNode::AddedInDays(n.max(1)))\n}\n\n/// eg edited:1\nfn parse_edited(s: &str) -> ParseResult<'_, SearchNode> {\n    parse_u32(s, \"edited:\").map(|n| SearchNode::EditedInDays(n.max(1)))\n}\n\n/// eg introduced:1\nfn parse_introduced(s: &str) -> ParseResult<'_, SearchNode> {\n    parse_u32(s, \"introduced:\").map(|n| SearchNode::IntroducedInDays(n.max(1)))\n}\n\n/// eg rated:3 or rated:10:2\n/// second arg must be between 1-4\nfn parse_rated(s: &str) -> ParseResult<'_, SearchNode> {\n    let mut it = s.splitn(2, ':');\n    let days = parse_u32(it.next().unwrap(), \"rated:\")?.max(1);\n    let button = parse_answer_button(it.next(), s)?;\n    Ok(SearchNode::Rated { days, ease: button })\n}\n\n/// eg is:due\nfn parse_state(s: &str) -> ParseResult<'_, SearchNode> {\n    use StateKind::*;\n    Ok(SearchNode::State(match s {\n        \"new\" => New,\n        \"review\" => Review,\n        \"learn\" => Learning,\n        \"due\" => Due,\n        \"buried\" => Buried,\n        \"buried-manually\" => UserBuried,\n        \"buried-sibling\" => SchedBuried,\n        \"suspended\" => Suspended,\n        _ => {\n            return Err(parse_failure(\n                s,\n                FailKind::InvalidState { provided: s.into() },\n            ))\n        }\n    }))\n}\n\nfn parse_mid(s: &str) -> ParseResult<'_, SearchNode> {\n    parse_i64(s, \"mid:\").map(|n| SearchNode::NotetypeId(n.into()))\n}\n\n/// ensure a list of ids contains only numbers and commas, returning unchanged\n/// if true used by nid: and cid:\nfn check_id_list<'a>(s: &'a str, context: &str) -> ParseResult<'a, &'a str> {\n    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"^(\\d+,)*\\d+$\").unwrap());\n    if RE.is_match(s) {\n        Ok(s)\n    } else {\n        Err(parse_failure(\n            s,\n            // id lists are undocumented, so no translation\n            FailKind::Other {\n                info: Some(format!(\"expected only digits and commas in {context}:\")),\n            },\n        ))\n    }\n}\n\n/// eg dupe:1231,hello\nfn parse_dupe(s: &str) -> ParseResult<'_, SearchNode> {\n    let mut it = s.splitn(2, ',');\n    let ntid = parse_i64(it.next().unwrap(), s)?;\n    if let Some(text) = it.next() {\n        Ok(SearchNode::Duplicates {\n            notetype_id: ntid.into(),\n            text: unescape_quotes_and_backslashes(text),\n        })\n    } else {\n        // this is an undocumented keyword, so no translation/help\n        Err(parse_failure(\n            s,\n            FailKind::Other {\n                info: Some(\"invalid 'dupe:' search\".into()),\n            },\n        ))\n    }\n}\n\nfn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode> {\n    Ok(if let Some(stripped) = val.strip_prefix(\"re:\") {\n        SearchNode::SingleField {\n            field: unescape(key)?,\n            text: unescape_quotes(stripped),\n            mode: FieldSearchMode::Regex,\n        }\n    } else if let Some(stripped) = val.strip_prefix(\"nc:\") {\n        SearchNode::SingleField {\n            field: unescape(key)?,\n            text: unescape_quotes(stripped),\n            mode: FieldSearchMode::NoCombining,\n        }\n    } else {\n        SearchNode::SingleField {\n            field: unescape(key)?,\n            text: unescape(val)?,\n            mode: FieldSearchMode::Normal,\n        }\n    })\n}\n\n/// For strings without unescaped \", convert \\\" to \"\nfn unescape_quotes(s: &str) -> String {\n    if s.contains('\"') {\n        s.replace(r#\"\\\"\"#, \"\\\"\")\n    } else {\n        s.into()\n    }\n}\n\n/// For non-globs like dupe text without any assumption about the content\nfn unescape_quotes_and_backslashes(s: &str) -> String {\n    if s.contains('\"') || s.contains('\\\\') {\n        s.replace(r#\"\\\"\"#, \"\\\"\").replace(r\"\\\\\", r\"\\\")\n    } else {\n        s.into()\n    }\n}\n\n/// Unescape chars with special meaning to the parser.\nfn unescape(txt: &str) -> ParseResult<'_, String> {\n    if let Some(seq) = invalid_escape_sequence(txt) {\n        Err(parse_failure(\n            txt,\n            FailKind::UnknownEscape { provided: seq },\n        ))\n    } else {\n        Ok(if is_parser_escape(txt) {\n            static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#\"\\\\[\\\\\":()-]\"#).unwrap());\n            RE.replace_all(txt, |caps: &Captures| match &caps[0] {\n                r\"\\\\\" => r\"\\\\\",\n                \"\\\\\\\"\" => \"\\\"\",\n                r\"\\:\" => \":\",\n                r\"\\(\" => \"(\",\n                r\"\\)\" => \")\",\n                r\"\\-\" => \"-\",\n                _ => unreachable!(),\n            })\n            .into()\n        } else {\n            txt.into()\n        })\n    }\n}\n\n/// Return invalid escape sequence if any.\nfn invalid_escape_sequence(txt: &str) -> Option<String> {\n    // odd number of \\s not followed by an escapable character\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r#\"(?x)\n            (?:^|[^\\\\])         # not a backslash\n            (?:\\\\\\\\)*           # even number of backslashes\n            (\\\\                 # single backslash\n            (?:[^\\\\\":*_()-]|$)) # anything but an escapable char\n            \"#,\n        )\n        .unwrap()\n    });\n    let caps = RE.captures(txt)?;\n\n    Some(caps[1].to_string())\n}\n\n/// Check string for escape sequences handled by the parser: \":()-\nfn is_parser_escape(txt: &str) -> bool {\n    // odd number of \\s followed by a char with special meaning to the parser\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r#\"(?x)\n            (?:^|[^\\\\])     # not a backslash\n            (?:\\\\\\\\)*       # even number of backslashes\n            \\\\              # single backslash\n            [\":()-]         # parser escape\n            \"#,\n        )\n        .unwrap()\n    });\n\n    RE.is_match(txt)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::error::SearchErrorKind;\n\n    #[test]\n    fn parsing() -> Result<()> {\n        use Node::*;\n        use SearchNode::*;\n\n        assert_eq!(parse(\"\")?, vec![Search(WholeCollection)]);\n        assert_eq!(parse(\"  \")?, vec![Search(WholeCollection)]);\n\n        // leading/trailing/interspersed whitespace\n        assert_eq!(\n            parse(\"  t   t2  \")?,\n            vec![\n                Search(UnqualifiedText(\"t\".into())),\n                And,\n                Search(UnqualifiedText(\"t2\".into()))\n            ]\n        );\n\n        // including in groups\n        assert_eq!(\n            parse(\"(  t   t2  )\")?,\n            vec![Group(vec![\n                Search(UnqualifiedText(\"t\".into())),\n                And,\n                Search(UnqualifiedText(\"t2\".into()))\n            ])]\n        );\n\n        assert_eq!(\n            parse(r#\"hello  -(world and \"foo:bar baz\") OR test\"#)?,\n            vec![\n                Search(UnqualifiedText(\"hello\".into())),\n                And,\n                Not(Box::new(Group(vec![\n                    Search(UnqualifiedText(\"world\".into())),\n                    And,\n                    Search(SingleField {\n                        field: \"foo\".into(),\n                        text: \"bar baz\".into(),\n                        mode: FieldSearchMode::Normal,\n                    })\n                ]))),\n                Or,\n                Search(UnqualifiedText(\"test\".into()))\n            ]\n        );\n\n        assert_eq!(\n            parse(\"foo:re:bar\")?,\n            vec![Search(SingleField {\n                field: \"foo\".into(),\n                text: \"bar\".into(),\n                mode: FieldSearchMode::Regex,\n            })]\n        );\n\n        assert_eq!(\n            parse(\"foo:nc:bar\")?,\n            vec![Search(SingleField {\n                field: \"foo\".into(),\n                text: \"bar\".into(),\n                mode: FieldSearchMode::NoCombining,\n            })]\n        );\n\n        // escaping is independent of quotation\n        assert_eq!(\n            parse(r#\"\"field:va\\\"lue\"\"#)?,\n            vec![Search(SingleField {\n                field: \"field\".into(),\n                text: \"va\\\"lue\".into(),\n                mode: FieldSearchMode::Normal,\n            })]\n        );\n        assert_eq!(parse(r#\"\"field:va\\\"lue\"\"#)?, parse(r#\"field:\"va\\\"lue\"\"#)?,);\n        assert_eq!(parse(r#\"\"field:va\\\"lue\"\"#)?, parse(r#\"field:va\\\"lue\"#)?,);\n\n        // parser unescapes \":()-\n        assert_eq!(\n            parse(r#\"\\\"\\:\\(\\)\\-\"#)?,\n            vec![Search(UnqualifiedText(r#\"\":()-\"#.into())),]\n        );\n\n        // parser doesn't unescape unescape \\*_\n        assert_eq!(\n            parse(r\"\\\\\\*\\_\")?,\n            vec![Search(UnqualifiedText(r\"\\\\\\*\\_\".into())),]\n        );\n\n        // escaping parentheses is optional (only) inside quotes\n        assert_eq!(parse(r#\"\"\\)\\(\"\"#), parse(r#\"\")(\"\"#));\n\n        // escaping : is optional if it is preceded by another :\n        assert_eq!(parse(\"field:val:ue\"), parse(r\"field:val\\:ue\"));\n        assert_eq!(parse(r#\"\"field:val:ue\"\"#), parse(r\"field:val\\:ue\"));\n        assert_eq!(parse(r#\"field:\"val:ue\"\"#), parse(r\"field:val\\:ue\"));\n\n        // escaping - is optional if it cannot be mistaken for a negator\n        assert_eq!(parse(\"-\"), parse(r\"\\-\"));\n        assert_eq!(parse(\"A-\"), parse(r\"A\\-\"));\n        assert_eq!(parse(r#\"\"-A\"\"#), parse(r\"\\-A\"));\n        assert_ne!(parse(\"-A\"), parse(r\"\\-A\"));\n\n        // any character should be escapable on the right side of re:\n        assert_eq!(\n            parse(r#\"\"re:\\btest\\%\"\"#)?,\n            vec![Search(Regex(r\"\\btest\\%\".into()))]\n        );\n\n        // no exceptions for escaping \"\n        assert_eq!(\n            parse(r#\"re:te\\\"st\"#)?,\n            vec![Search(Regex(r#\"te\"st\"#.into()))]\n        );\n\n        // spaces are optional if node separation is clear\n        assert_eq!(parse(r#\"a\"b\"(c)\"#)?, parse(\"a b (c)\")?);\n\n        assert_eq!(parse(\"added:3\")?, vec![Search(AddedInDays(3))]);\n        assert_eq!(\n            parse(\"card:front\")?,\n            vec![Search(CardTemplate(TemplateKind::Name(\"front\".into())))]\n        );\n        assert_eq!(\n            parse(\"card:3\")?,\n            vec![Search(CardTemplate(TemplateKind::Ordinal(2)))]\n        );\n        // 0 must not cause a crash due to underflow\n        assert_eq!(\n            parse(\"card:0\")?,\n            vec![Search(CardTemplate(TemplateKind::Ordinal(0)))]\n        );\n        assert_eq!(parse(\"deck:default\")?, vec![Search(Deck(\"default\".into()))]);\n        assert_eq!(\n            parse(\"deck:\\\"default one\\\"\")?,\n            vec![Search(Deck(\"default one\".into()))]\n        );\n\n        assert_eq!(\n            parse(\"preset:default\")?,\n            vec![Search(Preset(\"default\".into()))]\n        );\n\n        assert_eq!(parse(\"note:basic\")?, vec![Search(Notetype(\"basic\".into()))]);\n        assert_eq!(\n            parse(\"tag:hard\")?,\n            vec![Search(Tag {\n                tag: \"hard\".into(),\n                mode: FieldSearchMode::Normal\n            })]\n        );\n        assert_eq!(\n            parse(r\"tag:re:\\\\\")?,\n            vec![Search(Tag {\n                tag: r\"\\\\\".into(),\n                mode: FieldSearchMode::Regex\n            })]\n        );\n        assert_eq!(\n            parse(\"nid:1237123712,2,3\")?,\n            vec![Search(NoteIds(\"1237123712,2,3\".into()))]\n        );\n        assert_eq!(parse(\"is:due\")?, vec![Search(State(StateKind::Due))]);\n        assert_eq!(parse(\"flag:3\")?, vec![Search(Flag(3))]);\n\n        assert_eq!(\n            parse(\"prop:ivl>3\")?,\n            vec![Search(Property {\n                operator: \">\".into(),\n                kind: PropertyKind::Interval(3)\n            })]\n        );\n        assert_eq!(\n            parse(\"prop:ease<=3.3\")?,\n            vec![Search(Property {\n                operator: \"<=\".into(),\n                kind: PropertyKind::Ease(3.3)\n            })]\n        );\n        assert_eq!(\n            parse(\"prop:cdn:abc<=1\")?,\n            vec![Search(Property {\n                operator: \"<=\".into(),\n                kind: PropertyKind::CustomDataNumber {\n                    key: \"abc\".into(),\n                    value: 1.0\n                }\n            })]\n        );\n        assert_eq!(\n            parse(\"prop:cds:abc=foo\")?,\n            vec![Search(Property {\n                operator: \"=\".into(),\n                kind: PropertyKind::CustomDataString {\n                    key: \"abc\".into(),\n                    value: \"foo\".into()\n                }\n            })]\n        );\n        assert_eq!(\n            parse(\"\\\"prop:cds:abc=foo bar\\\"\")?,\n            vec![Search(Property {\n                operator: \"=\".into(),\n                kind: PropertyKind::CustomDataString {\n                    key: \"abc\".into(),\n                    value: \"foo bar\".into()\n                }\n            })]\n        );\n        assert_eq!(parse(\"has-cd:r\")?, vec![Search(CustomData(\"r\".into()))]);\n\n        Ok(())\n    }\n\n    #[test]\n    fn errors() {\n        use FailKind::*;\n\n        use crate::error::AnkiError;\n\n        fn assert_err_kind(input: &str, kind: FailKind) {\n            assert_eq!(parse(input), Err(AnkiError::SearchError { source: kind }));\n        }\n\n        fn failkind(input: &str) -> SearchErrorKind {\n            if let Err(AnkiError::SearchError { source: err }) = parse(input) {\n                err\n            } else {\n                panic!(\"expected search error\");\n            }\n        }\n\n        assert_err_kind(\"foo and\", MisplacedAnd);\n        assert_err_kind(\"and foo\", MisplacedAnd);\n        assert_err_kind(\"and\", MisplacedAnd);\n\n        assert_err_kind(\"foo or\", MisplacedOr);\n        assert_err_kind(\"or foo\", MisplacedOr);\n        assert_err_kind(\"or\", MisplacedOr);\n\n        assert_err_kind(\"()\", EmptyGroup);\n        assert_err_kind(\"( )\", EmptyGroup);\n        assert_err_kind(\"(foo () bar)\", EmptyGroup);\n\n        assert_err_kind(\")\", UnopenedGroup);\n        assert_err_kind(\"foo ) bar\", UnopenedGroup);\n        assert_err_kind(\"(foo) bar)\", UnopenedGroup);\n\n        assert_err_kind(\"(\", UnclosedGroup);\n        assert_err_kind(\"foo ( bar\", UnclosedGroup);\n        assert_err_kind(\"(foo (bar)\", UnclosedGroup);\n\n        assert_err_kind(r#\"\"\"\"#, EmptyQuote);\n        assert_err_kind(r#\"foo:\"\"\"#, EmptyQuote);\n\n        assert_err_kind(r#\" \" \"#, UnclosedQuote);\n        assert_err_kind(r#\"\" foo\"#, UnclosedQuote);\n        assert_err_kind(r#\"\"\\\"#, UnclosedQuote);\n        assert_err_kind(r#\"foo:\"bar\"#, UnclosedQuote);\n        assert_err_kind(r#\"foo:\"bar\\\"#, UnclosedQuote);\n\n        assert_err_kind(\":\", MissingKey);\n        assert_err_kind(\":foo\", MissingKey);\n        assert_err_kind(r#\":\"foo\"\"#, MissingKey);\n\n        assert_err_kind(\n            r\"\\\",\n            UnknownEscape {\n                provided: r\"\\\".to_string(),\n            },\n        );\n        assert_err_kind(\n            r\"\\%\",\n            UnknownEscape {\n                provided: r\"\\%\".to_string(),\n            },\n        );\n        assert_err_kind(\n            r\"foo\\\",\n            UnknownEscape {\n                provided: r\"\\\".to_string(),\n            },\n        );\n        assert_err_kind(\n            r\"\\foo\",\n            UnknownEscape {\n                provided: r\"\\f\".to_string(),\n            },\n        );\n        assert_err_kind(\n            r\"\\ \",\n            UnknownEscape {\n                provided: r\"\\\".to_string(),\n            },\n        );\n        assert_err_kind(\n            r#\"\"\\ \"\"#,\n            UnknownEscape {\n                provided: r\"\\ \".to_string(),\n            },\n        );\n\n        for term in &[\n            \"nid:1_2,3\",\n            \"nid:1,2,x\",\n            \"nid:,2,3\",\n            \"nid:1,2,\",\n            \"cid:1_2,3\",\n            \"cid:1,2,x\",\n            \"cid:,2,3\",\n            \"cid:1,2,\",\n        ] {\n            assert!(matches!(failkind(term), SearchErrorKind::Other { .. }));\n        }\n\n        assert_err_kind(\n            \"is:foo\",\n            InvalidState {\n                provided: \"foo\".into(),\n            },\n        );\n        assert_err_kind(\n            \"is:DUE\",\n            InvalidState {\n                provided: \"DUE\".into(),\n            },\n        );\n        assert_err_kind(\n            \"is:New\",\n            InvalidState {\n                provided: \"New\".into(),\n            },\n        );\n        assert_err_kind(\n            \"is:\",\n            InvalidState {\n                provided: \"\".into(),\n            },\n        );\n        assert_err_kind(\n            r#\"\"is:learn \"\"#,\n            InvalidState {\n                provided: \"learn \".into(),\n            },\n        );\n\n        assert_err_kind(r#\"\"flag: \"\"#, InvalidFlag);\n        assert_err_kind(\"flag:-0\", InvalidFlag);\n        assert_err_kind(\"flag:\", InvalidFlag);\n        assert_err_kind(\"flag:8\", InvalidFlag);\n        assert_err_kind(\"flag:1.1\", InvalidFlag);\n\n        for term in &[\"added\", \"edited\", \"rated\", \"resched\"] {\n            assert!(matches!(\n                failkind(&format!(\"{term}:1.1\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"{term}:-1\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"{term}:\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"{term}:foo\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n        }\n\n        assert!(matches!(\n            failkind(\"rated:1:\"),\n            SearchErrorKind::InvalidAnswerButton { .. }\n        ));\n        assert!(matches!(\n            failkind(\"rated:2:-1\"),\n            SearchErrorKind::InvalidAnswerButton { .. }\n        ));\n        assert!(matches!(\n            failkind(\"rated:3:1.1\"),\n            SearchErrorKind::InvalidAnswerButton { .. }\n        ));\n        assert!(matches!(\n            failkind(\"rated:0:foo\"),\n            SearchErrorKind::InvalidAnswerButton { .. }\n        ));\n\n        assert!(matches!(\n            failkind(\"dupe:\"),\n            SearchErrorKind::InvalidWholeNumber { .. }\n        ));\n        assert!(matches!(\n            failkind(\"dupe:1.1\"),\n            SearchErrorKind::InvalidWholeNumber { .. }\n        ));\n        assert!(matches!(\n            failkind(\"dupe:foo\"),\n            SearchErrorKind::InvalidWholeNumber { .. }\n        ));\n\n        assert_err_kind(\n            \"prop:\",\n            InvalidPropProperty {\n                provided: \"\".into(),\n            },\n        );\n        assert_err_kind(\n            \"prop:=1\",\n            InvalidPropProperty {\n                provided: \"=1\".into(),\n            },\n        );\n        assert_err_kind(\n            \"prop:DUE<5\",\n            InvalidPropProperty {\n                provided: \"DUE<5\".into(),\n            },\n        );\n        assert_err_kind(\n            \"prop:cdn=5\",\n            InvalidPropProperty {\n                provided: \"cdn=5\".to_string(),\n            },\n        );\n        assert_err_kind(\n            \"prop:cdn:=5\",\n            InvalidPropProperty {\n                provided: \"cdn:=5\".to_string(),\n            },\n        );\n        assert_err_kind(\n            \"prop:cds=s\",\n            InvalidPropProperty {\n                provided: \"cds=s\".to_string(),\n            },\n        );\n        assert_err_kind(\n            \"prop:cds:=s\",\n            InvalidPropProperty {\n                provided: \"cds:=s\".to_string(),\n            },\n        );\n\n        assert_err_kind(\n            \"prop:lapses\",\n            InvalidPropOperator {\n                provided: \"lapses\".to_string(),\n            },\n        );\n        assert_err_kind(\n            \"prop:pos~1\",\n            InvalidPropOperator {\n                provided: \"pos\".to_string(),\n            },\n        );\n        assert_err_kind(\n            \"prop:reps10\",\n            InvalidPropOperator {\n                provided: \"reps\".to_string(),\n            },\n        );\n\n        // unsigned\n\n        for term in &[\"ivl\", \"reps\", \"lapses\", \"pos\"] {\n            assert!(matches!(\n                failkind(&format!(\"prop:{term}>\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"prop:{term}=0.5\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"prop:{term}!=-1\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n            assert!(matches!(\n                failkind(&format!(\"prop:{term}<foo\")),\n                SearchErrorKind::InvalidPositiveWholeNumber { .. }\n            ));\n        }\n\n        // signed\n\n        assert!(matches!(\n            failkind(\"prop:due>\"),\n            SearchErrorKind::InvalidWholeNumber { .. }\n        ));\n        assert!(matches!(\n            failkind(\"prop:due=0.5\"),\n            SearchErrorKind::InvalidWholeNumber { .. }\n        ));\n\n        // float\n\n        assert!(matches!(\n            failkind(\"prop:ease>\"),\n            SearchErrorKind::InvalidNumber { .. }\n        ));\n        assert!(matches!(\n            failkind(\"prop:ease!=one\"),\n            SearchErrorKind::InvalidNumber { .. }\n        ));\n        assert!(matches!(\n            failkind(\"prop:ease<1,3\"),\n            SearchErrorKind::InvalidNumber { .. }\n        ));\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/service/browser_table.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::str::FromStr;\n\nuse anki_i18n::I18n;\n\nuse crate::browser_table;\n\nimpl browser_table::Column {\n    pub fn to_pb_column(self, i18n: &I18n) -> anki_proto::search::browser_columns::Column {\n        anki_proto::search::browser_columns::Column {\n            key: self.to_string(),\n            cards_mode_label: self.cards_mode_label(i18n),\n            notes_mode_label: self.notes_mode_label(i18n),\n            sorting_cards: self.default_cards_order() as i32,\n            sorting_notes: self.default_notes_order() as i32,\n            uses_cell_font: self.uses_cell_font(),\n            alignment: self.alignment() as i32,\n            cards_mode_tooltip: self.cards_mode_tooltip(i18n),\n            notes_mode_tooltip: self.notes_mode_tooltip(i18n),\n        }\n    }\n}\n\npub(crate) fn string_list_to_browser_columns(\n    list: anki_proto::generic::StringList,\n) -> Vec<browser_table::Column> {\n    list.vals\n        .into_iter()\n        .map(|c| browser_table::Column::from_str(&c).unwrap_or_default())\n        .collect()\n}\n"
  },
  {
    "path": "rslib/src/search/service/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod browser_table;\nmod search_node;\n\nuse std::str::FromStr;\nuse std::sync::Arc;\n\nuse anki_proto::generic;\nuse anki_proto::search::sort_order::Value as SortOrderProto;\n\nuse crate::browser_table::Column;\nuse crate::notes::service::to_note_ids;\nuse crate::prelude::*;\nuse crate::search::replace_search_node;\nuse crate::search::service::browser_table::string_list_to_browser_columns;\nuse crate::search::JoinSearches;\nuse crate::search::Node;\nuse crate::search::SortMode;\n\nimpl crate::services::SearchService for Collection {\n    fn build_search_string(\n        &mut self,\n        input: anki_proto::search::SearchNode,\n    ) -> Result<generic::String> {\n        let node: Node = input.try_into()?;\n        Ok(SearchBuilder::from_root(node).write().into())\n    }\n\n    fn search_cards(\n        &mut self,\n        input: anki_proto::search::SearchRequest,\n    ) -> Result<anki_proto::search::SearchResponse> {\n        let order = input.order.unwrap_or_default().value.into();\n        let cids = self.search_cards(&input.search, order)?;\n        Ok(anki_proto::search::SearchResponse {\n            ids: cids.into_iter().map(|v| v.0).collect(),\n        })\n    }\n\n    fn search_notes(\n        &mut self,\n        input: anki_proto::search::SearchRequest,\n    ) -> Result<anki_proto::search::SearchResponse> {\n        let order = input.order.unwrap_or_default().value.into();\n        let nids = self.search_notes(&input.search, order)?;\n        Ok(anki_proto::search::SearchResponse {\n            ids: nids.into_iter().map(|v| v.0).collect(),\n        })\n    }\n\n    fn join_search_nodes(\n        &mut self,\n        input: anki_proto::search::JoinSearchNodesRequest,\n    ) -> Result<generic::String> {\n        let existing_node: Node = input.existing_node.unwrap_or_default().try_into()?;\n        let additional_node: Node = input.additional_node.unwrap_or_default().try_into()?;\n\n        Ok(\n            match anki_proto::search::search_node::group::Joiner::try_from(input.joiner)\n                .unwrap_or_default()\n            {\n                anki_proto::search::search_node::group::Joiner::And => {\n                    existing_node.and_flat(additional_node)\n                }\n                anki_proto::search::search_node::group::Joiner::Or => {\n                    existing_node.or_flat(additional_node)\n                }\n            }\n            .write()\n            .into(),\n        )\n    }\n\n    fn replace_search_node(\n        &mut self,\n        input: anki_proto::search::ReplaceSearchNodeRequest,\n    ) -> Result<generic::String> {\n        let existing = {\n            let node = input.existing_node.unwrap_or_default().try_into()?;\n            if let Node::Group(nodes) = node {\n                nodes\n            } else {\n                vec![node]\n            }\n        };\n        let replacement = input.replacement_node.unwrap_or_default().try_into()?;\n        Ok(replace_search_node(existing, replacement).into())\n    }\n\n    fn find_and_replace(\n        &mut self,\n        input: anki_proto::search::FindAndReplaceRequest,\n    ) -> Result<anki_proto::collection::OpChangesWithCount> {\n        let mut search = if input.regex {\n            input.search\n        } else {\n            regex::escape(&input.search)\n        };\n        if !input.match_case {\n            search = format!(\"(?i){search}\");\n        }\n        let mut nids = to_note_ids(input.nids);\n        let field_name = if input.field_name.is_empty() {\n            None\n        } else {\n            Some(input.field_name)\n        };\n        let repl = input.replacement;\n\n        if nids.is_empty() {\n            nids = self.search_notes_unordered(\"\")?\n        };\n        self.find_and_replace(nids, &search, &repl, field_name)\n            .map(Into::into)\n    }\n\n    fn all_browser_columns(&mut self) -> Result<anki_proto::search::BrowserColumns> {\n        Ok(Collection::all_browser_columns(self))\n    }\n\n    fn set_active_browser_columns(&mut self, input: generic::StringList) -> Result<()> {\n        self.state.active_browser_columns = Some(Arc::new(string_list_to_browser_columns(input)));\n        Ok(())\n    }\n\n    fn browser_row_for_id(\n        &mut self,\n        input: generic::Int64,\n    ) -> Result<anki_proto::search::BrowserRow> {\n        self.browser_row_for_id(input.val)\n    }\n}\n\nimpl From<Option<SortOrderProto>> for SortMode {\n    fn from(order: Option<SortOrderProto>) -> Self {\n        use anki_proto::search::sort_order::Value as V;\n        match order.unwrap_or(V::None(generic::Empty {})) {\n            V::None(_) => SortMode::NoOrder,\n            V::Custom(s) => SortMode::Custom(s),\n            V::Builtin(b) => SortMode::Builtin {\n                column: Column::from_str(&b.column).unwrap_or_default(),\n                reverse: b.reverse,\n            },\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/service/search_node.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::search::search_node::IdList;\nuse itertools::Itertools;\n\nuse crate::prelude::*;\nuse crate::search::parse_search;\nuse crate::search::FieldSearchMode;\nuse crate::search::Negated;\nuse crate::search::Node;\nuse crate::search::PropertyKind;\nuse crate::search::RatingKind;\nuse crate::search::SearchNode;\nuse crate::search::StateKind;\nuse crate::search::TemplateKind;\nuse crate::text::escape_anki_wildcards;\nuse crate::text::escape_anki_wildcards_for_search_node;\n\nimpl TryFrom<anki_proto::search::SearchNode> for Node {\n    type Error = AnkiError;\n\n    fn try_from(msg: anki_proto::search::SearchNode) -> std::result::Result<Self, Self::Error> {\n        use anki_proto::search::search_node::group::Joiner;\n        use anki_proto::search::search_node::Filter;\n        use anki_proto::search::search_node::Flag;\n        Ok(if let Some(filter) = msg.filter {\n            match filter {\n                Filter::Tag(s) => SearchNode::from_tag_name(&s).into(),\n                Filter::Deck(s) => SearchNode::from_deck_name(&s).into(),\n                Filter::Note(s) => SearchNode::from_notetype_name(&s).into(),\n                Filter::Template(u) => {\n                    Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16)))\n                }\n                Filter::Nid(nid) => Node::Search(SearchNode::NoteIds(nid.to_string())),\n                Filter::Nids(nids) => Node::Search(SearchNode::NoteIds(id_list_to_string(nids))),\n                Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates {\n                    notetype_id: dupe.notetype_id.into(),\n                    text: dupe.first_field,\n                }),\n                Filter::FieldName(s) => Node::Search(SearchNode::SingleField {\n                    field: escape_anki_wildcards_for_search_node(&s),\n                    text: \"_*\".to_string(),\n                    mode: FieldSearchMode::Normal,\n                }),\n                Filter::Rated(rated) => Node::Search(SearchNode::Rated {\n                    days: rated.days,\n                    ease: rated.rating().into(),\n                }),\n                Filter::AddedInDays(u) => Node::Search(SearchNode::AddedInDays(u)),\n                Filter::IntroducedInDays(u) => Node::Search(SearchNode::IntroducedInDays(u)),\n                Filter::DueInDays(i) => Node::Search(SearchNode::Property {\n                    operator: \"<=\".to_string(),\n                    kind: PropertyKind::Due(i),\n                }),\n                Filter::DueOnDay(i) => Node::Search(SearchNode::Property {\n                    operator: \"=\".to_string(),\n                    kind: PropertyKind::Due(i),\n                }),\n                Filter::EditedInDays(u) => Node::Search(SearchNode::EditedInDays(u)),\n                Filter::CardState(state) => Node::Search(SearchNode::State(\n                    anki_proto::search::search_node::CardState::try_from(state)\n                        .unwrap_or_default()\n                        .into(),\n                )),\n                Filter::Flag(flag) => match Flag::try_from(flag).unwrap_or(Flag::Any) {\n                    Flag::None => Node::Search(SearchNode::Flag(0)),\n                    Flag::Any => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))),\n                    Flag::Red => Node::Search(SearchNode::Flag(1)),\n                    Flag::Orange => Node::Search(SearchNode::Flag(2)),\n                    Flag::Green => Node::Search(SearchNode::Flag(3)),\n                    Flag::Blue => Node::Search(SearchNode::Flag(4)),\n                    Flag::Pink => Node::Search(SearchNode::Flag(5)),\n                    Flag::Turquoise => Node::Search(SearchNode::Flag(6)),\n                    Flag::Purple => Node::Search(SearchNode::Flag(7)),\n                },\n                Filter::Negated(term) => Node::try_from(*term)?.negated(),\n                Filter::Group(mut group) => {\n                    match group.nodes.len() {\n                        0 => invalid_input!(\"empty group\"),\n                        // a group of 1 doesn't need to be a group\n                        1 => group.nodes.pop().unwrap().try_into()?,\n                        // 2+ nodes\n                        _ => {\n                            let joiner = match group.joiner() {\n                                Joiner::And => Node::And,\n                                Joiner::Or => Node::Or,\n                            };\n                            let parsed: Vec<_> = group\n                                .nodes\n                                .into_iter()\n                                .map(TryFrom::try_from)\n                                .collect::<Result<_>>()?;\n                            let joined =\n                                Itertools::intersperse(parsed.into_iter(), joiner).collect();\n                            Node::Group(joined)\n                        }\n                    }\n                }\n                Filter::ParsableText(text) => {\n                    let mut nodes = parse_search(&text)?;\n                    if nodes.len() == 1 {\n                        nodes.pop().unwrap()\n                    } else {\n                        Node::Group(nodes)\n                    }\n                }\n                Filter::Field(field) => Node::Search(SearchNode::SingleField {\n                    field: escape_anki_wildcards(&field.field_name),\n                    text: escape_anki_wildcards(&field.text),\n                    mode: field.mode().into(),\n                }),\n                Filter::LiteralText(text) => {\n                    let text = escape_anki_wildcards(&text);\n                    Node::Search(SearchNode::UnqualifiedText(text))\n                }\n            }\n        } else {\n            Node::Search(SearchNode::WholeCollection)\n        })\n    }\n}\n\nimpl From<anki_proto::search::search_node::Rating> for RatingKind {\n    fn from(r: anki_proto::search::search_node::Rating) -> Self {\n        match r {\n            anki_proto::search::search_node::Rating::Again => RatingKind::AnswerButton(1),\n            anki_proto::search::search_node::Rating::Hard => RatingKind::AnswerButton(2),\n            anki_proto::search::search_node::Rating::Good => RatingKind::AnswerButton(3),\n            anki_proto::search::search_node::Rating::Easy => RatingKind::AnswerButton(4),\n            anki_proto::search::search_node::Rating::Any => RatingKind::AnyAnswerButton,\n            anki_proto::search::search_node::Rating::ByReschedule => RatingKind::ManualReschedule,\n        }\n    }\n}\n\nimpl From<anki_proto::search::search_node::CardState> for StateKind {\n    fn from(k: anki_proto::search::search_node::CardState) -> Self {\n        match k {\n            anki_proto::search::search_node::CardState::New => StateKind::New,\n            anki_proto::search::search_node::CardState::Learn => StateKind::Learning,\n            anki_proto::search::search_node::CardState::Review => StateKind::Review,\n            anki_proto::search::search_node::CardState::Due => StateKind::Due,\n            anki_proto::search::search_node::CardState::Suspended => StateKind::Suspended,\n            anki_proto::search::search_node::CardState::Buried => StateKind::Buried,\n        }\n    }\n}\n\nfn id_list_to_string(list: IdList) -> String {\n    list.ids\n        .iter()\n        .map(|i| i.to_string())\n        .collect::<Vec<_>>()\n        .join(\",\")\n}\n"
  },
  {
    "path": "rslib/src/search/sqlwriter.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::fmt::Write;\nuse std::ops::Range;\n\nuse itertools::Itertools;\n\nuse super::parser::FieldSearchMode;\nuse super::parser::Node;\nuse super::parser::PropertyKind;\nuse super::parser::RatingKind;\nuse super::parser::SearchNode;\nuse super::parser::StateKind;\nuse super::parser::TemplateKind;\nuse super::ReturnItemType;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::collection::Collection;\nuse crate::error::Result;\nuse crate::notes::field_checksum;\nuse crate::notetype::NotetypeId;\nuse crate::prelude::*;\nuse crate::storage::ids_to_string;\nuse crate::storage::ProcessTextFlags;\nuse crate::text::glob_matcher;\nuse crate::text::is_glob;\nuse crate::text::normalize_to_nfc;\nuse crate::text::strip_html_preserving_media_filenames;\nuse crate::text::to_custom_re;\nuse crate::text::to_re;\nuse crate::text::to_sql;\nuse crate::text::to_text;\nuse crate::text::without_combining;\nuse crate::timestamp::TimestampSecs;\n\npub(crate) struct SqlWriter<'a> {\n    col: &'a mut Collection,\n    sql: String,\n    item_type: ReturnItemType,\n    args: Vec<String>,\n    normalize_note_text: bool,\n    table: RequiredTable,\n}\n\nimpl SqlWriter<'_> {\n    pub(crate) fn new(col: &mut Collection, item_type: ReturnItemType) -> SqlWriter<'_> {\n        let normalize_note_text = col.get_config_bool(BoolKey::NormalizeNoteText);\n        let sql = String::new();\n        let args = vec![];\n        SqlWriter {\n            col,\n            sql,\n            item_type,\n            args,\n            normalize_note_text,\n            table: item_type.required_table(),\n        }\n    }\n\n    pub(super) fn build_query(\n        mut self,\n        node: &Node,\n        table: RequiredTable,\n    ) -> Result<(String, Vec<String>)> {\n        self.table = self.table.combine(table.combine(node.required_table()));\n        self.write_table_sql();\n        self.write_node_to_sql(node)?;\n        Ok((self.sql, self.args))\n    }\n\n    fn write_table_sql(&mut self) {\n        let sql = match self.table {\n            RequiredTable::Cards => \"select c.id from cards c where \",\n            RequiredTable::Notes => \"select n.id from notes n where \",\n            _ => match self.item_type {\n                ReturnItemType::Cards => \"select c.id from cards c, notes n where c.nid=n.id and \",\n                ReturnItemType::Notes => {\n                    \"select distinct n.id from cards c, notes n where c.nid=n.id and \"\n                }\n            },\n        };\n        self.sql.push_str(sql);\n    }\n\n    /// As an optimization we can omit the cards or notes tables from\n    /// certain queries. For code that specifies a note id, we need to\n    /// choose the appropriate column name.\n    fn note_id_column(&self) -> &'static str {\n        match self.table {\n            RequiredTable::Notes | RequiredTable::CardsAndNotes => \"n.id\",\n            RequiredTable::Cards => \"c.nid\",\n            RequiredTable::CardsOrNotes => unreachable!(),\n        }\n    }\n\n    fn write_node_to_sql(&mut self, node: &Node) -> Result<()> {\n        match node {\n            Node::And => write!(self.sql, \" and \").unwrap(),\n            Node::Or => write!(self.sql, \" or \").unwrap(),\n            Node::Not(node) => {\n                write!(self.sql, \"not \").unwrap();\n                self.write_node_to_sql(node)?;\n            }\n            Node::Group(nodes) => {\n                write!(self.sql, \"(\").unwrap();\n                for node in nodes {\n                    self.write_node_to_sql(node)?;\n                }\n                write!(self.sql, \")\").unwrap();\n            }\n            Node::Search(search) => self.write_search_node_to_sql(search)?,\n        };\n        Ok(())\n    }\n\n    /// Convert search text to NFC if note normalization is enabled.\n    fn norm_note<'a>(&self, text: &'a str) -> Cow<'a, str> {\n        if self.normalize_note_text {\n            normalize_to_nfc(text)\n        } else {\n            text.into()\n        }\n    }\n\n    // NOTE: when adding any new nodes in the future, make sure that they are either\n    // a single search term, or they wrap multiple terms in parentheses, as can\n    // be seen in the sql() unit test at the bottom of the file.\n    fn write_search_node_to_sql(&mut self, node: &SearchNode) -> Result<()> {\n        use normalize_to_nfc as norm;\n        match node {\n            // note fields related\n            SearchNode::UnqualifiedText(text) => {\n                let text = &self.norm_note(text);\n                self.write_unqualified(\n                    text,\n                    self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch),\n                    false,\n                )?\n            }\n            SearchNode::SingleField { field, text, mode } => {\n                self.write_field(&norm(field), &self.norm_note(text), *mode)?\n            }\n            SearchNode::Duplicates { notetype_id, text } => {\n                self.write_dupe(*notetype_id, &self.norm_note(text))?\n            }\n            SearchNode::Regex(re) => self.write_regex(&self.norm_note(re), false)?,\n            SearchNode::NoCombining(text) => {\n                self.write_unqualified(&self.norm_note(text), true, false)?\n            }\n            SearchNode::StripClozes(text) => self.write_unqualified(\n                &self.norm_note(text),\n                self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch),\n                true,\n            )?,\n            SearchNode::WordBoundary(text) => self.write_word_boundary(&self.norm_note(text))?,\n\n            // other\n            SearchNode::AddedInDays(days) => self.write_added(*days)?,\n            SearchNode::EditedInDays(days) => self.write_edited(*days)?,\n            SearchNode::IntroducedInDays(days) => self.write_introduced(*days)?,\n            SearchNode::CardTemplate(template) => match template {\n                TemplateKind::Ordinal(_) => self.write_template(template),\n                TemplateKind::Name(name) => {\n                    self.write_template(&TemplateKind::Name(norm(name).into()))\n                }\n            },\n            SearchNode::Deck(deck) => self.write_deck(&norm(deck))?,\n            SearchNode::NotetypeId(ntid) => {\n                write!(self.sql, \"n.mid = {ntid}\").unwrap();\n            }\n            SearchNode::DeckIdsWithoutChildren(dids) => {\n                write!(\n                    self.sql,\n                    \"c.did in ({dids}) or (c.odid != 0 and c.odid in ({dids}))\"\n                )\n                .unwrap();\n            }\n            SearchNode::DeckIdWithChildren(did) => self.write_deck_id_with_children(*did)?,\n            SearchNode::Notetype(notetype) => self.write_notetype(&norm(notetype)),\n            SearchNode::Rated { days, ease } => self.write_rated(\">\", -i64::from(*days), ease)?,\n\n            SearchNode::Tag { tag, mode } => self.write_tag(&norm(tag), *mode),\n            SearchNode::State(state) => self.write_state(state)?,\n            SearchNode::Flag(flag) => {\n                write!(self.sql, \"(c.flags & 7) == {flag}\").unwrap();\n            }\n            SearchNode::NoteIds(nids) => {\n                write!(self.sql, \"{} in ({})\", self.note_id_column(), nids).unwrap();\n            }\n            SearchNode::CardIds(cids) => {\n                write!(self.sql, \"c.id in ({cids})\").unwrap();\n            }\n            SearchNode::Property { operator, kind } => self.write_prop(operator, kind)?,\n            SearchNode::CustomData(key) => self.write_custom_data(key)?,\n            SearchNode::WholeCollection => write!(self.sql, \"true\").unwrap(),\n            SearchNode::Preset(name) => self.write_deck_preset(name)?,\n        };\n        Ok(())\n    }\n\n    fn write_unqualified(\n        &mut self,\n        text: &str,\n        no_combining: bool,\n        strip_clozes: bool,\n    ) -> Result<()> {\n        let text = to_sql(text);\n        let text = if no_combining {\n            without_combining(&text)\n        } else {\n            text\n        };\n        // implicitly wrap in %\n        let text = format!(\"%{text}%\");\n        self.args.push(text);\n        let arg_idx = self.args.len();\n\n        let mut process_text_flags = ProcessTextFlags::empty();\n        if no_combining {\n            process_text_flags.insert(ProcessTextFlags::NoCombining);\n        }\n        if strip_clozes {\n            process_text_flags.insert(ProcessTextFlags::StripClozes);\n        }\n\n        let (sfld_expr, flds_expr) = if !process_text_flags.is_empty() {\n            let bits = process_text_flags.bits();\n            (\n                Cow::from(format!(\n                    \"coalesce(process_text(cast(n.sfld as text), {bits}), n.sfld)\"\n                )),\n                Cow::from(format!(\"coalesce(process_text(n.flds, {bits}), n.flds)\")),\n            )\n        } else {\n            (Cow::from(\"n.sfld\"), Cow::from(\"n.flds\"))\n        };\n\n        if strip_clozes {\n            let cloze_notetypes_only_clause = self\n                .col\n                .get_all_notetypes()?\n                .iter()\n                .filter(|nt| nt.is_cloze())\n                .map(|nt| format!(\"n.mid = {}\", nt.id))\n                .join(\" or \");\n            write!(self.sql, \"({cloze_notetypes_only_clause}) and \").unwrap();\n        }\n\n        if let Some(field_indicies_by_notetype) = self.included_fields_by_notetype()? {\n            let field_idx_str = format!(\"' || ?{arg_idx} || '\");\n            let other_idx_str = \"%\".to_string();\n\n            let notetype_clause = |ctx: &UnqualifiedSearchContext| -> String {\n                let field_index_clause = |range: &Range<u32>| {\n                    let f = (0..ctx.total_fields_in_note)\n                        .filter_map(|i| {\n                            if i as u32 == range.start {\n                                Some(&field_idx_str)\n                            } else if range.contains(&(i as u32)) {\n                                None\n                            } else {\n                                Some(&other_idx_str)\n                            }\n                        })\n                        .join(\"\\x1f\");\n                    format!(\"{flds_expr} like '{f}' escape '\\\\'\")\n                };\n                let mut all_field_clauses: Vec<String> = ctx\n                    .field_ranges_to_search\n                    .iter()\n                    .map(field_index_clause)\n                    .collect();\n                if !ctx.sortf_excluded {\n                    all_field_clauses.push(format!(\"{sfld_expr} like ?{arg_idx} escape '\\\\'\"));\n                }\n                format!(\n                    \"(n.mid = {mid} and ({all_field_clauses}))\",\n                    mid = ctx.ntid,\n                    all_field_clauses = all_field_clauses.join(\" or \")\n                )\n            };\n            let all_notetype_clauses = field_indicies_by_notetype\n                .iter()\n                .map(notetype_clause)\n                .join(\" or \");\n            write!(self.sql, \"({all_notetype_clauses})\").unwrap();\n        } else {\n            write!(\n                self.sql,\n                \"({sfld_expr} like ?{arg_idx} escape '\\\\' or {flds_expr} like ?{arg_idx} escape '\\\\')\"\n            )\n            .unwrap();\n        }\n\n        Ok(())\n    }\n\n    fn write_tag(&mut self, tag: &str, mode: FieldSearchMode) {\n        if mode == FieldSearchMode::Regex {\n            self.args.push(format!(\"(?i){tag}\"));\n            write!(self.sql, \"regexp_tags(?{}, n.tags)\", self.args.len()).unwrap();\n        } else {\n            match tag {\n                \"none\" => {\n                    write!(self.sql, \"n.tags = ''\").unwrap();\n                }\n                \"*\" => {\n                    write!(self.sql, \"true\").unwrap();\n                }\n                s if s.contains(' ') => write!(self.sql, \"false\").unwrap(),\n                text => {\n                    let text = if mode == FieldSearchMode::Normal {\n                        write!(self.sql, \"n.tags regexp ?\").unwrap();\n                        Cow::from(text)\n                    } else {\n                        write!(\n                            self.sql,\n                            \"coalesce(process_text(n.tags, {}), n.tags) regexp ?\",\n                            ProcessTextFlags::NoCombining.bits()\n                        )\n                        .unwrap();\n                        without_combining(text)\n                    };\n                    let re = &to_custom_re(&text, r\"\\S\");\n                    self.args.push(format!(\"(?i).* {re}(::| ).*\"));\n                }\n            }\n        }\n    }\n\n    fn write_rated(&mut self, op: &str, days: i64, ease: &RatingKind) -> Result<()> {\n        let today_cutoff = self.col.timing_today()?.next_day_at;\n        let target_cutoff_ms = today_cutoff.adding_secs(86_400 * days).as_millis();\n        let day_before_cutoff_ms = today_cutoff.adding_secs(86_400 * (days - 1)).as_millis();\n\n        write!(self.sql, \"c.id in (select cid from revlog where id\").unwrap();\n\n        match op {\n            \">\" => write!(self.sql, \" >= {target_cutoff_ms}\"),\n            \">=\" => write!(self.sql, \" >= {day_before_cutoff_ms}\"),\n            \"<\" => write!(self.sql, \" < {day_before_cutoff_ms}\"),\n            \"<=\" => write!(self.sql, \" < {target_cutoff_ms}\"),\n            \"=\" => write!(\n                self.sql,\n                \" between {} and {}\",\n                day_before_cutoff_ms,\n                target_cutoff_ms.0 - 1\n            ),\n            \"!=\" => write!(\n                self.sql,\n                \" not between {} and {}\",\n                day_before_cutoff_ms,\n                target_cutoff_ms.0 - 1\n            ),\n            _ => unreachable!(\"unexpected op\"),\n        }\n        .unwrap();\n\n        match ease {\n            RatingKind::AnswerButton(u) => write!(self.sql, \" and ease = {u})\"),\n            RatingKind::AnyAnswerButton => write!(self.sql, \" and ease > 0)\"),\n            RatingKind::ManualReschedule => write!(self.sql, \" and ease = 0)\"),\n        }\n        .unwrap();\n\n        Ok(())\n    }\n\n    fn write_prop(&mut self, op: &str, kind: &PropertyKind) -> Result<()> {\n        let timing = self.col.timing_today()?;\n\n        match kind {\n            PropertyKind::Due(days) => {\n                let day = days + (timing.days_elapsed as i32);\n                write!(\n                    self.sql,\n                    // SQL does integer division if both parameters are integers\n                    \"(\\\n                    (c.queue in ({rev},{daylrn}) and \n                        (case when c.odue != 0 then c.odue else c.due end) {op} {day}) or \\\n                    (c.queue in ({lrn},{previewrepeat}) and \n                        (((case when c.odue != 0 then c.odue else c.due end) - {cutoff}) / 86400) {op} {days})\\\n                    )\",\n                    rev = CardQueue::Review as u8,\n                    daylrn = CardQueue::DayLearn as u8,\n                    op = op,\n                    day = day,\n                    lrn = CardQueue::Learn as i8,\n                    previewrepeat = CardQueue::PreviewRepeat as i8,\n                    cutoff = timing.next_day_at,\n                    days = days\n                ).unwrap()\n            }\n            PropertyKind::Position(pos) => write!(\n                self.sql,\n                \"(c.type = {t} and (case when c.odue != 0 then c.odue else c.due end) {op} {pos})\",\n                t = CardType::New as u8,\n                op = op,\n                pos = pos\n            )\n            .unwrap(),\n            PropertyKind::Interval(ivl) => write!(self.sql, \"ivl {op} {ivl}\").unwrap(),\n            PropertyKind::Reps(reps) => write!(self.sql, \"reps {op} {reps}\").unwrap(),\n            PropertyKind::Lapses(days) => write!(self.sql, \"lapses {op} {days}\").unwrap(),\n            PropertyKind::Ease(ease) => {\n                write!(self.sql, \"factor {} {}\", op, (ease * 1000.0) as u32).unwrap()\n            }\n            PropertyKind::Rated(days, ease) => self.write_rated(op, i64::from(*days), ease)?,\n            PropertyKind::CustomDataNumber { key, value } => {\n                write!(\n                    self.sql,\n                    \"cast(extract_custom_data(c.data, '{key}') as float) {op} {value}\"\n                )\n                .unwrap();\n            }\n            PropertyKind::CustomDataString { key, value } => {\n                write!(\n                    self.sql,\n                    \"extract_custom_data(c.data, '{key}') {op} '{value}'\"\n                )\n                .unwrap();\n            }\n            PropertyKind::Stability(s) => {\n                write!(self.sql, \"extract_fsrs_variable(c.data, 's') {op} {s}\").unwrap()\n            }\n            PropertyKind::Difficulty(d) => {\n                let d = d * 9.0 + 1.0;\n                write!(self.sql, \"extract_fsrs_variable(c.data, 'd') {op} {d}\").unwrap()\n            }\n            PropertyKind::Retrievability(r) => {\n                let (elap, next_day_at, now) = {\n                    let timing = self.col.timing_today()?;\n                    (timing.days_elapsed, timing.next_day_at, timing.now)\n                };\n                const NEW_TYPE: i8 = CardType::New as i8;\n                write!(\n                    self.sql,\n                    \"case when c.type = {NEW_TYPE} then false else (extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {elap}, {next_day_at}, {now}) {op} {r}) end\"\n                )\n                .unwrap()\n            }\n        }\n\n        Ok(())\n    }\n\n    fn write_custom_data(&mut self, key: &str) -> Result<()> {\n        write!(self.sql, \"extract_custom_data(c.data, '{key}') is not null\").unwrap();\n\n        Ok(())\n    }\n\n    fn write_state(&mut self, state: &StateKind) -> Result<()> {\n        let timing = self.col.timing_today()?;\n        match state {\n            StateKind::New => write!(self.sql, \"c.type = {}\", CardType::New as i8),\n            StateKind::Review => write!(\n                self.sql,\n                \"c.type in ({}, {})\",\n                CardType::Review as i8,\n                CardType::Relearn as i8,\n            ),\n            StateKind::Learning => write!(\n                self.sql,\n                \"c.type in ({}, {})\",\n                CardType::Learn as i8,\n                CardType::Relearn as i8,\n            ),\n            StateKind::Buried => write!(\n                self.sql,\n                \"c.queue in ({},{})\",\n                CardQueue::SchedBuried as i8,\n                CardQueue::UserBuried as i8\n            ),\n            StateKind::Suspended => write!(self.sql, \"c.queue = {}\", CardQueue::Suspended as i8),\n            StateKind::Due => write!(\n                self.sql,\n                \"(\\\n                (c.queue in ({rev},{daylrn}) and c.due <= {today}) or \\\n                (c.queue in ({lrn},{previewrepeat}) and c.due <= {learncutoff})\\\n                )\",\n                rev = CardQueue::Review as i8,\n                daylrn = CardQueue::DayLearn as i8,\n                today = timing.days_elapsed,\n                lrn = CardQueue::Learn as i8,\n                previewrepeat = CardQueue::PreviewRepeat as i8,\n                learncutoff = TimestampSecs::now().0 + (self.col.learn_ahead_secs() as i64),\n            ),\n            StateKind::UserBuried => write!(self.sql, \"c.queue = {}\", CardQueue::UserBuried as i8),\n            StateKind::SchedBuried => {\n                write!(self.sql, \"c.queue = {}\", CardQueue::SchedBuried as i8)\n            }\n        }\n        .unwrap();\n        Ok(())\n    }\n\n    fn write_deck(&mut self, deck: &str) -> Result<()> {\n        match deck {\n            \"*\" => write!(self.sql, \"true\").unwrap(),\n            \"filtered\" => write!(self.sql, \"c.odid != 0\").unwrap(),\n            deck => {\n                // rewrite \"current\" to the current deck name\n                let native_deck = if deck == \"current\" {\n                    let current_did = self.col.get_current_deck_id();\n                    regex::escape(\n                        self.col\n                            .storage\n                            .get_deck(current_did)?\n                            .map(|d| d.name)\n                            .unwrap_or_else(|| NativeDeckName::from_native_str(\"Default\"))\n                            .as_native_str(),\n                    )\n                } else {\n                    NativeDeckName::from_human_name(to_re(deck))\n                        .as_native_str()\n                        .to_string()\n                };\n\n                // convert to a regex that includes child decks\n                self.args.push(format!(\"(?i)^{native_deck}($|\\x1f)\"));\n                let arg_idx = self.args.len();\n                self.sql.push_str(&format!(concat!(\n                    \"(c.did in (select id from decks where name regexp ?{n})\",\n                    \" or (c.odid != 0 and c.odid in (select id from decks where name regexp ?{n})))\"),\n                    n=arg_idx\n                ));\n            }\n        };\n        Ok(())\n    }\n\n    fn write_deck_id_with_children(&mut self, deck_id: DeckId) -> Result<()> {\n        if let Some(parent) = self.col.get_deck(deck_id)? {\n            let ids = self.col.storage.deck_id_with_children(&parent)?;\n            let mut buf = String::new();\n            ids_to_string(&mut buf, &ids);\n            write!(self.sql, \"c.did in {buf}\",).unwrap();\n        } else {\n            self.sql.push_str(\"false\")\n        }\n\n        Ok(())\n    }\n\n    fn write_template(&mut self, template: &TemplateKind) {\n        match template {\n            TemplateKind::Ordinal(n) => {\n                write!(self.sql, \"c.ord = {n}\").unwrap();\n            }\n            TemplateKind::Name(name) => {\n                if is_glob(name) {\n                    let re = format!(\"(?i)^{}$\", to_re(name));\n                    self.sql.push_str(\n                        \"(n.mid,c.ord) in (select ntid,ord from templates where name regexp ?)\",\n                    );\n                    self.args.push(re);\n                } else {\n                    self.sql.push_str(\n                        \"(n.mid,c.ord) in (select ntid,ord from templates where name = ?)\",\n                    );\n                    self.args.push(to_text(name).into());\n                }\n            }\n        };\n    }\n\n    fn write_notetype(&mut self, nt_name: &str) {\n        if is_glob(nt_name) {\n            let re = format!(\"(?i)^{}$\", to_re(nt_name));\n            self.sql\n                .push_str(\"n.mid in (select id from notetypes where name regexp ?)\");\n            self.args.push(re);\n        } else {\n            self.sql\n                .push_str(\"n.mid in (select id from notetypes where name = ?)\");\n            self.args.push(to_text(nt_name).into());\n        }\n    }\n\n    fn write_field(&mut self, field_name: &str, val: &str, mode: FieldSearchMode) -> Result<()> {\n        if matches!(field_name, \"*\" | \"_*\" | \"*_\") {\n            if mode == FieldSearchMode::Regex {\n                self.write_all_fields_regexp(val);\n            } else {\n                self.write_all_fields(val);\n            }\n            Ok(())\n        } else if mode == FieldSearchMode::Regex {\n            self.write_single_field_regexp(field_name, val)\n        } else if mode == FieldSearchMode::NoCombining {\n            self.write_single_field_nc(field_name, val)\n        } else {\n            self.write_single_field(field_name, val)\n        }\n    }\n\n    fn write_all_fields_regexp(&mut self, val: &str) {\n        self.args.push(format!(\"(?i){val}\"));\n        write!(self.sql, \"regexp_fields(?{}, n.flds)\", self.args.len()).unwrap();\n    }\n\n    fn write_all_fields(&mut self, val: &str) {\n        self.args.push(format!(\"(?is)^{}$\", to_re(val)));\n        write!(self.sql, \"regexp_fields(?{}, n.flds)\", self.args.len()).unwrap();\n    }\n\n    fn write_single_field_nc(&mut self, field_name: &str, val: &str) -> Result<()> {\n        let field_indicies_by_notetype = self.num_fields_and_fields_indices_by_notetype(\n            field_name,\n            matches!(val, \"*\" | \"_*\" | \"*_\"),\n        )?;\n        if field_indicies_by_notetype.is_empty() {\n            write!(self.sql, \"false\").unwrap();\n            return Ok(());\n        }\n\n        let val = to_sql(val);\n        let val = without_combining(&val);\n        self.args.push(val.into());\n        let arg_idx = self.args.len();\n        let field_idx_str = format!(\"' || ?{arg_idx} || '\");\n        let other_idx_str = \"%\".to_string();\n\n        let notetype_clause = |ctx: &FieldQualifiedSearchContext| -> String {\n            let field_index_clause = |range: &Range<u32>| {\n                let f = (0..ctx.total_fields_in_note)\n                    .filter_map(|i| {\n                        if i as u32 == range.start {\n                            Some(&field_idx_str)\n                        } else if range.contains(&(i as u32)) {\n                            None\n                        } else {\n                            Some(&other_idx_str)\n                        }\n                    })\n                    .join(\"\\x1f\");\n                format!(\n                    \"coalesce(process_text(n.flds, {}), n.flds) like '{f}' escape '\\\\'\",\n                    ProcessTextFlags::NoCombining.bits()\n                )\n            };\n\n            let all_field_clauses = ctx\n                .field_ranges_to_search\n                .iter()\n                .map(field_index_clause)\n                .join(\" or \");\n            format!(\"(n.mid = {mid} and ({all_field_clauses}))\", mid = ctx.ntid)\n        };\n        let all_notetype_clauses = field_indicies_by_notetype\n            .iter()\n            .map(notetype_clause)\n            .join(\" or \");\n        write!(self.sql, \"({all_notetype_clauses})\").unwrap();\n\n        Ok(())\n    }\n\n    fn write_single_field_regexp(&mut self, field_name: &str, val: &str) -> Result<()> {\n        let field_indicies_by_notetype = self.fields_indices_by_notetype(field_name)?;\n        if field_indicies_by_notetype.is_empty() {\n            write!(self.sql, \"false\").unwrap();\n            return Ok(());\n        }\n\n        self.args.push(format!(\"(?i){val}\"));\n        let arg_idx = self.args.len();\n\n        let all_notetype_clauses = field_indicies_by_notetype\n            .iter()\n            .map(|(mid, field_indices)| {\n                let field_index_list = field_indices.iter().join(\", \");\n                format!(\"(n.mid = {mid} and regexp_fields(?{arg_idx}, n.flds, {field_index_list}))\")\n            })\n            .join(\" or \");\n\n        write!(self.sql, \"({all_notetype_clauses})\").unwrap();\n\n        Ok(())\n    }\n\n    fn write_single_field(&mut self, field_name: &str, val: &str) -> Result<()> {\n        let field_indicies_by_notetype = self.num_fields_and_fields_indices_by_notetype(\n            field_name,\n            matches!(val, \"*\" | \"_*\" | \"*_\"),\n        )?;\n        if field_indicies_by_notetype.is_empty() {\n            write!(self.sql, \"false\").unwrap();\n            return Ok(());\n        }\n\n        self.args.push(to_sql(val).into());\n        let arg_idx = self.args.len();\n        let field_idx_str = format!(\"' || ?{arg_idx} || '\");\n        let other_idx_str = \"%\".to_string();\n\n        let notetype_clause = |ctx: &FieldQualifiedSearchContext| -> String {\n            let field_index_clause = |range: &Range<u32>| {\n                let f = (0..ctx.total_fields_in_note)\n                    .filter_map(|i| {\n                        if i as u32 == range.start {\n                            Some(&field_idx_str)\n                        } else if range.contains(&(i as u32)) {\n                            None\n                        } else {\n                            Some(&other_idx_str)\n                        }\n                    })\n                    .join(\"\\x1f\");\n                format!(\"n.flds like '{f}' escape '\\\\'\")\n            };\n\n            let all_field_clauses = ctx\n                .field_ranges_to_search\n                .iter()\n                .map(field_index_clause)\n                .join(\" or \");\n            format!(\"(n.mid = {mid} and ({all_field_clauses}))\", mid = ctx.ntid)\n        };\n        let all_notetype_clauses = field_indicies_by_notetype\n            .iter()\n            .map(notetype_clause)\n            .join(\" or \");\n        write!(self.sql, \"({all_notetype_clauses})\").unwrap();\n\n        Ok(())\n    }\n\n    fn num_fields_and_fields_indices_by_notetype(\n        &mut self,\n        field_name: &str,\n        test_for_nonempty: bool,\n    ) -> Result<Vec<FieldQualifiedSearchContext>> {\n        let matches_glob = glob_matcher(field_name);\n\n        let mut field_map = vec![];\n        for nt in self.col.get_all_notetypes()? {\n            let matched_fields = nt\n                .fields\n                .iter()\n                .filter(|&field| matches_glob(&field.name))\n                .map(|field| field.ord.unwrap_or_default())\n                .collect_ranges(!test_for_nonempty);\n            if !matched_fields.is_empty() {\n                field_map.push(FieldQualifiedSearchContext {\n                    ntid: nt.id,\n                    total_fields_in_note: nt.fields.len(),\n                    field_ranges_to_search: matched_fields,\n                });\n            }\n        }\n\n        // for now, sort the map for the benefit of unit tests\n        field_map.sort_by_key(|v| v.ntid);\n\n        Ok(field_map)\n    }\n\n    fn fields_indices_by_notetype(\n        &mut self,\n        field_name: &str,\n    ) -> Result<Vec<(NotetypeId, Vec<u32>)>> {\n        let matches_glob = glob_matcher(field_name);\n\n        let mut field_map = vec![];\n        for nt in self.col.get_all_notetypes()? {\n            let matched_fields: Vec<u32> = nt\n                .fields\n                .iter()\n                .filter(|&field| matches_glob(&field.name))\n                .map(|field| field.ord.unwrap_or_default())\n                .collect();\n            if !matched_fields.is_empty() {\n                field_map.push((nt.id, matched_fields));\n            }\n        }\n\n        // for now, sort the map for the benefit of unit tests\n        field_map.sort();\n\n        Ok(field_map)\n    }\n\n    fn included_fields_by_notetype(&mut self) -> Result<Option<Vec<UnqualifiedSearchContext>>> {\n        let mut any_excluded = false;\n        let mut field_map = vec![];\n        for nt in self.col.get_all_notetypes()? {\n            let mut sortf_excluded = false;\n            let matched_fields = nt\n                .fields\n                .iter()\n                .filter_map(|field| {\n                    let ord = field.ord.unwrap_or_default();\n                    if field.config.exclude_from_search {\n                        any_excluded = true;\n                        sortf_excluded |= ord == nt.config.sort_field_idx;\n                    }\n                    (!field.config.exclude_from_search).then_some(ord)\n                })\n                .collect_ranges(true);\n            if !matched_fields.is_empty() {\n                field_map.push(UnqualifiedSearchContext {\n                    ntid: nt.id,\n                    total_fields_in_note: nt.fields.len(),\n                    sortf_excluded,\n                    field_ranges_to_search: matched_fields,\n                });\n            }\n        }\n        if any_excluded {\n            Ok(Some(field_map))\n        } else {\n            Ok(None)\n        }\n    }\n\n    fn included_fields_for_unqualified_regex(\n        &mut self,\n    ) -> Result<Option<Vec<UnqualifiedRegexSearchContext>>> {\n        let mut any_excluded = false;\n        let mut field_map = vec![];\n        for nt in self.col.get_all_notetypes()? {\n            let matched_fields: Vec<u32> = nt\n                .fields\n                .iter()\n                .filter_map(|field| {\n                    any_excluded |= field.config.exclude_from_search;\n                    (!field.config.exclude_from_search).then_some(field.ord.unwrap_or_default())\n                })\n                .collect();\n            field_map.push(UnqualifiedRegexSearchContext {\n                ntid: nt.id,\n                total_fields_in_note: nt.fields.len(),\n                fields_to_search: matched_fields,\n            });\n        }\n        if any_excluded {\n            Ok(Some(field_map))\n        } else {\n            Ok(None)\n        }\n    }\n\n    fn write_dupe(&mut self, ntid: NotetypeId, text: &str) -> Result<()> {\n        let text_nohtml = strip_html_preserving_media_filenames(text);\n        let csum = field_checksum(text_nohtml.as_ref());\n\n        let nids: Vec<_> = self\n            .col\n            .storage\n            .note_fields_by_checksum(ntid, csum)?\n            .into_iter()\n            .filter_map(|(nid, field)| {\n                if strip_html_preserving_media_filenames(&field) == text_nohtml {\n                    Some(nid)\n                } else {\n                    None\n                }\n            })\n            .collect();\n\n        self.sql += \"n.id in \";\n        ids_to_string(&mut self.sql, &nids);\n\n        Ok(())\n    }\n\n    fn previous_day_cutoff(&mut self, days_back: u32) -> Result<TimestampSecs> {\n        let timing = self.col.timing_today()?;\n        Ok(timing.next_day_at.adding_secs(-86_400 * days_back as i64))\n    }\n\n    fn write_added(&mut self, days: u32) -> Result<()> {\n        let cutoff = self.previous_day_cutoff(days)?.as_millis();\n        write!(self.sql, \"c.id > {cutoff}\").unwrap();\n        Ok(())\n    }\n\n    fn write_edited(&mut self, days: u32) -> Result<()> {\n        let cutoff = self.previous_day_cutoff(days)?;\n        write!(self.sql, \"n.mod > {cutoff}\").unwrap();\n        Ok(())\n    }\n\n    fn write_introduced(&mut self, days: u32) -> Result<()> {\n        let cutoff = self.previous_day_cutoff(days)?.as_millis();\n        write!(\n            self.sql,\n            concat!(\n                \"((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id \",\n                // Exclude manual reschedulings\n                \"AND ease != 0) \",\n                // Logically redundant, speeds up query\n                \"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))\"\n            ),\n            cutoff = cutoff,\n        )\n        .unwrap();\n        Ok(())\n    }\n\n    fn write_regex(&mut self, word: &str, no_combining: bool) -> Result<()> {\n        let flds_expr = if no_combining {\n            Cow::from(format!(\n                \"coalesce(process_text(n.flds, {}), n.flds)\",\n                ProcessTextFlags::NoCombining.bits()\n            ))\n        } else {\n            Cow::from(\"n.flds\")\n        };\n        let word = if no_combining {\n            without_combining(word)\n        } else {\n            std::borrow::Cow::Borrowed(word)\n        };\n        self.args.push(format!(r\"(?i){word}\"));\n        let arg_idx = self.args.len();\n        if let Some(field_indices_by_notetype) = self.included_fields_for_unqualified_regex()? {\n            let notetype_clause = |ctx: &UnqualifiedRegexSearchContext| -> String {\n                let clause = if ctx.fields_to_search.len() == ctx.total_fields_in_note {\n                    format!(\"{flds_expr} regexp ?{arg_idx}\")\n                } else {\n                    let indices = ctx.fields_to_search.iter().join(\",\");\n                    format!(\"regexp_fields(?{arg_idx}, {flds_expr}, {indices})\")\n                };\n\n                format!(\"(n.mid = {mid} and {clause})\", mid = ctx.ntid)\n            };\n            let all_notetype_clauses = field_indices_by_notetype\n                .iter()\n                .map(notetype_clause)\n                .join(\" or \");\n            write!(self.sql, \"({all_notetype_clauses})\").unwrap();\n        } else {\n            write!(self.sql, \"{flds_expr} regexp ?{arg_idx}\").unwrap();\n        }\n\n        Ok(())\n    }\n\n    fn write_word_boundary(&mut self, word: &str) -> Result<()> {\n        let re = format!(r\"\\b{}\\b\", to_re(word));\n        self.write_regex(\n            &re,\n            self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch),\n        )\n    }\n\n    fn write_deck_preset(&mut self, name: &str) -> Result<()> {\n        let dcid = self.col.storage.get_deck_config_id_by_name(name)?;\n        if dcid.is_none() {\n            write!(self.sql, \"false\").unwrap();\n            return Ok(());\n        };\n\n        let mut str_ids = String::new();\n        let deck_ids = self\n            .col\n            .storage\n            .get_all_decks()?\n            .into_iter()\n            .filter_map(|d| {\n                if d.config_id() == dcid {\n                    Some(d.id)\n                } else {\n                    None\n                }\n            });\n        ids_to_string(&mut str_ids, deck_ids);\n        write!(self.sql, \"(c.did in {str_ids} or c.odid in {str_ids})\").unwrap();\n        Ok(())\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\npub enum RequiredTable {\n    Notes,\n    Cards,\n    CardsAndNotes,\n    CardsOrNotes,\n}\n\nimpl RequiredTable {\n    fn combine(self, other: RequiredTable) -> RequiredTable {\n        match (self, other) {\n            (RequiredTable::CardsAndNotes, _) => RequiredTable::CardsAndNotes,\n            (_, RequiredTable::CardsAndNotes) => RequiredTable::CardsAndNotes,\n            (RequiredTable::CardsOrNotes, b) => b,\n            (a, RequiredTable::CardsOrNotes) => a,\n            (a, b) => {\n                if a == b {\n                    a\n                } else {\n                    RequiredTable::CardsAndNotes\n                }\n            }\n        }\n    }\n}\n\n/// Given a list of numbers, create one or more ranges, collapsing\n/// contiguous numbers.\ntrait CollectRanges {\n    type Item;\n    fn collect_ranges(self, join: bool) -> Vec<Range<Self::Item>>;\n}\n\nimpl<\n        Idx: Copy + PartialOrd + std::ops::Add<Idx, Output = Idx> + From<u8>,\n        I: IntoIterator<Item = Idx>,\n    > CollectRanges for I\n{\n    type Item = Idx;\n\n    fn collect_ranges(self, join: bool) -> Vec<Range<Self::Item>> {\n        let mut result = Vec::new();\n        let mut iter = self.into_iter();\n        let next = iter.next();\n        if next.is_none() {\n            return result;\n        }\n        let mut start = next.unwrap();\n        let mut end = next.unwrap();\n\n        for i in iter {\n            if join && i == end + 1.into() {\n                end = end + 1.into();\n            } else {\n                result.push(start..end + 1.into());\n                start = i;\n                end = i;\n            }\n        }\n        result.push(start..end + 1.into());\n\n        result\n    }\n}\n\nstruct FieldQualifiedSearchContext {\n    ntid: NotetypeId,\n    total_fields_in_note: usize,\n    /// This may include more than one field in the case the user\n    /// has searched with a wildcard, eg f*:foo.\n    field_ranges_to_search: Vec<Range<u32>>,\n}\n\nstruct UnqualifiedSearchContext {\n    ntid: NotetypeId,\n    total_fields_in_note: usize,\n    sortf_excluded: bool,\n    field_ranges_to_search: Vec<Range<u32>>,\n}\n\nstruct UnqualifiedRegexSearchContext {\n    ntid: NotetypeId,\n    total_fields_in_note: usize,\n    /// Unlike the other contexts, this contains each individual index\n    /// instead of a list of ranges.\n    fields_to_search: Vec<u32>,\n}\n\nimpl Node {\n    fn required_table(&self) -> RequiredTable {\n        match self {\n            Node::And => RequiredTable::CardsOrNotes,\n            Node::Or => RequiredTable::CardsOrNotes,\n            Node::Not(node) => node.required_table(),\n            Node::Group(nodes) => nodes.iter().fold(RequiredTable::CardsOrNotes, |cur, node| {\n                cur.combine(node.required_table())\n            }),\n            Node::Search(node) => node.required_table(),\n        }\n    }\n}\n\nimpl SearchNode {\n    fn required_table(&self) -> RequiredTable {\n        match self {\n            SearchNode::AddedInDays(_) => RequiredTable::Cards,\n            SearchNode::IntroducedInDays(_) => RequiredTable::Cards,\n            SearchNode::Deck(_) => RequiredTable::Cards,\n            SearchNode::DeckIdsWithoutChildren(_) => RequiredTable::Cards,\n            SearchNode::DeckIdWithChildren(_) => RequiredTable::Cards,\n            SearchNode::Rated { .. } => RequiredTable::Cards,\n            SearchNode::State(_) => RequiredTable::Cards,\n            SearchNode::Flag(_) => RequiredTable::Cards,\n            SearchNode::CardIds(_) => RequiredTable::Cards,\n            SearchNode::Property { .. } => RequiredTable::Cards,\n            SearchNode::CustomData { .. } => RequiredTable::Cards,\n            SearchNode::Preset(_) => RequiredTable::Cards,\n\n            SearchNode::UnqualifiedText(_) => RequiredTable::Notes,\n            SearchNode::SingleField { .. } => RequiredTable::Notes,\n            SearchNode::Tag { .. } => RequiredTable::Notes,\n            SearchNode::Duplicates { .. } => RequiredTable::Notes,\n            SearchNode::Regex(_) => RequiredTable::Notes,\n            SearchNode::NoCombining(_) => RequiredTable::Notes,\n            SearchNode::StripClozes(_) => RequiredTable::Notes,\n            SearchNode::WordBoundary(_) => RequiredTable::Notes,\n            SearchNode::NotetypeId(_) => RequiredTable::Notes,\n            SearchNode::Notetype(_) => RequiredTable::Notes,\n            SearchNode::EditedInDays(_) => RequiredTable::Notes,\n\n            SearchNode::NoteIds(_) => RequiredTable::CardsOrNotes,\n            SearchNode::WholeCollection => RequiredTable::CardsOrNotes,\n\n            SearchNode::CardTemplate(_) => RequiredTable::CardsAndNotes,\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_io::write_file;\n    use tempfile::tempdir;\n\n    use super::super::parser::parse;\n    use super::*;\n    use crate::collection::Collection;\n    use crate::collection::CollectionBuilder;\n\n    // shortcut\n    fn s(req: &mut Collection, search: &str) -> (String, Vec<String>) {\n        let node = Node::Group(parse(search).unwrap());\n        let mut writer = SqlWriter::new(req, ReturnItemType::Cards);\n        writer.table = RequiredTable::Notes.combine(node.required_table());\n        writer.write_node_to_sql(&node).unwrap();\n        (writer.sql, writer.args)\n    }\n\n    #[test]\n    fn sql() {\n        // re-use the mediacheck .anki2 file for now\n        use crate::media::check::test::MEDIACHECK_ANKI2;\n        let dir = tempdir().unwrap();\n        let col_path = dir.path().join(\"col.anki2\");\n        write_file(&col_path, MEDIACHECK_ANKI2).unwrap();\n\n        let mut col = CollectionBuilder::new(col_path).build().unwrap();\n        let ctx = &mut col;\n\n        // unqualified search\n        assert_eq!(\n            s(ctx, \"te*st\"),\n            (\n                \"((n.sfld like ?1 escape '\\\\' or n.flds like ?1 escape '\\\\'))\".into(),\n                vec![\"%te%st%\".into()]\n            )\n        );\n        assert_eq!(s(ctx, \"te%st\").1, vec![r\"%te\\%st%\".to_string()]);\n        // user should be able to escape wildcards\n        assert_eq!(s(ctx, r\"te\\*s\\_t\").1, vec![\"%te*s\\\\_t%\".to_string()]);\n\n        // field search\n        assert_eq!(\n            s(ctx, \"front:te*st\"),\n            (\n                concat!(\n                    \"(((n.mid = 1581236385344 and (n.flds like '' || ?1 || '\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385345 and (n.flds like '' || ?1 || '\\u{1f}%\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385346 and (n.flds like '' || ?1 || '\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385347 and (n.flds like '' || ?1 || '\\u{1f}%' escape '\\\\'))))\"\n                )\n                .into(),\n                vec![\"te%st\".into()]\n            )\n        );\n        // field search with regex\n        assert_eq!(\n            s(ctx, \"front:re:te.*st\"),\n            (\n                concat!(\n                    \"(((n.mid = 1581236385344 and regexp_fields(?1, n.flds, 0)) or \",\n                    \"(n.mid = 1581236385345 and regexp_fields(?1, n.flds, 0)) or \",\n                    \"(n.mid = 1581236385346 and regexp_fields(?1, n.flds, 0)) or \",\n                    \"(n.mid = 1581236385347 and regexp_fields(?1, n.flds, 0))))\"\n                )\n                .into(),\n                vec![\"(?i)te.*st\".into()]\n            )\n        );\n        // field search with no-combine\n        assert_eq!(\n            s(ctx, \"front:nc:frânçais\"),\n            (\n                concat!(\n                    \"(((n.mid = 1581236385344 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385345 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\\u{1f}%\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385346 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\\u{1f}%' escape '\\\\')) or \",\n                    \"(n.mid = 1581236385347 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\\u{1f}%' escape '\\\\'))))\"\n                )\n                .into(),\n                vec![\"francais\".into()]\n            )\n        );\n        // all field search\n        assert_eq!(\n            s(ctx, \"*:te*st\"),\n            (\n                \"(regexp_fields(?1, n.flds))\".into(),\n                vec![\"(?is)^te.*st$\".into()]\n            )\n        );\n        // all field search with regex\n        assert_eq!(\n            s(ctx, \"*:re:te.*st\"),\n            (\n                \"(regexp_fields(?1, n.flds))\".into(),\n                vec![\"(?i)te.*st\".into()]\n            )\n        );\n\n        // added\n        let timing = ctx.timing_today().unwrap();\n        assert_eq!(\n            s(ctx, \"added:3\").0,\n            format!(\"(c.id > {})\", (timing.next_day_at.0 - (86_400 * 3)) * 1_000)\n        );\n        assert_eq!(s(ctx, \"added:0\").0, s(ctx, \"added:1\").0,);\n\n        // introduced\n        assert_eq!(\n            s(ctx, \"introduced:3\").0,\n            format!(\n                concat!(\n                    \"(((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id AND ease != 0) \",\n                    \"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})))\"\n                ),\n                cutoff = (timing.next_day_at.0 - (86_400 * 3)) * 1_000,\n            )\n        );\n        assert_eq!(s(ctx, \"introduced:0\").0, s(ctx, \"introduced:1\").0,);\n\n        // deck\n        assert_eq!(\n            s(ctx, \"deck:default\"),\n            (\n                \"((c.did in (select id from decks where name regexp ?1) or (c.odid != 0 and \\\n                c.odid in (select id from decks where name regexp ?1))))\"\n                    .into(),\n                vec![\"(?i)^default($|\\u{1f})\".into()]\n            )\n        );\n        assert_eq!(\n            s(ctx, \"deck:current\").1,\n            vec![\"(?i)^Default($|\\u{1f})\".to_string()]\n        );\n        assert_eq!(s(ctx, \"deck:d*\").1, vec![\"(?i)^d.*($|\\u{1f})\".to_string()]);\n        assert_eq!(s(ctx, \"deck:filtered\"), (\"(c.odid != 0)\".into(), vec![],));\n\n        // card\n        assert_eq!(\n            s(ctx, r#\"\"card:card 1\"\"#),\n            (\n                \"((n.mid,c.ord) in (select ntid,ord from templates where name = ?))\".into(),\n                vec![\"card 1\".into()]\n            )\n        );\n\n        // IDs\n        assert_eq!(s(ctx, \"mid:3\"), (\"(n.mid = 3)\".into(), vec![]));\n        assert_eq!(s(ctx, \"nid:3\"), (\"(n.id in (3))\".into(), vec![]));\n        assert_eq!(s(ctx, \"nid:3,4\"), (\"(n.id in (3,4))\".into(), vec![]));\n        assert_eq!(s(ctx, \"cid:3,4\"), (\"(c.id in (3,4))\".into(), vec![]));\n\n        // flags\n        assert_eq!(s(ctx, \"flag:2\"), (\"((c.flags & 7) == 2)\".into(), vec![]));\n        assert_eq!(s(ctx, \"flag:0\"), (\"((c.flags & 7) == 0)\".into(), vec![]));\n\n        // dupes\n        assert_eq!(s(ctx, \"dupe:123,test\"), (\"(n.id in ())\".into(), vec![]));\n\n        // tags\n        assert_eq!(\n            s(ctx, r\"tag:one\"),\n            (\n                \"(n.tags regexp ?)\".into(),\n                vec![\"(?i).* one(::| ).*\".into()]\n            )\n        );\n        assert_eq!(\n            s(ctx, r\"tag:foo::bar\"),\n            (\n                \"(n.tags regexp ?)\".into(),\n                vec![\"(?i).* foo::bar(::| ).*\".into()]\n            )\n        );\n\n        assert_eq!(\n            s(ctx, r\"tag:o*n\\*et%w%oth_re\\_e\"),\n            (\n                \"(n.tags regexp ?)\".into(),\n                vec![r\"(?i).* o\\S*n\\*et%w%oth\\Sre_e(::| ).*\".into()]\n            )\n        );\n        assert_eq!(s(ctx, \"tag:none\"), (\"(n.tags = '')\".into(), vec![]));\n        assert_eq!(s(ctx, \"tag:*\"), (\"(true)\".into(), vec![]));\n        assert_eq!(\n            s(ctx, \"tag:re:.ne|tw.\"),\n            (\n                \"(regexp_tags(?1, n.tags))\".into(),\n                vec![\"(?i).ne|tw.\".into()]\n            )\n        );\n\n        // state\n        assert_eq!(\n            s(ctx, \"is:suspended\").0,\n            format!(\"(c.queue = {})\", CardQueue::Suspended as i8)\n        );\n        assert_eq!(\n            s(ctx, \"is:new\").0,\n            format!(\"(c.type = {})\", CardType::New as i8)\n        );\n\n        // rated\n        assert_eq!(\n            s(ctx, \"rated:2\").0,\n            format!(\n                \"(c.id in (select cid from revlog where id >= {} and ease > 0))\",\n                (timing.next_day_at.0 - (86_400 * 2)) * 1_000\n            )\n        );\n        assert_eq!(\n            s(ctx, \"rated:400:1\").0,\n            format!(\n                \"(c.id in (select cid from revlog where id >= {} and ease = 1))\",\n                (timing.next_day_at.0 - (86_400 * 400)) * 1_000\n            )\n        );\n        assert_eq!(s(ctx, \"rated:0\").0, s(ctx, \"rated:1\").0);\n\n        // resched\n        assert_eq!(\n            s(ctx, \"resched:400\").0,\n            format!(\n                \"(c.id in (select cid from revlog where id >= {} and ease = 0))\",\n                (timing.next_day_at.0 - (86_400 * 400)) * 1_000\n            )\n        );\n\n        // props\n        assert_eq!(s(ctx, \"prop:lapses=3\").0, \"(lapses = 3)\".to_string());\n        assert_eq!(s(ctx, \"prop:ease>=2.5\").0, \"(factor >= 2500)\".to_string());\n        assert_eq!(\n            s(ctx, \"prop:due!=-1\").0,\n            format!(\n                \"(((c.queue in (2,3) and \\n                        (case when \\\nc.odue != 0 then c.odue else c.due end) != {days}) or (c.queue in (1,4) and \n                        (((case when c.odue != 0 then c.odue else c.due end) - {cutoff}) / 86400) != -1)))\",\n                days = timing.days_elapsed - 1,\n                cutoff = timing.next_day_at\n            )\n        );\n        assert_eq!(s(ctx, \"prop:rated>-5:3\").0, s(ctx, \"rated:5:3\").0);\n        assert_eq!(\n            &s(ctx, \"prop:cdn:r=1\").0,\n            \"(cast(extract_custom_data(c.data, 'r') as float) = 1)\"\n        );\n        assert_eq!(\n            &s(ctx, \"prop:cds:r=s\").0,\n            \"(extract_custom_data(c.data, 'r') = 's')\"\n        );\n\n        // note types by name\n        assert_eq!(\n            s(ctx, \"note:basic\"),\n            (\n                \"(n.mid in (select id from notetypes where name = ?))\".into(),\n                vec![\"basic\".into()]\n            )\n        );\n        assert_eq!(\n            s(ctx, \"note:basic*\"),\n            (\n                \"(n.mid in (select id from notetypes where name regexp ?))\".into(),\n                vec![\"(?i)^basic.*$\".into()]\n            )\n        );\n\n        // regex\n        assert_eq!(\n            s(ctx, r\"re:\\bone\"),\n            (\"(n.flds regexp ?1)\".into(), vec![r\"(?i)\\bone\".into()])\n        );\n\n        // word boundary\n        assert_eq!(\n            s(ctx, r\"w:foo\"),\n            (\"(n.flds regexp ?1)\".into(), vec![r\"(?i)\\bfoo\\b\".into()])\n        );\n        assert_eq!(\n            s(ctx, r\"w:*foo\"),\n            (\"(n.flds regexp ?1)\".into(), vec![r\"(?i)\\b.*foo\\b\".into()])\n        );\n\n        assert_eq!(\n            s(ctx, r\"w:*fo_o*\"),\n            (\n                \"(n.flds regexp ?1)\".into(),\n                vec![r\"(?i)\\b.*fo.o.*\\b\".into()]\n            )\n        );\n\n        // has-cd\n        assert_eq!(\n            &s(ctx, \"has-cd:r\").0,\n            \"(extract_custom_data(c.data, 'r') is not null)\"\n        );\n\n        // preset search\n        assert_eq!(\n            &s(ctx, \"preset:default\").0,\n            \"((c.did in (1) or c.odid in (1)))\"\n        );\n        assert_eq!(&s(ctx, \"preset:typo\").0, \"(false)\");\n\n        // strip clozes\n        assert_eq!(&s(ctx, \"sc:abcdef\").0, \"((n.mid = 1581236385343) and (coalesce(process_text(cast(n.sfld as text), 2), n.sfld) like ?1 escape '\\\\' or coalesce(process_text(n.flds, 2), n.flds) like ?1 escape '\\\\'))\");\n    }\n\n    #[test]\n    fn required_table() {\n        assert_eq!(\n            Node::Group(parse(\"\").unwrap()).required_table(),\n            RequiredTable::CardsOrNotes\n        );\n        assert_eq!(\n            Node::Group(parse(\"test\").unwrap()).required_table(),\n            RequiredTable::Notes\n        );\n        assert_eq!(\n            Node::Group(parse(\"cid:1\").unwrap()).required_table(),\n            RequiredTable::Cards\n        );\n        assert_eq!(\n            Node::Group(parse(\"cid:1 test\").unwrap()).required_table(),\n            RequiredTable::CardsAndNotes\n        );\n        assert_eq!(\n            Node::Group(parse(\"nid:1\").unwrap()).required_table(),\n            RequiredTable::CardsOrNotes\n        );\n        assert_eq!(\n            Node::Group(parse(\"cid:1 nid:1\").unwrap()).required_table(),\n            RequiredTable::Cards\n        );\n        assert_eq!(\n            Node::Group(parse(\"test nid:1\").unwrap()).required_table(),\n            RequiredTable::Notes\n        );\n    }\n\n    #[allow(clippy::single_range_in_vec_init)]\n    #[test]\n    fn ranges() {\n        assert_eq!([1, 2, 3].collect_ranges(true), [1..4]);\n        assert_eq!([1, 3, 4].collect_ranges(true), [1..2, 3..5]);\n        assert_eq!([1, 2, 5, 6].collect_ranges(false), [1..2, 2..3, 5..6, 6..7]);\n    }\n}\n"
  },
  {
    "path": "rslib/src/search/template_order.sql",
    "content": "DROP TABLE IF EXISTS sort_order;\nCREATE TEMPORARY TABLE sort_order (\n  pos integer PRIMARY KEY,\n  ntid integer NOT NULL,\n  ord integer NOT NULL,\n  UNIQUE(ntid, ord)\n);\nINSERT INTO sort_order (ntid, ord)\nSELECT ntid,\n  ord\nFROM templates\nORDER BY name"
  },
  {
    "path": "rslib/src/search/writer.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::mem;\nuse std::sync::LazyLock;\n\nuse regex::Regex;\n\nuse crate::notetype::NotetypeId as NotetypeIdType;\nuse crate::prelude::*;\nuse crate::search::parser::parse;\nuse crate::search::parser::FieldSearchMode;\nuse crate::search::parser::Node;\nuse crate::search::parser::PropertyKind;\nuse crate::search::parser::RatingKind;\nuse crate::search::parser::SearchNode;\nuse crate::search::parser::StateKind;\nuse crate::search::parser::TemplateKind;\nuse crate::text::escape_anki_wildcards;\n\n/// Given an existing parsed search, if the provided `replacement` is a single\n/// search node such as a deck:xxx search, replace any instances of that search\n/// in `existing` with the new value. Then return the possibly modified first\n/// search as a string.\npub fn replace_search_node(mut existing: Vec<Node>, replacement: Node) -> String {\n    if let Node::Search(search_node) = replacement {\n        fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) {\n            fn update_node(old_node: &mut Node, new_node: &SearchNode) {\n                match old_node {\n                    Node::Not(n) => update_node(n, new_node),\n                    Node::Group(ns) => update_node_vec(ns, new_node),\n                    Node::Search(n) => {\n                        if mem::discriminant(n) == mem::discriminant(new_node) {\n                            *n = new_node.clone();\n                        }\n                    }\n                    _ => (),\n                }\n            }\n            old_nodes.iter_mut().for_each(|n| update_node(n, new_node));\n        }\n        update_node_vec(&mut existing, &search_node);\n    }\n    write_nodes(&existing)\n}\n\npub(super) fn write_nodes(nodes: &[Node]) -> String {\n    nodes.iter().map(write_node).collect()\n}\n\n#[allow(clippy::to_string_trait_impl)]\nimpl ToString for Node {\n    fn to_string(&self) -> String {\n        write_node(self)\n    }\n}\n\nfn write_node(node: &Node) -> String {\n    use Node::*;\n    match node {\n        And => \" \".to_string(),\n        Or => \" OR \".to_string(),\n        Not(n) => format!(\"-{}\", write_node(n)),\n        Group(ns) => format!(\"({})\", write_nodes(ns)),\n        Search(n) => write_search_node(n),\n    }\n}\n\nfn write_search_node(node: &SearchNode) -> String {\n    use SearchNode::*;\n    match node {\n        UnqualifiedText(s) => maybe_quote(&s.replace(':', \"\\\\:\")),\n        SingleField { field, text, mode } => write_single_field(field, text, *mode),\n        AddedInDays(u) => format!(\"added:{u}\"),\n        EditedInDays(u) => format!(\"edited:{u}\"),\n        IntroducedInDays(u) => format!(\"introduced:{u}\"),\n        CardTemplate(t) => write_template(t),\n        Deck(s) => maybe_quote(&format!(\"deck:{s}\")),\n        DeckIdsWithoutChildren(s) => format!(\"did:{s}\"),\n        // not exposed on the GUI end\n        DeckIdWithChildren(_) => \"\".to_string(),\n        NotetypeId(NotetypeIdType(i)) => format!(\"mid:{i}\"),\n        Notetype(s) => maybe_quote(&format!(\"note:{s}\")),\n        Rated { days, ease } => write_rated(days, ease),\n        Tag { tag, mode } => write_single_field(\"tag\", tag, *mode),\n        Duplicates { notetype_id, text } => write_dupe(notetype_id, text),\n        State(k) => write_state(k),\n        Flag(u) => format!(\"flag:{u}\"),\n        NoteIds(s) => format!(\"nid:{s}\"),\n        CardIds(s) => format!(\"cid:{s}\"),\n        Property { operator, kind } => write_property(operator, kind),\n        WholeCollection => \"deck:*\".to_string(),\n        Regex(s) => maybe_quote(&format!(\"re:{s}\")),\n        NoCombining(s) => maybe_quote(&format!(\"nc:{s}\")),\n        StripClozes(s) => maybe_quote(&format!(\"sc:{s}\")),\n        WordBoundary(s) => maybe_quote(&format!(\"w:{s}\")),\n        CustomData(k) => maybe_quote(&format!(\"has-cd:{k}\")),\n        Preset(s) => maybe_quote(&format!(\"preset:{s}\")),\n    }\n}\n\n/// Escape double quotes and wrap in double quotes if necessary.\nfn maybe_quote(txt: &str) -> String {\n    if needs_quotation(txt) {\n        format!(\"\\\"{}\\\"\", txt.replace('\\\"', \"\\\\\\\"\"))\n    } else {\n        txt.replace('\\\"', \"\\\\\\\"\")\n    }\n}\n\n/// Checks for the reserved keywords \"and\" and \"or\", a prepended hyphen,\n/// whitespace and brackets.\nfn needs_quotation(txt: &str) -> bool {\n    static RE: LazyLock<Regex> =\n        LazyLock::new(|| Regex::new(\"(?i)^and$|^or$|^-.| |\\u{3000}|\\\\(|\\\\)\").unwrap());\n    RE.is_match(txt)\n}\n\n/// Also used by tag search, which has the same syntax.\nfn write_single_field(field: &str, text: &str, mode: FieldSearchMode) -> String {\n    let prefix = match mode {\n        FieldSearchMode::Normal => \"\",\n        FieldSearchMode::Regex => \"re:\",\n        FieldSearchMode::NoCombining => \"nc:\",\n    };\n    let text = if mode == FieldSearchMode::Normal\n        && (text.starts_with(\"re:\") || text.starts_with(\"nc:\"))\n    {\n        text.replacen(':', \"\\\\:\", 1)\n    } else {\n        text.to_string()\n    };\n    maybe_quote(&format!(\n        \"{}:{}{}\",\n        field.replace(':', \"\\\\:\"),\n        prefix,\n        &text\n    ))\n}\n\nfn write_template(template: &TemplateKind) -> String {\n    match template {\n        TemplateKind::Ordinal(u) => format!(\"card:{}\", u + 1),\n        TemplateKind::Name(s) => maybe_quote(&format!(\"card:{s}\")),\n    }\n}\n\nfn write_rated(days: &u32, ease: &RatingKind) -> String {\n    use RatingKind::*;\n    match ease {\n        AnswerButton(n) => format!(\"rated:{days}:{n}\"),\n        AnyAnswerButton => format!(\"rated:{days}\"),\n        ManualReschedule => format!(\"resched:{days}\"),\n    }\n}\n\n/// Escape double quotes and backslashes: \\\"\nfn write_dupe(notetype_id: &NotetypeId, text: &str) -> String {\n    let esc = text.replace('\\\\', r\"\\\\\");\n    maybe_quote(&format!(\"dupe:{notetype_id},{esc}\"))\n}\n\nfn write_state(kind: &StateKind) -> String {\n    use StateKind::*;\n    format!(\n        \"is:{}\",\n        match kind {\n            New => \"new\",\n            Review => \"review\",\n            Learning => \"learn\",\n            Due => \"due\",\n            Buried => \"buried\",\n            UserBuried => \"buried-manually\",\n            SchedBuried => \"buried-sibling\",\n            Suspended => \"suspended\",\n        }\n    )\n}\n\nfn write_property(operator: &str, kind: &PropertyKind) -> String {\n    use PropertyKind::*;\n    match kind {\n        Due(i) => format!(\"prop:due{operator}{i}\"),\n        Interval(u) => format!(\"prop:ivl{operator}{u}\"),\n        Reps(u) => format!(\"prop:reps{operator}{u}\"),\n        Lapses(u) => format!(\"prop:lapses{operator}{u}\"),\n        Ease(f) => format!(\"prop:ease{operator}{f}\"),\n        Position(u) => format!(\"prop:pos{operator}{u}\"),\n        Stability(u) => format!(\"prop:s{operator}{u}\"),\n        Difficulty(u) => format!(\"prop:d{operator}{u}\"),\n        Retrievability(u) => format!(\"prop:r{operator}{u}\"),\n        Rated(u, ease) => match ease {\n            RatingKind::AnswerButton(val) => format!(\"prop:rated{operator}{u}:{val}\"),\n            RatingKind::AnyAnswerButton => format!(\"prop:rated{operator}{u}\"),\n            RatingKind::ManualReschedule => format!(\"prop:resched{operator}{u}\"),\n        },\n        CustomDataNumber { key, value } => format!(\"prop:cdn:{key}{operator}{value}\"),\n        CustomDataString { key, value } => {\n            maybe_quote(&format!(\"prop:cds:{key}{operator}{value}\",))\n        }\n    }\n}\n\npub(crate) fn deck_search(name: &str) -> String {\n    write_nodes(&[Node::Search(SearchNode::Deck(escape_anki_wildcards(name)))])\n}\n\n/// Take an Anki-style search string and convert it into an equivalent\n/// search string with normalized syntax.\npub(crate) fn normalize_search(input: &str) -> Result<String> {\n    Ok(write_nodes(&parse(input)?))\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::error::Result;\n    use crate::search::parse_search as parse;\n\n    #[test]\n    fn normalizing() {\n        // remove redundant quotes\n        assert_eq!(\n            r#\"foo \"b a r\"\"#,\n            normalize_search(r#\"\"foo\" \"b a r\"\"#).unwrap()\n        );\n        assert_eq!(\"field:foo\", normalize_search(r#\"field:\"foo\"\"#).unwrap());\n        // escape by quoting where possible\n        assert_eq!(r#\"\"(\" \")\"\"#, normalize_search(r\"\\( \\)\").unwrap());\n        assert_eq!(r#\"\"-foo\"\"#, normalize_search(r\"\\-foo\").unwrap());\n        assert_eq!(r\"\\*\\:\\_\", normalize_search(r\"\\*\\:\\_\").unwrap());\n        // remove redundant escapes\n        assert_eq!(\"deck::\", normalize_search(r\"deck:\\:\").unwrap());\n        assert_eq!(\"-\", normalize_search(r\"\\-\").unwrap());\n        assert_eq!(\"--\", normalize_search(r\"-\\-\").unwrap());\n        // ANDs are implicit, ORs in upper case\n        assert_eq!(\"1 2 OR 3\", normalize_search(r\"1 and 2 or 3\").unwrap());\n        assert_eq!(r#\"\"f o o\" bar\"#, normalize_search(r#\"\"f o o\"bar\"#).unwrap());\n        // AND and OR must be escaped regardless of case\n        assert_eq!(r#\"\"aNd\" \"oR\"\"#, normalize_search(r#\"\"aNd\" \"oR\"\"#).unwrap());\n        // normalize numbers\n        assert_eq!(\"prop:ease>1\", normalize_search(\"prop:ease>1.0\").unwrap());\n    }\n\n    #[test]\n    fn replacing() -> Result<()> {\n        assert_eq!(\n            replace_search_node(parse(\"deck:baz bar\")?, parse(\"deck:foo\")?.pop().unwrap()),\n            \"deck:foo bar\",\n        );\n        assert_eq!(\n            replace_search_node(\n                parse(\"tag:foo Or tag:bar\")?,\n                parse(\"tag:baz\")?.pop().unwrap()\n            ),\n            \"tag:baz OR tag:baz\",\n        );\n        assert_eq!(\n            replace_search_node(\n                parse(\"foo or (-foo tag:baz)\")?,\n                parse(\"bar\")?.pop().unwrap()\n            ),\n            \"bar OR (-bar tag:baz)\",\n        );\n        assert_eq!(\n            replace_search_node(parse(\"is:due\")?, parse(\"-is:new\")?.pop().unwrap()),\n            \"is:due\"\n        );\n        assert_eq!(\n            replace_search_node(parse(\"added:1\")?, parse(\"is:due\")?.pop().unwrap()),\n            \"added:1\"\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/serde.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize as DeTrait;\nuse serde::Deserializer;\npub(crate) use serde_aux::field_attributes::deserialize_bool_from_anything;\npub(crate) use serde_aux::field_attributes::deserialize_number_from_string;\nuse serde_json::Value;\n\nuse crate::timestamp::TimestampSecs;\n\n/// Note: if you wish to cover the case where a field is missing, make sure you\n/// also use the `serde(default)` flag.\npub(crate) fn default_on_invalid<'de, T, D>(deserializer: D) -> Result<T, D::Error>\nwhere\n    T: Default + DeTrait<'de>,\n    D: Deserializer<'de>,\n{\n    let v: Value = DeTrait::deserialize(deserializer)?;\n    Ok(T::deserialize(v).unwrap_or_default())\n}\n\npub(crate) fn is_default<T: Default + PartialEq>(t: &T) -> bool {\n    *t == Default::default()\n}\n\npub(crate) fn deserialize_int_from_number<'de, T, D>(deserializer: D) -> Result<T, D::Error>\nwhere\n    D: Deserializer<'de>,\n    T: serde::Deserialize<'de> + FromI64,\n{\n    #[derive(DeTrait)]\n    #[serde(untagged)]\n    enum IntOrFloat {\n        Int(i64),\n        Float(f64),\n    }\n\n    match IntOrFloat::deserialize(deserializer)? {\n        IntOrFloat::Float(f) => Ok(T::from_i64(f as i64)),\n        IntOrFloat::Int(i) => Ok(T::from_i64(i)),\n    }\n}\n\n// It may be possible to use the num_traits crate instead in the future.\npub(crate) trait FromI64 {\n    fn from_i64(val: i64) -> Self;\n}\n\nimpl FromI64 for i32 {\n    fn from_i64(val: i64) -> Self {\n        val as Self\n    }\n}\n\nimpl FromI64 for u32 {\n    fn from_i64(val: i64) -> Self {\n        val.max(0) as Self\n    }\n}\n\nimpl FromI64 for i64 {\n    fn from_i64(val: i64) -> Self {\n        val\n    }\n}\n\nimpl FromI64 for TimestampSecs {\n    fn from_i64(val: i64) -> Self {\n        TimestampSecs(val)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use serde::Deserialize;\n\n    use super::*;\n\n    #[derive(Deserialize, Debug, PartialEq, Eq)]\n    struct MaybeInvalid {\n        #[serde(deserialize_with = \"default_on_invalid\", default)]\n        field: Option<usize>,\n    }\n\n    #[test]\n    fn invalid_or_missing() {\n        assert_eq!(\n            serde_json::from_str::<MaybeInvalid>(r#\"{\"field\": 5}\"#).unwrap(),\n            MaybeInvalid { field: Some(5) }\n        );\n        assert_eq!(\n            serde_json::from_str::<MaybeInvalid>(r#\"{\"field\": \"5\"}\"#).unwrap(),\n            MaybeInvalid { field: None }\n        );\n        assert_eq!(\n            serde_json::from_str::<MaybeInvalid>(r#\"{\"another\": 5}\"#).unwrap(),\n            MaybeInvalid { field: None }\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/services.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![allow(clippy::redundant_closure)]\n\n// Includes the automatically-generated *Service and Backend*Service traits,\n// and some impls on Backend and Collection.\n\ninclude!(concat!(env!(\"OUT_DIR\"), \"/backend.rs\"));\n"
  },
  {
    "path": "rslib/src/stats/card.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse fsrs::FSRS;\nuse fsrs::FSRS5_DEFAULT_DECAY;\n\nuse crate::card::CardType;\nuse crate::card::FsrsMemoryState;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state;\nuse crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config;\nuse crate::scheduler::timing::is_unix_epoch_timestamp;\n\nimpl Collection {\n    pub fn card_stats(&mut self, cid: CardId) -> Result<anki_proto::stats::CardStatsResponse> {\n        let card = self.storage.get_card(cid)?.or_not_found(cid)?;\n        let note = self\n            .storage\n            .get_note(card.note_id)?\n            .or_not_found(card.note_id)?;\n        let nt = self\n            .get_notetype(note.notetype_id)?\n            .or_not_found(note.notetype_id)?;\n        let deck = self\n            .storage\n            .get_deck(card.deck_id)?\n            .or_not_found(card.deck_id)?;\n        let revlog = self.storage.get_revlog_entries_for_card(card.id)?;\n\n        let (average_secs, total_secs) = average_and_total_secs_strings(&revlog);\n        let timing = self.timing_today()?;\n\n        let last_review_time = if let Some(last_review_time) = card.last_review_time {\n            last_review_time\n        } else {\n            let mut new_card = card.clone();\n            let last_review_time = self\n                .storage\n                .time_of_last_review(card.id)?\n                .unwrap_or_default();\n\n            new_card.last_review_time = Some(last_review_time);\n\n            self.storage.update_card(&new_card)?;\n            last_review_time\n        };\n\n        let seconds_elapsed = timing.now.elapsed_secs_since(last_review_time) as u32;\n\n        let fsrs_retrievability = card\n            .memory_state\n            .zip(Some(seconds_elapsed))\n            .zip(Some(card.decay.unwrap_or(FSRS5_DEFAULT_DECAY)))\n            .map(|((state, seconds), decay)| {\n                FSRS::new(None).unwrap().current_retrievability_seconds(\n                    state.into(),\n                    seconds,\n                    decay,\n                )\n            });\n\n        let original_deck = if card.original_deck_id == DeckId(0) {\n            deck.clone()\n        } else {\n            self.storage\n                .get_deck(card.original_deck_id)?\n                .or_not_found(card.original_deck_id)?\n        };\n        let config_id = original_deck.config_id().unwrap();\n        let preset = self\n            .get_deck_config(config_id, true)?\n            .or_not_found(config_id.to_string())?;\n        Ok(anki_proto::stats::CardStatsResponse {\n            card_id: card.id.into(),\n            note_id: card.note_id.into(),\n            deck: deck.human_name(),\n            added: card.id.as_secs().0,\n            first_review: revlog\n                .iter()\n                .find(|entry| entry.has_rating())\n                .map(|entry| entry.id.as_secs().0),\n            // last_review_time is not used to ensure cram revlogs are included.\n            latest_review: revlog\n                .iter()\n                .rfind(|entry| entry.has_rating())\n                .map(|entry| entry.id.as_secs().0),\n            due_date: self.due_date(&card)?,\n            due_position: self.position(&card),\n            interval: card.interval,\n            ease: card.ease_factor as u32,\n            reviews: card.reps,\n            lapses: card.lapses,\n            average_secs,\n            total_secs,\n            card_type: nt.get_template(card.template_idx)?.name.clone(),\n            notetype: nt.name.clone(),\n            revlog: self.stats_revlog_entries_with_memory_state(&card, revlog)?,\n            memory_state: card.memory_state.map(Into::into),\n            fsrs_retrievability,\n            custom_data: card.custom_data,\n            fsrs_params: preset.fsrs_params().to_vec(),\n            preset: preset.name,\n            original_deck: if original_deck != deck {\n                Some(original_deck.human_name())\n            } else {\n                None\n            },\n            desired_retention: card.desired_retention,\n        })\n    }\n\n    pub fn get_review_logs(&mut self, cid: CardId) -> Result<anki_proto::stats::ReviewLogs> {\n        let revlogs = self.storage.get_revlog_entries_for_card(cid)?;\n        Ok(anki_proto::stats::ReviewLogs {\n            entries: revlogs.iter().rev().map(stats_revlog_entry).collect(),\n        })\n    }\n\n    fn due_date(&mut self, card: &Card) -> Result<Option<i64>> {\n        Ok(match card.ctype {\n            CardType::New => None,\n            CardType::Review | CardType::Learn | CardType::Relearn => {\n                let due = if card.original_due != 0 {\n                    card.original_due\n                } else {\n                    card.due\n                };\n                if !is_unix_epoch_timestamp(due) {\n                    let days_remaining = due - (self.timing_today()?.days_elapsed as i32);\n                    let mut due_timestamp = TimestampSecs::now();\n                    due_timestamp.0 += (days_remaining as i64) * 86_400;\n                    Some(due_timestamp.0)\n                } else {\n                    Some(due as i64)\n                }\n            }\n        })\n    }\n\n    fn position(&mut self, card: &Card) -> Option<i32> {\n        if let Some(original_pos) = card.original_position {\n            return Some(original_pos as i32);\n        }\n        match card.ctype {\n            CardType::New => Some(card.due),\n            _ => None,\n        }\n    }\n\n    fn stats_revlog_entries_with_memory_state(\n        self: &mut Collection,\n        card: &Card,\n        revlog: Vec<RevlogEntry>,\n    ) -> Result<Vec<anki_proto::stats::card_stats_response::StatsRevlogEntry>> {\n        let deck_id = card.original_deck_id.or(card.deck_id);\n        let deck = self.get_deck(deck_id)?.or_not_found(card.deck_id)?;\n        let conf_id = DeckConfigId(deck.normal()?.config_id);\n        let config = self\n            .storage\n            .get_deck_config(conf_id)?\n            .or_not_found(conf_id)?;\n        let historical_retention = config.inner.historical_retention;\n        let fsrs = FSRS::new(Some(config.fsrs_params()))?;\n        let next_day_at = self.timing_today()?.next_day_at;\n        let ignore_before = ignore_revlogs_before_ms_from_config(&config)?;\n\n        let mut result = Vec::new();\n        if let Some(item) = fsrs_item_for_memory_state(\n            &fsrs,\n            revlog.clone(),\n            next_day_at,\n            historical_retention,\n            ignore_before,\n        )? {\n            let memory_states = fsrs.historical_memory_states(item.item, item.starting_state)?;\n            let mut revlog_index = 0;\n            for entry in revlog {\n                let mut stats_entry = stats_revlog_entry(&entry);\n                let memory_state: Option<FsrsMemoryState> = if revlog_index >= memory_states.len() {\n                    // The removed revlog is in the end of the revlog, so we use the last memory\n                    // state\n                    Some(memory_states[memory_states.len() - 1].into())\n                } else if entry.id == item.filtered_revlogs[revlog_index].id {\n                    revlog_index += 1;\n                    Some(memory_states[revlog_index - 1].into())\n                } else if revlog_index == 0 {\n                    // The removed revlog is in the start of the revlog, so we don't have a memory\n                    // state for it\n                    None\n                } else {\n                    // The removed revlog is in the middle of the revlog, so we use the memory\n                    // state for the previous revlog entry\n                    Some(memory_states[revlog_index].into())\n                };\n                stats_entry.memory_state = memory_state.map(|s| s.into());\n                result.push(stats_entry);\n            }\n            Ok(result.into_iter().rev().collect())\n        } else {\n            Ok(revlog.iter().rev().map(stats_revlog_entry).collect())\n        }\n    }\n}\n\nfn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) {\n    let normal_answer_count = revlog.iter().filter(|r| r.has_rating()).count();\n    let total_secs: f32 = revlog\n        .iter()\n        .map(|entry| (entry.taken_millis as f32) / 1000.0)\n        .sum();\n    if normal_answer_count == 0 || total_secs == 0.0 {\n        (0.0, 0.0)\n    } else {\n        (total_secs / normal_answer_count as f32, total_secs)\n    }\n}\n\nfn stats_revlog_entry(\n    entry: &RevlogEntry,\n) -> anki_proto::stats::card_stats_response::StatsRevlogEntry {\n    anki_proto::stats::card_stats_response::StatsRevlogEntry {\n        time: entry.id.as_secs().0,\n        review_kind: entry.review_kind.into(),\n        button_chosen: entry.button_chosen as u32,\n        interval: entry.interval_secs(),\n        ease: entry.ease_factor,\n        taken_secs: entry.taken_millis as f32 / 1000.,\n        memory_state: None,\n        last_interval: entry.last_interval_secs(),\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::search::SortMode;\n\n    #[test]\n    fn stats() -> Result<()> {\n        let mut col = Collection::new();\n\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        let cid = col.search_cards(\"\", SortMode::NoOrder)?[0];\n        let _report = col.card_stats(cid)?;\n        //println!(\"report {}\", report);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/added.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::Added;\n\nuse super::GraphsContext;\n\nimpl GraphsContext {\n    pub(super) fn added_days(&self) -> Added {\n        let mut data = Added::default();\n        for card in &self.cards {\n            // this could perhaps be simplified; it currently tries to match the old TS code\n            // logic\n            let day = ((card.id.as_secs().elapsed_secs_since(self.next_day_start) as f64)\n                / 86_400.0)\n                .ceil() as i32;\n            *data.added.entry(day).or_insert_with(Default::default) += 1;\n        }\n        data\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/buttons.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::buttons::ButtonCounts;\nuse anki_proto::stats::graphs_response::Buttons;\n\nuse super::GraphsContext;\nuse crate::revlog::RevlogEntry;\nuse crate::revlog::RevlogReviewKind;\n\nimpl GraphsContext {\n    pub(super) fn buttons(&self) -> Buttons {\n        let mut all_time = ButtonCounts {\n            learning: vec![0; 4],\n            young: vec![0; 4],\n            mature: vec![0; 4],\n        };\n        let mut conditional_buckets = vec![\n            (\n                self.next_day_start.adding_secs(-86_400 * 365),\n                all_time.clone(),\n            ),\n            (\n                self.next_day_start.adding_secs(-86_400 * 90),\n                all_time.clone(),\n            ),\n            (\n                self.next_day_start.adding_secs(-86_400 * 30),\n                all_time.clone(),\n            ),\n        ];\n        'outer: for review in &self.revlog {\n            let Some(interval_bucket) = interval_bucket(review) else {\n                continue;\n            };\n            let Some(button_idx) = button_index(review.button_chosen) else {\n                continue;\n            };\n            let review_secs = review.id.as_secs();\n            increment_button_counts(&mut all_time, interval_bucket, button_idx);\n            for (stamp, bucket) in &mut conditional_buckets {\n                if &review_secs < stamp {\n                    continue 'outer;\n                }\n                increment_button_counts(bucket, interval_bucket, button_idx);\n            }\n        }\n        Buttons {\n            one_month: Some(conditional_buckets.pop().unwrap().1),\n            three_months: Some(conditional_buckets.pop().unwrap().1),\n            one_year: Some(conditional_buckets.pop().unwrap().1),\n            all_time: Some(all_time),\n        }\n    }\n}\n\n#[derive(Clone, Copy)]\nenum IntervalBucket {\n    Learning,\n    Young,\n    Mature,\n}\n\nfn increment_button_counts(counts: &mut ButtonCounts, bucket: IntervalBucket, button_idx: usize) {\n    match bucket {\n        IntervalBucket::Learning => counts.learning[button_idx] += 1,\n        IntervalBucket::Young => counts.young[button_idx] += 1,\n        IntervalBucket::Mature => counts.mature[button_idx] += 1,\n    }\n}\n\nfn interval_bucket(review: &RevlogEntry) -> Option<IntervalBucket> {\n    match review.review_kind {\n        RevlogReviewKind::Learning | RevlogReviewKind::Relearning | RevlogReviewKind::Filtered => {\n            Some(IntervalBucket::Learning)\n        }\n        RevlogReviewKind::Review => Some(if review.last_interval < 21 {\n            IntervalBucket::Young\n        } else {\n            IntervalBucket::Mature\n        }),\n        RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => None,\n    }\n}\n\nfn button_index(button_chosen: u8) -> Option<usize> {\n    if (1..=4).contains(&button_chosen) {\n        Some((button_chosen - 1) as usize)\n    } else {\n        None\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/card_counts.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::card_counts::Counts;\nuse anki_proto::stats::graphs_response::CardCounts;\n\nuse crate::card::Card;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    pub(super) fn card_counts(&self) -> CardCounts {\n        let mut excluding_inactive = Counts::default();\n        let mut including_inactive = Counts::default();\n        for card in &self.cards {\n            match card.queue {\n                CardQueue::Suspended => {\n                    excluding_inactive.suspended += 1;\n                }\n                CardQueue::SchedBuried | CardQueue::UserBuried => {\n                    excluding_inactive.buried += 1;\n                }\n                _ => increment_counts(&mut excluding_inactive, card),\n            };\n            increment_counts(&mut including_inactive, card);\n        }\n        CardCounts {\n            excluding_inactive: Some(excluding_inactive),\n            including_inactive: Some(including_inactive),\n        }\n    }\n}\n\nfn increment_counts(counts: &mut Counts, card: &Card) {\n    match card.ctype {\n        CardType::New => {\n            counts.new_cards += 1;\n        }\n        CardType::Learn => {\n            counts.learn += 1;\n        }\n        CardType::Review => {\n            if card.interval < 21 {\n                counts.young += 1;\n            } else {\n                counts.mature += 1;\n            }\n        }\n        CardType::Relearn => {\n            counts.relearn += 1;\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/eases.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::Eases;\n\nuse crate::card::CardType;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    /// (SM-2, FSRS)\n    pub(super) fn eases(&self) -> (Eases, Eases) {\n        let mut eases = Eases::default();\n        let mut ease_values = Vec::new();\n        let mut difficulty = Eases::default();\n        let mut difficulty_values = Vec::new();\n        for card in &self.cards {\n            if let Some(state) = card.memory_state {\n                *difficulty\n                    .eases\n                    .entry(percent_to_bin(state.difficulty() * 100.0, 1))\n                    .or_insert_with(Default::default) += 1;\n                difficulty_values.push(state.difficulty());\n            } else if matches!(card.ctype, CardType::Review | CardType::Relearn) {\n                *eases\n                    .eases\n                    .entry((card.ease_factor / 10) as u32)\n                    .or_insert_with(Default::default) += 1;\n                ease_values.push(card.ease_factor as f32);\n            }\n        }\n\n        eases.average = median(&mut ease_values) / 10.0;\n        difficulty.average = median(&mut difficulty_values) * 100.0;\n\n        (eases, difficulty)\n    }\n}\n\n/// Helper function to calculate the median of a vector\nfn median(data: &mut [f32]) -> f32 {\n    if data.is_empty() {\n        return 0.0;\n    }\n    data.sort_by(|a, b| a.partial_cmp(b).unwrap());\n    let mid = data.len() / 2;\n    if data.len() % 2 == 0 {\n        (data[mid - 1] + data[mid]) / 2.0\n    } else {\n        data[mid]\n    }\n}\n\n/// Bins the number into a bin of 0, 5, .. 95\npub(super) fn percent_to_bin(x: f32, bin_size: u32) -> u32 {\n    if x == 100.0 {\n        100 - bin_size\n    } else {\n        ((x / bin_size as f32).floor() * bin_size as f32) as u32\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn bins() {\n        assert_eq!(percent_to_bin(0.0, 5), 0);\n        assert_eq!(percent_to_bin(4.9, 5), 0);\n        assert_eq!(percent_to_bin(5.0, 5), 5);\n        assert_eq!(percent_to_bin(9.9, 5), 5);\n        assert_eq!(percent_to_bin(99.9, 5), 95);\n        assert_eq!(percent_to_bin(100.0, 5), 95);\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/future_due.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse anki_proto::stats::graphs_response::FutureDue;\n\nuse super::GraphsContext;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::scheduler::timing::is_unix_epoch_timestamp;\n\nimpl GraphsContext {\n    pub(super) fn future_due(&self) -> FutureDue {\n        let mut have_backlog = false;\n        let mut due_by_day: HashMap<i32, u32> = Default::default();\n        let mut daily_load = 0.0;\n        for c in &self.cards {\n            // matched on type because queue changes on burying or suspending a new card\n            if c.ctype == CardType::New {\n                continue;\n            }\n            if c.queue == CardQueue::Suspended {\n                continue;\n            }\n            let due = c.original_or_current_due();\n            let due_day = if is_unix_epoch_timestamp(due) {\n                let offset = due as i64 - self.next_day_start.0;\n                (offset / 86_400) as i32\n            } else {\n                due - (self.days_elapsed as i32)\n            };\n\n            daily_load += 1.0 / c.interval.max(1) as f32;\n\n            // still want to filtered out buried cards that are due today\n            if due_day <= 0 && matches!(c.queue, CardQueue::UserBuried | CardQueue::SchedBuried) {\n                continue;\n            }\n            have_backlog |= due_day < 0;\n            *due_by_day.entry(due_day).or_default() += 1;\n        }\n        FutureDue {\n            future_due: due_by_day,\n            have_backlog,\n            daily_load: daily_load as u32,\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/hours.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::hours::Hour;\nuse anki_proto::stats::graphs_response::Hours;\n\nuse crate::revlog::RevlogReviewKind;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    pub(super) fn hours(&self) -> Hours {\n        let mut data = Hours {\n            one_month: vec![Default::default(); 24],\n            three_months: vec![Default::default(); 24],\n            one_year: vec![Default::default(); 24],\n            all_time: vec![Default::default(); 24],\n        };\n        let mut conditional_buckets = [\n            (\n                self.next_day_start.adding_secs(-86_400 * 365),\n                &mut data.one_year,\n            ),\n            (\n                self.next_day_start.adding_secs(-86_400 * 90),\n                &mut data.three_months,\n            ),\n            (\n                self.next_day_start.adding_secs(-86_400 * 30),\n                &mut data.one_month,\n            ),\n        ];\n        'outer: for review in &self.revlog {\n            if matches!(\n                review.review_kind,\n                RevlogReviewKind::Filtered\n                    | RevlogReviewKind::Manual\n                    | RevlogReviewKind::Rescheduled\n            ) {\n                continue;\n            }\n            let review_secs = review.id.as_secs();\n            let hour = (((review_secs.0 + self.local_offset_secs) / 3600) % 24) as usize;\n            let correct = review.button_chosen > 1;\n            increment_count_for_hour(&mut data.all_time[hour], correct);\n            for (stamp, bucket) in &mut conditional_buckets {\n                if &review_secs < stamp {\n                    continue 'outer;\n                }\n                increment_count_for_hour(&mut bucket[hour], correct);\n            }\n        }\n        data\n    }\n}\n\npub(crate) fn increment_count_for_hour(hour: &mut Hour, correct: bool) {\n    hour.total += 1;\n    if correct {\n        hour.correct += 1;\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/intervals.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::Intervals;\n\nuse crate::card::CardType;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    pub(super) fn intervals(&self) -> Intervals {\n        let mut data = Intervals::default();\n        for card in &self.cards {\n            if matches!(card.ctype, CardType::Review | CardType::Relearn) {\n                *data\n                    .intervals\n                    .entry(card.interval)\n                    .or_insert_with(Default::default) += 1;\n            }\n        }\n        data\n    }\n\n    pub(super) fn stability(&self) -> Intervals {\n        let mut data = Intervals::default();\n        for card in &self.cards {\n            if let Some(state) = &card.memory_state {\n                *data\n                    .intervals\n                    .entry(state.stability.round() as u32)\n                    .or_insert_with(Default::default) += 1;\n            }\n        }\n        data\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod added;\nmod buttons;\nmod card_counts;\nmod eases;\nmod future_due;\nmod hours;\nmod intervals;\nmod retention;\nmod retrievability;\nmod reviews;\nmod today;\n\nuse crate::config::BoolKey;\nuse crate::config::Weekday;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::search::SortMode;\n\nstruct GraphsContext {\n    revlog: Vec<RevlogEntry>,\n    cards: Vec<Card>,\n    next_day_start: TimestampSecs,\n    days_elapsed: u32,\n    local_offset_secs: i64,\n}\n\nimpl Collection {\n    pub(crate) fn graph_data_for_search(\n        &mut self,\n        search: &str,\n        days: u32,\n    ) -> Result<anki_proto::stats::GraphsResponse> {\n        let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;\n        let all = search.trim().is_empty();\n        guard.col.graph_data(all, days)\n    }\n\n    fn graph_data(&mut self, all: bool, days: u32) -> Result<anki_proto::stats::GraphsResponse> {\n        let timing = self.timing_today()?;\n        let revlog_start = if days > 0 {\n            timing\n                .next_day_at\n                .adding_secs(-(((days as i64) + 1) * 86_400))\n        } else {\n            TimestampSecs(0)\n        };\n        let offset = self.local_utc_offset_for_user()?;\n        let local_offset_secs = offset.local_minus_utc() as i64;\n        let revlog = if all {\n            self.storage.get_all_revlog_entries(revlog_start)?\n        } else {\n            self.storage\n                .get_revlog_entries_for_searched_cards_after_stamp(revlog_start)?\n        };\n        let ctx = GraphsContext {\n            revlog,\n            days_elapsed: timing.days_elapsed,\n            cards: self.storage.all_searched_cards()?,\n            next_day_start: timing.next_day_at,\n            local_offset_secs,\n        };\n        let (eases, difficulty) = ctx.eases();\n        let resp = anki_proto::stats::GraphsResponse {\n            added: Some(ctx.added_days()),\n            reviews: Some(ctx.review_counts_and_times()),\n            true_retention: Some(ctx.calculate_true_retention()),\n            future_due: Some(ctx.future_due()),\n            intervals: Some(ctx.intervals()),\n            stability: Some(ctx.stability()),\n            eases: Some(eases),\n            difficulty: Some(difficulty),\n            today: Some(ctx.today()),\n            hours: Some(ctx.hours()),\n            buttons: Some(ctx.buttons()),\n            card_counts: Some(ctx.card_counts()),\n            rollover_hour: self.rollover_for_current_scheduler()? as u32,\n            retrievability: Some(ctx.retrievability()),\n            fsrs: self.get_config_bool(BoolKey::Fsrs),\n        };\n        Ok(resp)\n    }\n\n    pub(crate) fn get_graph_preferences(&self) -> anki_proto::stats::GraphPreferences {\n        anki_proto::stats::GraphPreferences {\n            calendar_first_day_of_week: self.get_first_day_of_week() as i32,\n            card_counts_separate_inactive: self\n                .get_config_bool(BoolKey::CardCountsSeparateInactive),\n            browser_links_supported: true,\n            future_due_show_backlog: self.get_config_bool(BoolKey::FutureDueShowBacklog),\n        }\n    }\n\n    pub(crate) fn set_graph_preferences(\n        &mut self,\n        prefs: anki_proto::stats::GraphPreferences,\n    ) -> Result<()> {\n        self.set_first_day_of_week(match prefs.calendar_first_day_of_week {\n            1 => Weekday::Monday,\n            5 => Weekday::Friday,\n            6 => Weekday::Saturday,\n            _ => Weekday::Sunday,\n        })?;\n        self.set_config_bool_inner(\n            BoolKey::CardCountsSeparateInactive,\n            prefs.card_counts_separate_inactive,\n        )?;\n        self.set_config_bool_inner(BoolKey::FutureDueShowBacklog, prefs.future_due_show_backlog)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/retention.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::collections::HashMap;\n\nuse anki_proto::stats::graphs_response::true_retention_stats::TrueRetention;\nuse anki_proto::stats::graphs_response::TrueRetentionStats;\n\nuse super::GraphsContext;\nuse super::TimestampSecs;\nuse crate::revlog::RevlogReviewKind;\n\nimpl GraphsContext {\n    pub fn calculate_true_retention(&self) -> TrueRetentionStats {\n        let mut stats = TrueRetentionStats::default();\n\n        // create periods\n        let day = 86400;\n        let periods = vec![\n            (\n                \"today\",\n                self.next_day_start.adding_secs(-day),\n                self.next_day_start,\n            ),\n            (\n                \"yesterday\",\n                self.next_day_start.adding_secs(-2 * day),\n                self.next_day_start.adding_secs(-day),\n            ),\n            (\n                \"week\",\n                self.next_day_start.adding_secs(-7 * day),\n                self.next_day_start,\n            ),\n            (\n                \"month\",\n                self.next_day_start.adding_secs(-30 * day),\n                self.next_day_start,\n            ),\n            (\n                \"year\",\n                self.next_day_start.adding_secs(-365 * day),\n                self.next_day_start,\n            ),\n            (\"all_time\", TimestampSecs(0), self.next_day_start),\n        ];\n\n        // create period stats\n        let mut period_stats: HashMap<&str, TrueRetention> = periods\n            .iter()\n            .map(|(name, _, _)| (*name, TrueRetention::default()))\n            .collect();\n\n        self.revlog\n            .iter()\n            .filter(|review| {\n                review.has_rating_and_affects_scheduling()\n                    // cards with an interval ≥ 1 day\n                    && (review.review_kind == RevlogReviewKind::Review\n                        || review.last_interval <= -86400\n                        || review.last_interval >= 1)\n            })\n            .for_each(|review| {\n                for (period_name, start, end) in &periods {\n                    if review.id.as_secs() >= *start && review.id.as_secs() < *end {\n                        let period_stat = period_stats.get_mut(period_name).unwrap();\n                        const MATURE_IVL: i32 = 21; // mature interval is 21 days\n                        match (review.last_interval < MATURE_IVL, review.button_chosen) {\n                            (true, 1) => period_stat.young_failed += 1,\n                            (true, _) => period_stat.young_passed += 1,\n                            (false, 1) => period_stat.mature_failed += 1,\n                            (false, _) => period_stat.mature_passed += 1,\n                        }\n                    }\n                }\n            });\n\n        stats.today = Some(period_stats[\"today\"]);\n        stats.yesterday = Some(period_stats[\"yesterday\"]);\n        stats.week = Some(period_stats[\"week\"]);\n        stats.month = Some(period_stats[\"month\"]);\n        stats.year = Some(period_stats[\"year\"]);\n        stats.all_time = Some(period_stats[\"all_time\"]);\n\n        stats\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/retrievability.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::Retrievability;\nuse fsrs::FSRS;\nuse fsrs::FSRS5_DEFAULT_DECAY;\n\nuse crate::prelude::TimestampSecs;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::stats::graphs::eases::percent_to_bin;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    /// (SM-2, FSRS)\n    pub(super) fn retrievability(&self) -> Retrievability {\n        let mut retrievability = Retrievability::default();\n        let mut card_with_retrievability_count: usize = 0;\n        let timing = SchedTimingToday {\n            days_elapsed: self.days_elapsed,\n            now: TimestampSecs::now(),\n            next_day_at: self.next_day_start,\n        };\n        let fsrs = FSRS::new(None).unwrap();\n        // note id -> (sum, count)\n        let mut note_retrievability: std::collections::HashMap<i64, (f32, u32)> =\n            std::collections::HashMap::new();\n        for card in &self.cards {\n            let entry = note_retrievability\n                .entry(card.note_id.0)\n                .or_insert((0.0, 0));\n            entry.1 += 1;\n            if let Some(state) = card.memory_state {\n                let elapsed_seconds = card.seconds_since_last_review(&timing).unwrap_or_default();\n                let r = fsrs.current_retrievability_seconds(\n                    state.into(),\n                    elapsed_seconds,\n                    card.decay.unwrap_or(FSRS5_DEFAULT_DECAY),\n                );\n\n                *retrievability\n                    .retrievability\n                    .entry(percent_to_bin(r * 100.0, 1))\n                    .or_insert_with(Default::default) += 1;\n                retrievability.sum_by_card += r;\n                card_with_retrievability_count += 1;\n                entry.0 += r;\n            } else {\n                entry.0 += 0.0;\n            }\n        }\n        if card_with_retrievability_count != 0 {\n            retrievability.average =\n                retrievability.sum_by_card * 100.0 / card_with_retrievability_count as f32;\n        }\n        retrievability.sum_by_note = note_retrievability\n            .values()\n            .map(|(sum, count)| sum / *count as f32)\n            .sum();\n        retrievability\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/reviews.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::ReviewCountsAndTimes;\n\nuse super::GraphsContext;\nuse crate::revlog::RevlogReviewKind;\n\nimpl GraphsContext {\n    pub(super) fn review_counts_and_times(&self) -> ReviewCountsAndTimes {\n        let mut data = ReviewCountsAndTimes::default();\n        for review in &self.revlog {\n            if review.review_kind == RevlogReviewKind::Manual\n                || review.review_kind == RevlogReviewKind::Rescheduled\n            {\n                continue;\n            }\n            let day = (review.id.as_secs().elapsed_secs_since(self.next_day_start) / 86_400) as i32;\n            let count = data.count.entry(day).or_insert_with(Default::default);\n            let time = data.time.entry(day).or_insert_with(Default::default);\n            match review.review_kind {\n                RevlogReviewKind::Learning => {\n                    count.learn += 1;\n                    time.learn += review.taken_millis;\n                }\n                RevlogReviewKind::Relearning => {\n                    count.relearn += 1;\n                    time.relearn += review.taken_millis;\n                }\n                RevlogReviewKind::Review => {\n                    if review.last_interval < 21 {\n                        count.young += 1;\n                        time.young += review.taken_millis;\n                    } else {\n                        count.mature += 1;\n                        time.mature += review.taken_millis;\n                    }\n                }\n                RevlogReviewKind::Filtered => {\n                    count.filtered += 1;\n                    time.filtered += review.taken_millis;\n                }\n                RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => unreachable!(),\n            }\n        }\n        data\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/graphs/today.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::stats::graphs_response::Today;\n\nuse crate::revlog::RevlogReviewKind;\nuse crate::stats::graphs::GraphsContext;\n\nimpl GraphsContext {\n    pub(super) fn today(&self) -> Today {\n        let mut today = Today::default();\n        let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0;\n        for review in self.revlog.iter().rev() {\n            if review.id.0 < start_of_today_ms {\n                continue;\n            }\n            if review.review_kind == RevlogReviewKind::Manual\n                || review.review_kind == RevlogReviewKind::Rescheduled\n            {\n                continue;\n            }\n            // total\n            today.answer_count += 1;\n            today.answer_millis += review.taken_millis;\n            // correct\n            if review.button_chosen > 1 {\n                today.correct_count += 1;\n            }\n            // mature\n            if review.last_interval >= 21 {\n                today.mature_count += 1;\n                if review.button_chosen > 1 {\n                    today.mature_correct += 1;\n                }\n            }\n            // type counts\n            match review.review_kind {\n                RevlogReviewKind::Learning => today.learn_count += 1,\n                RevlogReviewKind::Review => today.review_count += 1,\n                RevlogReviewKind::Relearning => today.relearn_count += 1,\n                RevlogReviewKind::Filtered => today.early_review_count += 1,\n                RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => unreachable!(),\n            }\n        }\n        today\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod card;\nmod graphs;\nmod service;\nmod today;\n\npub use today::studied_today;\n"
  },
  {
    "path": "rslib/src/stats/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::revlog::RevlogReviewKind;\n\nimpl crate::services::StatsService for Collection {\n    fn card_stats(\n        &mut self,\n        input: anki_proto::cards::CardId,\n    ) -> error::Result<anki_proto::stats::CardStatsResponse> {\n        self.card_stats(input.cid.into())\n    }\n\n    fn get_review_logs(\n        &mut self,\n        input: anki_proto::cards::CardId,\n    ) -> error::Result<anki_proto::stats::ReviewLogs> {\n        self.get_review_logs(input.cid.into())\n    }\n\n    fn graphs(\n        &mut self,\n        input: anki_proto::stats::GraphsRequest,\n    ) -> error::Result<anki_proto::stats::GraphsResponse> {\n        self.graph_data_for_search(&input.search, input.days)\n    }\n\n    fn get_graph_preferences(&mut self) -> error::Result<anki_proto::stats::GraphPreferences> {\n        Ok(Collection::get_graph_preferences(self))\n    }\n\n    fn set_graph_preferences(\n        &mut self,\n        input: anki_proto::stats::GraphPreferences,\n    ) -> error::Result<()> {\n        self.set_graph_preferences(input)\n    }\n}\n\nimpl From<RevlogReviewKind> for i32 {\n    fn from(kind: RevlogReviewKind) -> Self {\n        (match kind {\n            RevlogReviewKind::Learning => anki_proto::stats::revlog_entry::ReviewKind::Learning,\n            RevlogReviewKind::Review => anki_proto::stats::revlog_entry::ReviewKind::Review,\n            RevlogReviewKind::Relearning => anki_proto::stats::revlog_entry::ReviewKind::Relearning,\n            RevlogReviewKind::Filtered => anki_proto::stats::revlog_entry::ReviewKind::Filtered,\n            RevlogReviewKind::Manual => anki_proto::stats::revlog_entry::ReviewKind::Manual,\n            RevlogReviewKind::Rescheduled => {\n                anki_proto::stats::revlog_entry::ReviewKind::Rescheduled\n            }\n        }) as i32\n    }\n}\n"
  },
  {
    "path": "rslib/src/stats/today.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_i18n::I18n;\n\nuse crate::prelude::*;\nuse crate::scheduler::timespan::Timespan;\nuse crate::scheduler::timespan::TimespanUnit;\n\npub fn studied_today(cards: u32, secs: f32, tr: &I18n) -> String {\n    let span = Timespan::from_secs(secs).natural_span();\n    let unit = std::cmp::min(span.unit(), TimespanUnit::Minutes);\n    let amount = span.to_unit(unit).as_unit();\n    let secs_per_card = if cards > 0 {\n        secs / (cards as f32)\n    } else {\n        0.0\n    };\n    tr.statistics_studied_today(unit.as_str(), secs_per_card, amount, cards)\n        .into()\n}\n\nimpl Collection {\n    pub fn studied_today(&mut self) -> Result<String> {\n        let timing = self.timing_today()?;\n        let today = self.storage.studied_today(timing.next_day_at)?;\n        Ok(studied_today(today.cards, today.seconds as f32, &self.tr))\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_i18n::I18n;\n\n    use super::studied_today;\n\n    #[test]\n    fn today() {\n        // temporary test of fluent term handling\n        let tr = I18n::template_only();\n        assert_eq!(\n            &studied_today(3, 13.0, &tr).replace('\\n', \" \"),\n            \"Studied 3 cards in 13 seconds today (4.33s/card)\"\n        );\n        assert_eq!(\n            &studied_today(300, 5400.0, &tr).replace('\\n', \" \"),\n            \"Studied 300 cards in 90 minutes today (18s/card)\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/card/active_new_cards.sql",
    "content": "SELECT id,\n  nid,\n  ord,\n  cast(mod AS integer),\n  did,\n  odid\nFROM cards\nWHERE did IN (\n    SELECT id\n    FROM active_decks\n  )\n  AND queue = 0"
  },
  {
    "path": "rslib/src/storage/card/add_card.sql",
    "content": "INSERT INTO cards (\n    id,\n    nid,\n    did,\n    ord,\n    mod,\n    usn,\n    type,\n    queue,\n    due,\n    ivl,\n    factor,\n    reps,\n    lapses,\n    left,\n    odue,\n    odid,\n    flags,\n    data\n  )\nVALUES (\n    (\n      CASE\n        WHEN ?1 IN (\n          SELECT id\n          FROM cards\n        ) THEN (\n          SELECT max(id) + 1\n          FROM cards\n        )\n        ELSE ?1\n      END\n    ),\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?\n  )"
  },
  {
    "path": "rslib/src/storage/card/add_card_if_unique.sql",
    "content": "INSERT\n  OR IGNORE INTO cards (\n    id,\n    nid,\n    did,\n    ord,\n    mod,\n    usn,\n    type,\n    queue,\n    due,\n    ivl,\n    factor,\n    reps,\n    lapses,\n    left,\n    odue,\n    odid,\n    flags,\n    data\n  )\nVALUES (\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?\n  )"
  },
  {
    "path": "rslib/src/storage/card/add_or_update.sql",
    "content": "INSERT\n  OR REPLACE INTO cards (\n    id,\n    nid,\n    did,\n    ord,\n    mod,\n    usn,\n    type,\n    queue,\n    due,\n    ivl,\n    factor,\n    reps,\n    lapses,\n    left,\n    odue,\n    odid,\n    flags,\n    data\n  )\nVALUES (\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?\n  )"
  },
  {
    "path": "rslib/src/storage/card/at_or_above_position.sql",
    "content": "INSERT INTO search_cids\nSELECT id\nFROM cards\nWHERE due >= ?\n  AND type = ?"
  },
  {
    "path": "rslib/src/storage/card/congrats.sql",
    "content": "SELECT coalesce(\n    sum(\n      queue IN (:review_queue, :day_learn_queue)\n      AND due <= :today\n    ),\n    0\n  ) AS review_count,\n  coalesce(sum(queue = :new_queue), 0) AS new_count,\n  coalesce(sum(queue = :sched_buried_queue), 0) AS sched_buried,\n  coalesce(sum(queue = :user_buried_queue), 0) AS user_buried,\n  coalesce(sum(queue = :learn_queue), 0) AS learn_count,\n  max(\n    0,\n    coalesce(\n      min(\n        CASE\n          WHEN queue = :learn_queue THEN due\n          ELSE NULL\n        END\n      ),\n      0\n    )\n  ) AS first_learn_due\nFROM cards\nWHERE did IN (\n    SELECT id\n    FROM active_decks\n  )"
  },
  {
    "path": "rslib/src/storage/card/data.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse rusqlite::types::FromSql;\nuse rusqlite::types::FromSqlError;\nuse rusqlite::types::ValueRef;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_json::Value;\n\nuse crate::card::FsrsMemoryState;\nuse crate::prelude::*;\nuse crate::serde::default_on_invalid;\n\n/// Helper for serdeing the card data column.\n#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]\n#[serde(default)]\npub(crate) struct CardData {\n    #[serde(\n        rename = \"pos\",\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) original_position: Option<u32>,\n    #[serde(\n        rename = \"s\",\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) fsrs_stability: Option<f32>,\n    #[serde(\n        rename = \"d\",\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) fsrs_difficulty: Option<f32>,\n    #[serde(\n        rename = \"dr\",\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) fsrs_desired_retention: Option<f32>,\n    #[serde(\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) decay: Option<f32>,\n    #[serde(\n        rename = \"lrt\",\n        skip_serializing_if = \"Option::is_none\",\n        deserialize_with = \"default_on_invalid\"\n    )]\n    pub(crate) last_review_time: Option<TimestampSecs>,\n\n    /// A string representation of a JSON object storing optional data\n    /// associated with the card, so v3 custom scheduling code can persist\n    /// state.\n    #[serde(default, rename = \"cd\", skip_serializing_if = \"meta_is_empty\")]\n    pub(crate) custom_data: String,\n}\n\nimpl CardData {\n    pub(crate) fn from_card(card: &Card) -> Self {\n        Self {\n            original_position: card.original_position,\n            fsrs_stability: card.memory_state.as_ref().map(|m| m.stability),\n            fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty),\n            fsrs_desired_retention: card.desired_retention,\n            decay: card.decay,\n            last_review_time: card.last_review_time,\n            custom_data: card.custom_data.clone(),\n        }\n    }\n\n    pub(crate) fn from_str(s: &str) -> Self {\n        serde_json::from_str(s).unwrap_or_default()\n    }\n\n    pub(crate) fn memory_state(&self) -> Option<FsrsMemoryState> {\n        if let Some(stability) = self.fsrs_stability {\n            if let Some(difficulty) = self.fsrs_difficulty {\n                return Some(FsrsMemoryState {\n                    stability,\n                    difficulty,\n                });\n            }\n        }\n        None\n    }\n\n    pub(crate) fn convert_to_json(&mut self) -> Result<String> {\n        if let Some(v) = &mut self.fsrs_stability {\n            round_to_places(v, 4)\n        }\n        if let Some(v) = &mut self.fsrs_difficulty {\n            round_to_places(v, 3)\n        }\n        if let Some(v) = &mut self.fsrs_desired_retention {\n            round_to_places(v, 2)\n        }\n        if let Some(v) = &mut self.decay {\n            round_to_places(v, 3)\n        }\n        serde_json::to_string(&self).map_err(Into::into)\n    }\n}\n\nfn round_to_places(value: &mut f32, decimal_places: u32) {\n    let factor = 10_f32.powi(decimal_places as i32);\n    *value = (*value * factor).round() / factor;\n}\n\nimpl FromSql for CardData {\n    /// Infallible; invalid/missing data results in the default value.\n    fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {\n        if let ValueRef::Text(s) = value {\n            Ok(serde_json::from_slice(s).unwrap_or_default())\n        } else {\n            Ok(Self::default())\n        }\n    }\n}\n\n/// Serialize the JSON `data` for a card.\npub(crate) fn card_data_string(card: &Card) -> String {\n    CardData::from_card(card).convert_to_json().unwrap()\n}\n\nfn meta_is_empty(s: &str) -> bool {\n    matches!(s, \"\" | \"{}\")\n}\n\nfn validate_custom_data(json_str: &str) -> Result<()> {\n    if !meta_is_empty(json_str) {\n        let object: HashMap<&str, Value> =\n            serde_json::from_str(json_str).or_invalid(\"custom data not an object\")?;\n        require!(\n            object.keys().all(|k| k.len() <= 8),\n            \"custom data keys must be <= 8 bytes\"\n        );\n        require!(\n            json_str.len() <= 100,\n            \"serialized custom data must be under 100 bytes\"\n        );\n    }\n    Ok(())\n}\n\nimpl Card {\n    pub(crate) fn validate_custom_data(&self) -> Result<()> {\n        validate_custom_data(&self.custom_data)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    #[test]\n    fn validation() {\n        assert!(validate_custom_data(\"\").is_ok());\n        assert!(validate_custom_data(\"{}\").is_ok());\n        assert!(validate_custom_data(r#\"{\"foo\": 5}\"#).is_ok());\n        assert!(validate_custom_data(r#\"[\"foo\"]\"#).is_err());\n        assert!(validate_custom_data(r#\"{\"日\": 5}\"#).is_ok());\n        assert!(validate_custom_data(r#\"{\"日本語\": 5}\"#).is_err());\n        assert!(validate_custom_data(&format!(r#\"{{\"foo\": \"{}\"}}\"#, \"x\".repeat(100))).is_err());\n    }\n\n    #[test]\n    fn compact_floats() {\n        let mut data = CardData {\n            original_position: None,\n            fsrs_stability: Some(123.45678),\n            fsrs_difficulty: Some(1.234567),\n            fsrs_desired_retention: Some(0.987654),\n            decay: Some(0.123456),\n            last_review_time: None,\n            custom_data: \"\".to_string(),\n        };\n        assert_eq!(\n            data.convert_to_json().unwrap(),\n            r#\"{\"s\":123.4568,\"d\":1.235,\"dr\":0.99,\"decay\":0.123}\"#\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/card/deck_due_counts.sql",
    "content": "SELECT CASE\n    WHEN odid == 0 THEN did\n    ELSE odid\n  END AS original_did,\n  CASE\n    WHEN odid == 0 THEN due\n    ELSE odue\n  END AS true_due,\n  COUNT() AS COUNT\nFROM cards\nWHERE type = 2\n  AND queue != -1\nGROUP BY original_did,\n  true_due"
  },
  {
    "path": "rslib/src/storage/card/due_cards.sql",
    "content": "SELECT id,\n  nid,\n  due,\n  cast(ivl AS integer),\n  cast(mod AS integer),\n  did,\n  odid\nFROM cards\nWHERE did IN (\n    SELECT id\n    FROM active_decks\n  )\n  AND (\n    queue = ?\n    AND due <= ?\n  )"
  },
  {
    "path": "rslib/src/storage/card/filtered.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::decks::FilteredSearchOrder;\nuse crate::decks::FilteredSearchTerm;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::storage::sqlite::SqlSortOrder;\n\npub(crate) fn order_and_limit_for_search(\n    term: &FilteredSearchTerm,\n    timing: SchedTimingToday,\n    fsrs: bool,\n) -> String {\n    let temp_string;\n    let today = timing.days_elapsed;\n    let next_day_at = timing.next_day_at.0;\n    let now = timing.now.0;\n    let order = match term.order() {\n        FilteredSearchOrder::OldestReviewedFirst => \"(select max(id) from revlog where cid=c.id)\",\n        FilteredSearchOrder::Random => \"random()\",\n        FilteredSearchOrder::IntervalsAscending => \"ivl\",\n        FilteredSearchOrder::IntervalsDescending => \"ivl desc\",\n        FilteredSearchOrder::Lapses => \"lapses desc\",\n        FilteredSearchOrder::Added => \"n.id, c.ord\",\n        FilteredSearchOrder::ReverseAdded => \"n.id desc, c.ord asc\",\n        FilteredSearchOrder::Due => {\n            let current_timestamp = timing.now.0;\n            temp_string = format!(\n                \"(case when c.due > 1000000000 then due else (due - {today}) * 86400 + {current_timestamp} end), c.ord\");\n            &temp_string\n        }\n        FilteredSearchOrder::RetrievabilityAscending => {\n            temp_string =\n                build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Ascending);\n            &temp_string\n        }\n        FilteredSearchOrder::RetrievabilityDescending => {\n            temp_string =\n                build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Descending);\n            &temp_string\n        }\n        FilteredSearchOrder::RelativeOverdueness => {\n            temp_string =\n                format!(\"extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) asc\");\n            &temp_string\n        }\n    };\n\n    format!(\"{}, fnvhash(c.id, c.mod) limit {}\", order, term.limit)\n}\n\nfn build_retrievability_query(\n    fsrs: bool,\n    today: u32,\n    next_day_at: i64,\n    now: i64,\n    order: SqlSortOrder,\n) -> String {\n    if fsrs {\n        format!(\n            \"extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, ivl, {today}, {next_day_at}, {now}) {order}\"\n        )\n    } else {\n        String::new()\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/card/fix_due_new.sql",
    "content": "UPDATE cards\nSET due = (\n    CASE\n      WHEN type = 0\n      AND queue != 4 THEN 1000000 + due % 1000000\n      ELSE due\n    END\n  ),\n  mod = ?1,\n  usn = ?2\nWHERE due != (\n    CASE\n      WHEN type = 0\n      AND queue != 4 THEN 1000000 + due % 1000000\n      ELSE due\n    END\n  )\n  AND due >= 1000000;"
  },
  {
    "path": "rslib/src/storage/card/fix_due_other.sql",
    "content": "UPDATE cards\nSET due = (\n    CASE\n      WHEN queue = 2\n      AND due > 100000 THEN ?1\n      ELSE min(max(round(due), -2147483648), 2147483647)\n    END\n  ),\n  mod = ?2,\n  usn = ?3\nWHERE due != (\n    CASE\n      WHEN queue = 2\n      AND due > 100000 THEN ?1\n      ELSE min(max(round(due), -2147483648), 2147483647)\n    END\n  );"
  },
  {
    "path": "rslib/src/storage/card/fix_ivl.sql",
    "content": "UPDATE cards\nSET ivl = min(max(round(ivl), 0), 2147483647),\n  mod = ?1,\n  usn = ?2\nWHERE ivl != min(max(round(ivl), 0), 2147483647)"
  },
  {
    "path": "rslib/src/storage/card/fix_low_ease.sql",
    "content": "UPDATE cards\nSET factor = 2500,\n  usn = ?\nWHERE factor != 0\n  AND factor <= 2000\n  AND (\n    did IN DECK_IDS\n    OR odid IN DECK_IDS\n  )"
  },
  {
    "path": "rslib/src/storage/card/fix_mod.sql",
    "content": "UPDATE cards\nSET mod = cast(mod AS integer)\nWHERE mod != cast(mod AS integer)"
  },
  {
    "path": "rslib/src/storage/card/fix_odue.sql",
    "content": "UPDATE cards\nSET odue = (\n    CASE\n      WHEN odue > 0\n      AND (\n        type = 1\n        OR queue = 2\n      )\n      AND NOT ?3\n      AND NOT odid THEN 0\n      ELSE min(max(round(odue), -2147483648), 2147483647)\n    END\n  ),\n  mod = ?1,\n  usn = ?2\nWHERE odue != (\n    CASE\n      WHEN odue > 0\n      AND (\n        type = 1\n        OR queue = 2\n      )\n      AND NOT ?3\n      AND NOT odid THEN 0\n      ELSE min(max(round(odue), -2147483648), 2147483647)\n    END\n  );"
  },
  {
    "path": "rslib/src/storage/card/fix_ordinal.sql",
    "content": "UPDATE cards\nSET ord = max(0, min(30000, ord)),\n  mod = ?1,\n  usn = ?2\nWHERE ord != max(0, min(30000, ord))"
  },
  {
    "path": "rslib/src/storage/card/get_card.sql",
    "content": "SELECT id,\n  nid,\n  did,\n  ord,\n  cast(mod AS integer),\n  usn,\n  type,\n  queue,\n  due,\n  cast(ivl AS integer),\n  factor,\n  reps,\n  lapses,\n  left,\n  odue,\n  odid,\n  flags,\n  data\nFROM cards"
  },
  {
    "path": "rslib/src/storage/card/get_card_entry.sql",
    "content": "SELECT id,\n  nid,\n  CASE\n    WHEN odid = 0 THEN did\n    ELSE odid\n  END AS did\nFROM cards;"
  },
  {
    "path": "rslib/src/storage/card/get_ignored_before_count.sql",
    "content": "SELECT COUNT(DISTINCT cid)\nFROM revlog\nWHERE id > ?\n  AND type == 0\n  AND cid IN search_cids"
  },
  {
    "path": "rslib/src/storage/card/intraday_due.sql",
    "content": "SELECT id,\n  nid,\n  due,\n  cast(mod AS integer),\n  did,\n  odid\nFROM cards\nWHERE did IN (\n    SELECT id\n    FROM active_decks\n  )\n  AND (\n    queue IN (1, 4)\n    AND due <= ?\n  )"
  },
  {
    "path": "rslib/src/storage/card/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod data;\npub(crate) mod filtered;\n\nuse std::collections::HashSet;\nuse std::convert::TryFrom;\nuse std::fmt;\nuse std::result;\n\nuse anki_proto::stats::CardEntry;\nuse rusqlite::named_params;\nuse rusqlite::params;\nuse rusqlite::types::FromSql;\nuse rusqlite::types::FromSqlError;\nuse rusqlite::types::ValueRef;\nuse rusqlite::OptionalExtension;\nuse rusqlite::Row;\n\nuse self::data::CardData;\nuse super::ids_to_string;\nuse super::sqlite::SqlSortOrder;\nuse crate::card::Card;\nuse crate::card::CardId;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::deckconfig::DeckConfigId;\nuse crate::deckconfig::ReviewCardOrder;\nuse crate::decks::Deck;\nuse crate::decks::DeckId;\nuse crate::decks::DeckKind;\nuse crate::error::Result;\nuse crate::notes::NoteId;\nuse crate::scheduler::congrats::CongratsInfo;\nuse crate::scheduler::fsrs::memory_state::get_last_revlog_info;\nuse crate::scheduler::queue::BuryMode;\nuse crate::scheduler::queue::DueCard;\nuse crate::scheduler::queue::DueCardKind;\nuse crate::scheduler::queue::NewCard;\nuse crate::scheduler::timing::SchedTimingToday;\nuse crate::timestamp::TimestampMillis;\nuse crate::timestamp::TimestampSecs;\nuse crate::types::Usn;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) struct CardFixStats {\n    pub new_cards_fixed: usize,\n    pub other_cards_fixed: usize,\n    pub last_review_time_fixed: usize,\n}\n\nimpl FromSql for CardType {\n    fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> {\n        if let ValueRef::Integer(i) = value {\n            Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?)\n        } else {\n            Err(FromSqlError::InvalidType)\n        }\n    }\n}\n\nimpl FromSql for CardQueue {\n    fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> {\n        if let ValueRef::Integer(i) = value {\n            Ok(Self::try_from(i as i8).map_err(|_| FromSqlError::InvalidType)?)\n        } else {\n            Err(FromSqlError::InvalidType)\n        }\n    }\n}\n\nfn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {\n    let data: CardData = row.get(17)?;\n    Ok(Card {\n        id: row.get(0)?,\n        note_id: row.get(1)?,\n        deck_id: row.get(2)?,\n        template_idx: row.get(3)?,\n        mtime: row.get(4)?,\n        usn: row.get(5)?,\n        ctype: row.get(6)?,\n        queue: row.get(7)?,\n        due: row.get(8).ok().unwrap_or_default(),\n        interval: row.get(9)?,\n        ease_factor: row.get(10)?,\n        reps: row.get(11)?,\n        lapses: row.get(12)?,\n        remaining_steps: row.get(13)?,\n        original_due: row.get(14).ok().unwrap_or_default(),\n        original_deck_id: row.get(15)?,\n        flags: row.get(16)?,\n        original_position: data.original_position,\n        memory_state: data.memory_state(),\n        desired_retention: data.fsrs_desired_retention,\n        decay: data.decay,\n        last_review_time: data.last_review_time,\n        custom_data: data.custom_data,\n    })\n}\n\nfn row_to_card_entry(row: &Row) -> Result<CardEntry> {\n    Ok(CardEntry {\n        id: row.get(0)?,\n        note_id: row.get(1)?,\n        deck_id: row.get(2)?,\n    })\n}\n\nfn row_to_new_card(row: &Row) -> result::Result<NewCard, rusqlite::Error> {\n    Ok(NewCard {\n        id: row.get(0)?,\n        note_id: row.get(1)?,\n        template_index: row.get(2)?,\n        mtime: row.get(3)?,\n        current_deck_id: row.get(4)?,\n        original_deck_id: row.get(5)?,\n        hash: 0,\n    })\n}\n\nimpl super::SqliteStorage {\n    pub fn get_card(&self, cid: CardId) -> Result<Option<Card>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get_card.sql\"), \" where id = ?\"))?\n            .query_row(params![cid], row_to_card)\n            .optional()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_all_card_entries(&self) -> Result<Vec<CardEntry>> {\n        self.db\n            .prepare_cached(include_str!(\"get_card_entry.sql\"))?\n            .query_and_then([], row_to_card_entry)?\n            .collect()\n    }\n\n    pub(crate) fn update_card(&self, card: &Card) -> Result<()> {\n        let mut stmt = self.db.prepare_cached(include_str!(\"update_card.sql\"))?;\n        stmt.execute(params![\n            card.note_id,\n            card.deck_id,\n            card.template_idx,\n            card.mtime,\n            card.usn,\n            card.ctype as u8,\n            card.queue as i8,\n            card.due,\n            card.interval,\n            card.ease_factor,\n            card.reps,\n            card.lapses,\n            card.remaining_steps,\n            card.original_due,\n            card.original_deck_id,\n            card.flags,\n            CardData::from_card(card).convert_to_json()?,\n            card.id,\n        ])?;\n        Ok(())\n    }\n\n    pub(crate) fn add_card(&self, card: &mut Card) -> Result<()> {\n        let now = TimestampMillis::now().0;\n        let mut stmt = self.db.prepare_cached(include_str!(\"add_card.sql\"))?;\n        stmt.execute(params![\n            now,\n            card.note_id,\n            card.deck_id,\n            card.template_idx,\n            card.mtime,\n            card.usn,\n            card.ctype as u8,\n            card.queue as i8,\n            card.due,\n            card.interval,\n            card.ease_factor,\n            card.reps,\n            card.lapses,\n            card.remaining_steps,\n            card.original_due,\n            card.original_deck_id,\n            card.flags,\n            CardData::from_card(card).convert_to_json()?,\n        ])?;\n        card.id = CardId(self.db.last_insert_rowid());\n        Ok(())\n    }\n\n    /// Add card if id is unique. True if card was added.\n    pub(crate) fn add_card_if_unique(&self, card: &Card) -> Result<bool> {\n        self.db\n            .prepare_cached(include_str!(\"add_card_if_unique.sql\"))?\n            .execute(params![\n                card.id,\n                card.note_id,\n                card.deck_id,\n                card.template_idx,\n                card.mtime,\n                card.usn,\n                card.ctype as u8,\n                card.queue as i8,\n                card.due,\n                card.interval,\n                card.ease_factor,\n                card.reps,\n                card.lapses,\n                card.remaining_steps,\n                card.original_due,\n                card.original_deck_id,\n                card.flags,\n                CardData::from_card(card).convert_to_json()?,\n            ])\n            .map(|n_rows| n_rows == 1)\n            .map_err(Into::into)\n    }\n\n    /// Add or update card, using the provided ID. Used for syncing & undoing.\n    pub(crate) fn add_or_update_card(&self, card: &Card) -> Result<()> {\n        let mut stmt = self.db.prepare_cached(include_str!(\"add_or_update.sql\"))?;\n        stmt.execute(params![\n            card.id,\n            card.note_id,\n            card.deck_id,\n            card.template_idx,\n            card.mtime,\n            card.usn,\n            card.ctype as u8,\n            card.queue as i8,\n            card.due,\n            card.interval,\n            card.ease_factor,\n            card.reps,\n            card.lapses,\n            card.remaining_steps,\n            card.original_due,\n            card.original_deck_id,\n            card.flags,\n            CardData::from_card(card).convert_to_json()?,\n        ])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn remove_card(&self, cid: CardId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from cards where id = ?\")?\n            .execute([cid])?;\n        Ok(())\n    }\n\n    pub(crate) fn for_each_intraday_card_in_active_decks<F>(\n        &self,\n        learn_cutoff: TimestampSecs,\n        mut func: F,\n    ) -> Result<()>\n    where\n        F: FnMut(DueCard),\n    {\n        let mut stmt = self.db.prepare_cached(include_str!(\"intraday_due.sql\"))?;\n        let mut rows = stmt.query(params![learn_cutoff])?;\n        while let Some(row) = rows.next()? {\n            func(DueCard {\n                id: row.get(0)?,\n                note_id: row.get(1)?,\n                due: row.get(2).ok().unwrap_or_default(),\n                mtime: row.get(3)?,\n                current_deck_id: row.get(4)?,\n                original_deck_id: row.get(5)?,\n                kind: DueCardKind::Learning,\n            })\n        }\n\n        Ok(())\n    }\n\n    /// Call func() for each review card or interday learning card, stopping\n    /// when it returns false or no more cards found.\n    pub(crate) fn for_each_due_card_in_active_decks<F>(\n        &self,\n        timing: SchedTimingToday,\n        order: ReviewCardOrder,\n        kind: DueCardKind,\n        fsrs: bool,\n        mut func: F,\n    ) -> Result<()>\n    where\n        F: FnMut(DueCard) -> Result<bool>,\n    {\n        let order_clause = review_order_sql(order, timing, fsrs);\n        let mut stmt = self.db.prepare_cached(&format!(\n            \"{} order by {}\",\n            include_str!(\"due_cards.sql\"),\n            order_clause\n        ))?;\n        let queue = match kind {\n            DueCardKind::Review => CardQueue::Review,\n            DueCardKind::Learning => CardQueue::DayLearn,\n        };\n        let mut rows = stmt.query(params![queue as i8, timing.days_elapsed])?;\n        while let Some(row) = rows.next()? {\n            if !func(DueCard {\n                id: row.get(0)?,\n                note_id: row.get(1)?,\n                due: row.get(2).ok().unwrap_or_default(),\n                mtime: row.get(4)?,\n                current_deck_id: row.get(5)?,\n                original_deck_id: row.get(6)?,\n                kind,\n            })? {\n                break;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Call func() for each new card in the provided deck, stopping when it\n    /// returns or no more cards found.\n    pub(crate) fn for_each_new_card_in_deck<F>(\n        &self,\n        deck: DeckId,\n        sort: NewCardSorting,\n        mut func: F,\n    ) -> Result<()>\n    where\n        F: FnMut(NewCard) -> Result<bool>,\n    {\n        let mut stmt = self.db.prepare_cached(&format!(\n            \"{} ORDER BY {}\",\n            include_str!(\"new_cards.sql\"),\n            sort.write()\n        ))?;\n        let mut rows = stmt.query(params![deck])?;\n        while let Some(row) = rows.next()? {\n            if !func(row_to_new_card(row)?)? {\n                break;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Call func() for each new card in the active decks, stopping when it\n    /// returns false or no more cards found.\n    pub(crate) fn for_each_new_card_in_active_decks<F>(\n        &self,\n        order: NewCardSorting,\n        mut func: F,\n    ) -> Result<()>\n    where\n        F: FnMut(NewCard) -> Result<bool>,\n    {\n        let mut stmt = self.db.prepare_cached(&format!(\n            \"{} ORDER BY {}\",\n            include_str!(\"active_new_cards.sql\"),\n            order.write(),\n        ))?;\n        let mut rows = stmt.query(params![])?;\n        while let Some(row) = rows.next()? {\n            if !func(row_to_new_card(row)?)? {\n                break;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Fix some invalid card properties, and return number of changed cards.\n    pub(crate) fn fix_card_properties(\n        &self,\n        today: u32,\n        mtime: TimestampSecs,\n        usn: Usn,\n        v1_sched: bool,\n    ) -> Result<CardFixStats> {\n        let new_cnt = self\n            .db\n            .prepare(include_str!(\"fix_due_new.sql\"))?\n            .execute(params![mtime, usn])?;\n        let mut other_cnt = self\n            .db\n            .prepare(include_str!(\"fix_due_other.sql\"))?\n            .execute(params![today, mtime, usn])?;\n        other_cnt += self\n            .db\n            .prepare(include_str!(\"fix_odue.sql\"))?\n            .execute(params![mtime, usn, v1_sched])?;\n        other_cnt += self\n            .db\n            .prepare(include_str!(\"fix_ivl.sql\"))?\n            .execute(params![mtime, usn])?;\n        other_cnt += self\n            .db\n            .prepare(include_str!(\"fix_mod.sql\"))?\n            .execute(params![])?;\n        other_cnt += self\n            .db\n            .prepare(include_str!(\"fix_ordinal.sql\"))?\n            .execute(params![mtime, usn])?;\n        let mut last_review_time_cnt = 0;\n        let revlog = self.get_all_revlog_entries_in_card_order()?;\n        let last_revlog_info = get_last_revlog_info(&revlog);\n        for (card_id, last_revlog_info) in last_revlog_info {\n            let card = self.get_card(card_id)?;\n            if last_revlog_info.last_reviewed_at.is_none() {\n                continue;\n            } else if let Some(mut card) = card {\n                if card.ctype != CardType::New && card.last_review_time.is_none() {\n                    card.last_review_time = last_revlog_info.last_reviewed_at;\n                    self.update_card(&card)?;\n                    last_review_time_cnt += 1;\n                }\n            }\n        }\n        Ok(CardFixStats {\n            new_cards_fixed: new_cnt,\n            other_cards_fixed: other_cnt,\n            last_review_time_fixed: last_review_time_cnt,\n        })\n    }\n\n    pub(crate) fn delete_orphaned_cards(&self) -> Result<usize> {\n        self.db\n            .prepare(\"delete from cards where nid not in (select id from notes)\")?\n            .execute([])\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn all_filtered_cards_by_deck(&self) -> Result<Vec<(CardId, DeckId)>> {\n        self.db\n            .prepare(\"select id, did from cards where odid > 0\")?\n            .query_and_then([], |r| -> Result<_> { Ok((r.get(0)?, r.get(1)?)) })?\n            .collect()\n    }\n\n    pub(crate) fn max_new_card_position(&self) -> Result<u32> {\n        self.db\n            .prepare(\"select max(due)+1 from cards where type=0\")?\n            .query_row([], |r| r.get(0))\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_card_by_ordinal(&self, nid: NoteId, ord: u16) -> Result<Option<Card>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_card.sql\"),\n                \" where nid = ? and ord = ?\"\n            ))?\n            .query_row(params![nid, ord], row_to_card)\n            .optional()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn clear_pending_card_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update cards set usn = 0 where usn = -1\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn have_at_least_one_card(&self) -> Result<bool> {\n        self.db\n            .prepare_cached(\"select null from cards\")?\n            .query([])?\n            .next()\n            .map(|o| o.is_some())\n            .map_err(Into::into)\n    }\n\n    pub fn all_cards_of_note(&self, nid: NoteId) -> Result<Vec<Card>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get_card.sql\"), \" where nid = ?\"))?\n            .query_and_then([nid], |r| row_to_card(r).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn all_cards_of_notes_above_ordinal(\n        &mut self,\n        note_ids: &[NoteId],\n        ordinal: usize,\n    ) -> Result<Vec<Card>> {\n        self.with_ids_in_searched_notes_table(note_ids, || {\n            self.db\n                .prepare_cached(concat!(\n                    include_str!(\"get_card.sql\"),\n                    \" where nid in (select nid from search_nids) and ord > ?\"\n                ))?\n                .query_and_then([ordinal as i64], |r| row_to_card(r).map_err(Into::into))?\n                .collect()\n        })\n    }\n\n    pub fn all_card_ids_of_note_in_template_order(&self, nid: NoteId) -> Result<Vec<CardId>> {\n        self.db\n            .prepare_cached(\"select id from cards where nid = ? order by ord\")?\n            .query_and_then([nid], |r| Ok(CardId(r.get(0)?)))?\n            .collect()\n    }\n\n    pub(crate) fn get_all_card_ids(&self) -> Result<HashSet<CardId>> {\n        self.db\n            .prepare(\"SELECT id FROM cards\")?\n            .query_and_then([], |row| Ok(row.get(0)?))?\n            .collect()\n    }\n\n    pub(crate) fn all_cards_as_nid_and_ord(&self) -> Result<HashSet<(NoteId, u16)>> {\n        self.db\n            .prepare(\"SELECT nid, ord FROM cards\")?\n            .query_and_then([], |r| Ok((NoteId(r.get(0)?), r.get(1)?)))?\n            .collect()\n    }\n\n    pub(crate) fn card_ids_of_notes(&self, nids: &[NoteId]) -> Result<Vec<CardId>> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"select id from cards where nid = ?\")?;\n        let mut cids = vec![];\n        for nid in nids {\n            for cid in stmt.query_map([nid], |row| row.get(0))? {\n                cids.push(cid?);\n            }\n        }\n        Ok(cids)\n    }\n\n    pub(crate) fn all_siblings_for_bury(\n        &self,\n        cid: CardId,\n        nid: NoteId,\n        bury_mode: BuryMode,\n    ) -> Result<Vec<Card>> {\n        let params = named_params! {\n            \":card_id\": cid,\n            \":note_id\": nid,\n            \":include_new\": bury_mode.bury_new,\n            \":include_reviews\": bury_mode.bury_reviews,\n            \":include_day_learn\": bury_mode.bury_interday_learning      ,\n            \":new_queue\": CardQueue::New as i8,\n            \":review_queue\": CardQueue::Review as i8,\n            \":daylearn_queue\": CardQueue::DayLearn as i8,\n        };\n        self.with_searched_cards_table(false, || {\n            self.db\n                .prepare_cached(include_str!(\"siblings_for_bury.sql\"))?\n                .execute(params)?;\n            self.all_searched_cards()\n        })\n    }\n\n    pub(crate) fn with_searched_cards_table<T>(\n        &self,\n        preserve_order: bool,\n        func: impl FnOnce() -> Result<T>,\n    ) -> Result<T> {\n        if preserve_order {\n            self.setup_searched_cards_table_to_preserve_order()?;\n        } else {\n            self.setup_searched_cards_table()?;\n        }\n        let result = func();\n        self.clear_searched_cards_table()?;\n        result\n    }\n\n    pub(crate) fn note_ids_of_cards(&self, cids: &[CardId]) -> Result<HashSet<NoteId>> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"select nid from cards where id = ?\")?;\n        let mut nids = HashSet::new();\n        for cid in cids {\n            if let Some(nid) = stmt\n                .query_row([cid], |r| r.get::<_, NoteId>(0))\n                .optional()?\n            {\n                nids.insert(nid);\n            }\n        }\n        Ok(nids)\n    }\n\n    /// Place the ids of cards with notes in 'search_nids' into 'search_cids'.\n    /// Returns number of added cards.\n    pub(crate) fn search_cards_of_notes_into_table(&self) -> Result<usize> {\n        self.db\n            .prepare(include_str!(\"search_cards_of_notes_into_table.sql\"))?\n            .execute([])\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn all_searched_cards(&self) -> Result<Vec<Card>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_card.sql\"),\n                \" where id in (select cid from search_cids)\"\n            ))?\n            .query_and_then([], |r| row_to_card(r).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn all_searched_cards_in_search_order(&self) -> Result<Vec<Card>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_card.sql\"),\n                \", search_cids where cards.id = search_cids.cid order by search_cids.rowid\"\n            ))?\n            .query_and_then([], |r| row_to_card(r).map_err(Into::into))?\n            .collect()\n    }\n\n    /// Cards will arrive in card id order, not search order.\n    pub(crate) fn for_each_card_in_search<F>(&self, mut func: F) -> Result<()>\n    where\n        F: FnMut(Card) -> Result<()>,\n    {\n        let mut stmt = self.db.prepare_cached(concat!(\n            include_str!(\"get_card.sql\"),\n            \" where id in (select cid from search_cids)\"\n        ))?;\n        let mut rows = stmt.query([])?;\n        while let Some(row) = rows.next()? {\n            let card = row_to_card(row)?;\n            func(card)?\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn get_all_cards_due_in_range(\n        &self,\n        min_day: u32,\n        max_day: u32,\n    ) -> Result<Vec<Vec<(CardId, NoteId, DeckId)>>> {\n        Ok(self\n            .db\n            .prepare_cached(\"select id, nid, did, due from cards where due >= ?1 and due < ?2 \")?\n            .query_and_then([min_day, max_day], |row: &Row| {\n                Ok::<_, rusqlite::Error>((\n                    row.get::<_, CardId>(0)?,\n                    row.get::<_, NoteId>(1)?,\n                    row.get::<_, DeckId>(2)?,\n                    row.get::<_, i32>(3)?,\n                ))\n            })?\n            .flatten()\n            .fold(\n                vec![Vec::new(); (max_day - min_day) as usize],\n                |mut acc, (card_id, note_id, deck_id, due)| {\n                    acc[due as usize - min_day as usize].push((card_id, note_id, deck_id));\n                    acc\n                },\n            ))\n    }\n\n    pub(crate) fn get_deck_due_counts(&self) -> Result<Vec<(DeckId, i32, usize)>> {\n        self.db\n            .prepare(include_str!(\"deck_due_counts.sql\"))?\n            .query_and_then([], |row| -> Result<_> {\n                Ok((DeckId(row.get(0)?), row.get(1)?, row.get(2)?))\n            })?\n            .collect()\n    }\n\n    pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result<CongratsInfo> {\n        // NOTE: this line is obsolete in v3 as it's run on queue build, but kept to\n        // prevent errors for v1/v2 users before they upgrade\n        self.update_active_decks(current)?;\n        self.db\n            .prepare(include_str!(\"congrats.sql\"))?\n            .query_and_then(\n                named_params! {\n                    \":review_queue\": CardQueue::Review as i8,\n                    \":day_learn_queue\": CardQueue::DayLearn as i8,\n                    \":new_queue\": CardQueue::New as i8,\n                    \":user_buried_queue\": CardQueue::UserBuried as i8,\n                    \":sched_buried_queue\": CardQueue::SchedBuried as i8,\n                    \":learn_queue\": CardQueue::Learn as i8,\n                    \":today\": today,\n                },\n                |row| {\n                    Ok(CongratsInfo {\n                        review_remaining: row.get::<_, u32>(0)? > 0,\n                        new_remaining: row.get::<_, u32>(1)? > 0,\n                        have_sched_buried: row.get::<_, u32>(2)? > 0,\n                        have_user_buried: row.get::<_, u32>(3)? > 0,\n                        learn_count: row.get(4)?,\n                        next_learn_due: row.get(5)?,\n                    })\n                },\n            )?\n            .next()\n            .unwrap()\n    }\n\n    pub(crate) fn all_cards_at_or_above_position(&self, start: u32) -> Result<Vec<Card>> {\n        self.with_searched_cards_table(false, || {\n            self.db\n                .prepare(include_str!(\"at_or_above_position.sql\"))?\n                .execute([start, CardType::New as u32])?;\n            self.all_searched_cards()\n        })\n    }\n\n    pub(crate) fn setup_searched_cards_table(&self) -> Result<()> {\n        self.db\n            .execute_batch(include_str!(\"search_cids_setup.sql\"))?;\n        Ok(())\n    }\n\n    pub(crate) fn setup_searched_cards_table_to_preserve_order(&self) -> Result<()> {\n        self.db\n            .execute_batch(include_str!(\"search_cids_setup_ordered.sql\"))?;\n        Ok(())\n    }\n\n    pub(crate) fn clear_searched_cards_table(&self) -> Result<()> {\n        self.db.execute(\"drop table if exists search_cids\", [])?;\n        Ok(())\n    }\n\n    /// Injects the provided card IDs into the search_cids table, for\n    /// when ids have arrived outside of a search.\n    pub(crate) fn set_search_table_to_card_ids(&self, cards: &[CardId]) -> Result<()> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"insert into search_cids values (?)\")?;\n        for cid in cards {\n            stmt.execute([cid])?;\n        }\n        Ok(())\n    }\n\n    /// Fix cards with low eases due to schema 15 bug.\n    /// Deck configs were defaulting to 2.5% ease, which was capped to\n    /// 130% when the deck options were edited for the first time.\n    pub(crate) fn fix_low_card_eases_for_configs(\n        &self,\n        configs: &[DeckConfigId],\n        server: bool,\n    ) -> Result<()> {\n        let mut affected_decks = vec![];\n        for conf in configs {\n            for (deck_id, _name) in self.get_all_deck_names()? {\n                if let Some(deck) = self.get_deck(deck_id)? {\n                    if let DeckKind::Normal(normal) = &deck.kind {\n                        if normal.config_id == conf.0 {\n                            affected_decks.push(deck.id);\n                        }\n                    }\n                }\n            }\n        }\n\n        let mut ids = String::new();\n        ids_to_string(&mut ids, &affected_decks);\n        let sql = include_str!(\"fix_low_ease.sql\").replace(\"DECK_IDS\", &ids);\n\n        self.db.prepare(&sql)?.execute(params![self.usn(server)?])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn get_card_count_with_ignore_before(\n        &self,\n        ignore_before: TimestampMillis,\n    ) -> Result<u64> {\n        Ok(self\n            .db\n            .prepare(include_str!(\"get_ignored_before_count.sql\"))?\n            .query(params![ignore_before.0])?\n            .next()\n            .unwrap()\n            .unwrap()\n            .get(0)?)\n    }\n\n    #[cfg(test)]\n    pub(crate) fn get_all_cards(&self) -> Vec<Card> {\n        self.db\n            .prepare(\"SELECT * FROM cards\")\n            .unwrap()\n            .query_and_then([], row_to_card)\n            .unwrap()\n            .collect::<rusqlite::Result<_>>()\n            .unwrap()\n    }\n}\n\n#[derive(Clone, Copy)]\npub(crate) enum ReviewOrderSubclause {\n    Day,\n    Deck,\n    Random,\n    IntervalsAscending,\n    IntervalsDescending,\n    EaseAscending,\n    EaseDescending,\n    /// FSRS\n    DifficultyAscending,\n    /// FSRS\n    DifficultyDescending,\n    RetrievabilityFsrs {\n        timing: SchedTimingToday,\n        order: SqlSortOrder,\n    },\n    RelativeOverdueness {\n        fsrs: bool,\n        timing: SchedTimingToday,\n    },\n    Added,\n    ReverseAdded,\n}\n\nimpl fmt::Display for ReviewOrderSubclause {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        let temp_string;\n        let clause = match self {\n            ReviewOrderSubclause::Day => \"due\",\n            ReviewOrderSubclause::Deck => \"(select rowid from active_decks ad where ad.id = did)\",\n            ReviewOrderSubclause::Random => \"fnvhash(id, mod)\",\n            ReviewOrderSubclause::IntervalsAscending => \"ivl asc\",\n            ReviewOrderSubclause::IntervalsDescending => \"ivl desc\",\n            ReviewOrderSubclause::EaseAscending => \"factor asc\",\n            ReviewOrderSubclause::EaseDescending => \"factor desc\",\n            ReviewOrderSubclause::DifficultyAscending => \"extract_fsrs_variable(data, 'd') asc\",\n            ReviewOrderSubclause::DifficultyDescending => \"extract_fsrs_variable(data, 'd') desc\",\n            ReviewOrderSubclause::RetrievabilityFsrs { timing, order } => {\n                let today = timing.days_elapsed;\n                let next_day_at = timing.next_day_at.0;\n                let now = timing.now.0;\n                temp_string =\n                    format!(\"extract_fsrs_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) {order}\");\n                &temp_string\n            }\n            ReviewOrderSubclause::RelativeOverdueness { fsrs, timing } => {\n                let today = timing.days_elapsed;\n                let next_day_at = timing.next_day_at.0;\n                let now = timing.now.0;\n                temp_string = if *fsrs {\n                    format!(\"extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, ivl, {today}, {next_day_at}, {now}) asc\")\n                } else {\n                    format!(\n                        // - (elapsed days+0.001)/(scheduled interval)\n                        \"-(1 + cast({today}-due+0.001 as real)/ivl) asc\"\n                    )\n                };\n                &temp_string\n            }\n            ReviewOrderSubclause::Added => \"nid asc, ord asc\",\n            ReviewOrderSubclause::ReverseAdded => \"nid desc, ord asc\",\n        };\n        write!(f, \"{clause}\")\n    }\n}\n\nfn review_order_sql(order: ReviewCardOrder, timing: SchedTimingToday, fsrs: bool) -> String {\n    let mut subclauses = match order {\n        ReviewCardOrder::Day => vec![ReviewOrderSubclause::Day],\n        ReviewCardOrder::DayThenDeck => vec![ReviewOrderSubclause::Day, ReviewOrderSubclause::Deck],\n        ReviewCardOrder::DeckThenDay => vec![ReviewOrderSubclause::Deck, ReviewOrderSubclause::Day],\n        ReviewCardOrder::IntervalsAscending => vec![ReviewOrderSubclause::IntervalsAscending],\n        ReviewCardOrder::IntervalsDescending => vec![ReviewOrderSubclause::IntervalsDescending],\n        ReviewCardOrder::EaseAscending => {\n            vec![if fsrs {\n                ReviewOrderSubclause::DifficultyDescending\n            } else {\n                ReviewOrderSubclause::EaseAscending\n            }]\n        }\n        ReviewCardOrder::EaseDescending => vec![if fsrs {\n            ReviewOrderSubclause::DifficultyAscending\n        } else {\n            ReviewOrderSubclause::EaseDescending\n        }],\n        ReviewCardOrder::RetrievabilityAscending => {\n            vec![ReviewOrderSubclause::RetrievabilityFsrs {\n                timing,\n                order: SqlSortOrder::Ascending,\n            }]\n        }\n        ReviewCardOrder::RetrievabilityDescending => {\n            vec![ReviewOrderSubclause::RetrievabilityFsrs {\n                timing,\n                order: SqlSortOrder::Descending,\n            }]\n        }\n        ReviewCardOrder::RelativeOverdueness => {\n            vec![ReviewOrderSubclause::RelativeOverdueness { fsrs, timing }]\n        }\n        ReviewCardOrder::Random => vec![],\n        ReviewCardOrder::Added => vec![ReviewOrderSubclause::Added],\n        ReviewCardOrder::ReverseAdded => vec![ReviewOrderSubclause::ReverseAdded],\n    };\n    subclauses.push(ReviewOrderSubclause::Random);\n\n    let v: Vec<_> = subclauses\n        .iter()\n        .map(ReviewOrderSubclause::to_string)\n        .collect();\n    v.join(\", \")\n}\n\n#[derive(Debug, Clone, Copy)]\npub(crate) enum NewCardSorting {\n    /// Ascending position, consecutive siblings,\n    /// provided they have the same position.\n    LowestPosition,\n    /// Descending position, consecutive siblings,\n    /// provided they have the same position.\n    HighestPosition,\n    /// Random, but with consecutive siblings.\n    /// For some given salt the order is stable.\n    RandomNotes(u32),\n    /// Fully random.\n    /// For some given salt the order is stable.\n    RandomCards(u32),\n}\n\nimpl NewCardSorting {\n    fn write(self) -> String {\n        match self {\n            NewCardSorting::LowestPosition => \"due ASC, ord ASC\".to_string(),\n            NewCardSorting::HighestPosition => \"due DESC, ord ASC\".to_string(),\n            NewCardSorting::RandomNotes(salt) => format!(\"fnvhash(nid, {salt}), ord ASC\"),\n            NewCardSorting::RandomCards(salt) => format!(\"fnvhash(id, {salt})\"),\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use std::path::Path;\n\n    use anki_i18n::I18n;\n\n    use crate::card::Card;\n    use crate::storage::SqliteStorage;\n\n    #[test]\n    fn add_card() {\n        let tr = I18n::template_only();\n        let storage =\n            SqliteStorage::open_or_create(Path::new(\":memory:\"), &tr, false, false).unwrap();\n        let mut card = Card::default();\n        storage.add_card(&mut card).unwrap();\n        let id1 = card.id;\n        storage.add_card(&mut card).unwrap();\n        assert_ne!(id1, card.id);\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/card/new_cards.sql",
    "content": "SELECT id,\n  nid,\n  ord,\n  cast(mod AS integer),\n  did,\n  odid\nFROM cards\nWHERE did = ?\n  AND queue = 0"
  },
  {
    "path": "rslib/src/storage/card/search_cards_of_notes_into_table.sql",
    "content": "INSERT INTO search_cids\nSELECT id\nFROM cards\nWHERE nid IN (\n    SELECT nid\n    FROM search_nids\n  )"
  },
  {
    "path": "rslib/src/storage/card/search_cids_setup.sql",
    "content": "DROP TABLE IF EXISTS search_cids;\nCREATE TEMPORARY TABLE search_cids (cid integer PRIMARY KEY NOT NULL);"
  },
  {
    "path": "rslib/src/storage/card/search_cids_setup_ordered.sql",
    "content": "DROP TABLE IF EXISTS search_cids;\nCREATE TEMPORARY TABLE search_cids (cid integer NOT NULL);"
  },
  {
    "path": "rslib/src/storage/card/siblings_for_bury.sql",
    "content": "INSERT INTO search_cids\nSELECT id\nFROM cards\nWHERE id != :card_id\n  AND nid = :note_id\n  AND (\n    (\n      :include_new\n      AND queue = :new_queue\n    )\n    OR (\n      :include_reviews\n      AND queue = :review_queue\n    )\n    OR (\n      :include_day_learn\n      AND queue = :daylearn_queue\n    )\n  );"
  },
  {
    "path": "rslib/src/storage/card/update_card.sql",
    "content": "UPDATE cards\nSET nid = ?,\n  did = ?,\n  ord = ?,\n  mod = ?,\n  usn = ?,\n  type = ?,\n  queue = ?,\n  due = ?,\n  ivl = ?,\n  factor = ?,\n  reps = ?,\n  lapses = ?,\n  left = ?,\n  odue = ?,\n  odid = ?,\n  flags = ?,\n  data = ?\nWHERE id = ?"
  },
  {
    "path": "rslib/src/storage/collection_timestamps.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rusqlite::params;\n\nuse super::SqliteStorage;\nuse crate::collection::timestamps::CollectionTimestamps;\nuse crate::prelude::*;\n\nimpl SqliteStorage {\n    pub(crate) fn get_collection_timestamps(&self) -> Result<CollectionTimestamps> {\n        self.db\n            .prepare_cached(\"select mod, scm, ls from col\")?\n            .query_row([], |row| {\n                Ok(CollectionTimestamps {\n                    collection_change: row.get(0)?,\n                    schema_change: row.get(1)?,\n                    last_sync: row.get(2)?,\n                })\n            })\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn set_schema_modified_time(&self, stamp: TimestampMillis) -> Result<()> {\n        self.db\n            .prepare_cached(\"update col set scm = ?\")?\n            .execute([stamp])?;\n        Ok(())\n    }\n\n    pub(crate) fn set_last_sync(&self, stamp: TimestampMillis) -> Result<()> {\n        self.db.prepare(\"update col set ls = ?\")?.execute([stamp])?;\n        Ok(())\n    }\n\n    pub(crate) fn set_modified_time(&self, stamp: TimestampMillis) -> Result<()> {\n        self.db\n            .prepare_cached(\"update col set mod=?\")?\n            .execute(params![stamp])?;\n        Ok(())\n    }\n\n    // Creation timestamp is used less frequently, and has separate accessor\n\n    pub(crate) fn creation_stamp(&self) -> Result<TimestampSecs> {\n        self.db\n            .prepare_cached(\"select crt from col\")?\n            .query_row([], |row| row.get(0))\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn set_creation_stamp(&self, stamp: TimestampSecs) -> Result<()> {\n        self.db\n            .prepare(\"update col set crt = ?\")?\n            .execute([stamp])?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/config/add.sql",
    "content": "INSERT\n  OR REPLACE INTO config (KEY, usn, mtime_secs, val)\nVALUES (?, ?, ?, ?)"
  },
  {
    "path": "rslib/src/storage/config/get.sql",
    "content": "SELECT val\nFROM config\nWHERE KEY = ?"
  },
  {
    "path": "rslib/src/storage/config/get_entry.sql",
    "content": "SELECT val,\n  usn,\n  mtime_secs\nFROM config\nWHERE KEY = ?"
  },
  {
    "path": "rslib/src/storage/config/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse rusqlite::params;\nuse serde::de::DeserializeOwned;\nuse serde_json::Value;\n\nuse super::SqliteStorage;\nuse crate::config::ConfigEntry;\nuse crate::error::Result;\nuse crate::timestamp::TimestampSecs;\nuse crate::types::Usn;\n\nimpl SqliteStorage {\n    pub(crate) fn set_config_entry(&self, entry: &ConfigEntry) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![&entry.key, entry.usn, entry.mtime, &entry.value])?;\n        Ok(())\n    }\n\n    pub(crate) fn remove_config(&self, key: &str) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from config where key=?\")?\n            .execute([key])?;\n        Ok(())\n    }\n\n    pub(crate) fn get_config_value<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {\n        self.db\n            .prepare_cached(include_str!(\"get.sql\"))?\n            .query_and_then([key], |row| {\n                let blob = row.get_ref_unwrap(0).as_blob()?;\n                serde_json::from_slice(blob).map_err(Into::into)\n            })?\n            .next()\n            .transpose()\n    }\n\n    /// Return the raw bytes and other metadata, for undoing.\n    pub(crate) fn get_config_entry(&self, key: &str) -> Result<Option<Box<ConfigEntry>>> {\n        self.db\n            .prepare_cached(include_str!(\"get_entry.sql\"))?\n            .query_and_then([key], |row| {\n                Ok(ConfigEntry::boxed(\n                    key,\n                    row.get(0)?,\n                    row.get(1)?,\n                    row.get(2)?,\n                ))\n            })?\n            .next()\n            .transpose()\n    }\n\n    /// Prefix is expected to end with '_'.\n    pub(crate) fn get_config_prefix(&self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>> {\n        let mut end = prefix.to_string();\n        assert_eq!(end.pop(), Some('_'));\n        end.push(std::char::from_u32('_' as u32 + 1).unwrap());\n        self.db\n            .prepare(\"select key, val from config where key > ? and key < ?\")?\n            .query_and_then(params![prefix, &end], |row| Ok((row.get(0)?, row.get(1)?)))?\n            .collect()\n    }\n\n    pub(crate) fn get_all_config(&self) -> Result<HashMap<String, Value>> {\n        self.db\n            .prepare(\"select key, val from config\")?\n            .query_and_then([], |row| {\n                let val: Value = serde_json::from_slice(row.get_ref_unwrap(1).as_blob()?)?;\n                Ok((row.get::<usize, String>(0)?, val))\n            })?\n            .collect()\n    }\n\n    pub(crate) fn set_all_config(\n        &self,\n        conf: HashMap<String, Value>,\n        usn: Usn,\n        mtime: TimestampSecs,\n    ) -> Result<()> {\n        self.db.execute(\"delete from config\", [])?;\n        for (key, val) in conf.iter() {\n            self.set_config_entry(&ConfigEntry::boxed(\n                key,\n                serde_json::to_vec(&val)?,\n                usn,\n                mtime,\n            ))?;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn clear_config_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update config set usn = 0 where usn != 0\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    // Upgrading/downgrading\n\n    pub(super) fn upgrade_config_to_schema14(&self) -> Result<()> {\n        let conf = self\n            .db\n            .query_row_and_then(\"select conf from col\", [], |row| {\n                let conf: Result<HashMap<String, Value>> =\n                    serde_json::from_str(row.get_ref_unwrap(0).as_str()?).map_err(Into::into);\n                conf\n            })?;\n        self.set_all_config(conf, Usn(0), TimestampSecs(0))?;\n        self.db.execute_batch(\"update col set conf=''\")?;\n\n        Ok(())\n    }\n\n    pub(super) fn downgrade_config_from_schema14(&self) -> Result<()> {\n        let allconf = self.get_all_config()?;\n        self.db\n            .execute(\"update col set conf=?\", [serde_json::to_string(&allconf)?])?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/dbcheck/invalid_ids_count.sql",
    "content": "SELECT (\n    SELECT COUNT(*)\n    FROM notes\n    WHERE id > :cutoff\n  ) + (\n    SELECT COUNT(*)\n    FROM cards\n    WHERE id > :cutoff\n  ) + (\n    SELECT COUNT(*)\n    FROM revlog\n    WHERE id > :cutoff\n  );"
  },
  {
    "path": "rslib/src/storage/dbcheck/invalid_ids_create.sql",
    "content": "DROP TABLE IF EXISTS invalid_ids;\nCREATE TEMPORARY TABLE invalid_ids AS WITH max_existing_valid_id AS (\n  SELECT coalesce(max(id), 0) AS max_id\n  FROM \"{source_table}\"\n  WHERE id <= \"{max_valid_id}\"\n),\nfirst_new_id AS (\n  SELECT CASE\n      WHEN \"{new_id}\" > (\n        SELECT max_id\n        FROM max_existing_valid_id\n      ) THEN \"{new_id}\"\n      ELSE (\n        SELECT max_id\n        FROM max_existing_valid_id\n      ) + 1\n    END AS id\n)\nSELECT id,\n  (\n    SELECT id\n    FROM first_new_id\n  ) + row_number() OVER (\n    ORDER BY id\n  ) - 1 AS new_id\nFROM \"{source_table}\"\nWHERE id > \"{max_valid_id}\";\nCREATE INDEX invalid_ids_id_idx ON invalid_ids (id);"
  },
  {
    "path": "rslib/src/storage/dbcheck/invalid_ids_drop.sql",
    "content": "DROP TABLE IF EXISTS invalid_ids;"
  },
  {
    "path": "rslib/src/storage/dbcheck/invalid_ids_update.sql",
    "content": "UPDATE \"{target_table}\"\nSET \"{id_column}\" = (\n    SELECT invalid_ids.new_id\n    FROM invalid_ids\n    WHERE invalid_ids.id = \"{target_table}\".\"{id_column}\"\n    LIMIT 1\n  )\nWHERE \"{target_table}\".\"{id_column}\" IN (\n    SELECT invalid_ids.id\n    FROM invalid_ids\n  );"
  },
  {
    "path": "rslib/src/storage/dbcheck/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\n\nimpl super::SqliteStorage {\n    /// True if any ids used as timestamps are larger than `cutoff`.\n    pub(crate) fn invalid_ids(&self, cutoff: i64) -> Result<usize> {\n        Ok(self\n            .db\n            .query_row_and_then(include_str!(\"invalid_ids_count.sql\"), [cutoff], |r| {\n                r.get(0)\n            })?)\n    }\n\n    /// Ensures all ids used as timestamps are `max_valid_id` or lower.\n    /// If not, new ids will be assigned starting at whichever is larger,\n    /// `new_id` or the next free valid id.\n    /// `new_id` must be a valid id, i.e. lower or equal to `max_valid_id`.\n    pub(crate) fn fix_invalid_ids(&self, max_valid_id: i64, new_id: i64) -> Result<()> {\n        require!(new_id <= max_valid_id, \"new_id is invalid\");\n        for (source_table, foreign_table) in [\n            (\"notes\", Some((\"cards\", \"nid\"))),\n            (\"cards\", Some((\"revlog\", \"cid\"))),\n            (\"revlog\", None),\n        ] {\n            self.setup_invalid_ids_table(source_table, max_valid_id, new_id)?;\n            self.update_invalid_ids_from_table(source_table, \"id\")?;\n            if let Some((target_table, id_column)) = foreign_table {\n                self.update_invalid_ids_from_table(target_table, id_column)?;\n            }\n        }\n        self.db.execute(include_str!(\"invalid_ids_drop.sql\"), [])?;\n        Ok(())\n    }\n\n    fn setup_invalid_ids_table(\n        &self,\n        source_table: &str,\n        max_valid_id: i64,\n        new_id: i64,\n    ) -> Result<()> {\n        self.db.execute_batch(&format!(\n            include_str!(\"invalid_ids_create.sql\"),\n            source_table = source_table,\n            max_valid_id = max_valid_id,\n            new_id = new_id,\n        ))?;\n        Ok(())\n    }\n\n    /// Fix the invalid ids in `id_column` of `target_table` using the map from\n    /// the invalid ids temporary table.\n    fn update_invalid_ids_from_table(&self, target_table: &str, id_column: &str) -> Result<()> {\n        self.db.execute_batch(&format!(\n            include_str!(\"invalid_ids_update.sql\"),\n            target_table = target_table,\n            id_column = id_column,\n        ))?;\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use crate::prelude::*;\n\n    #[test]\n    fn any_invalid_ids() {\n        let mut col = Collection::new();\n        assert_eq!(col.storage.invalid_ids(0).unwrap(), 0);\n        NoteAdder::basic(&mut col).add(&mut col);\n        // 1 card and 1 note\n        assert_eq!(col.storage.invalid_ids(0).unwrap(), 2);\n        assert_eq!(\n            col.storage.invalid_ids(TimestampMillis::now().0).unwrap(),\n            0\n        );\n    }\n\n    #[test]\n    fn fix_invalid_note_ids_only_and_update_cards() {\n        let mut col = Collection::new();\n        let valid = NoteAdder::basic(&mut col).add(&mut col);\n        NoteAdder::basic(&mut col).add(&mut col);\n        col.storage.fix_invalid_ids(valid.id.0, 42).unwrap();\n        assert_eq!(col.storage.all_cards_of_note(valid.id).unwrap().len(), 1);\n        assert_eq!(col.storage.all_cards_of_note(NoteId(42)).unwrap().len(), 1);\n    }\n\n    #[test]\n    fn fix_invalid_card_ids_only() {\n        let mut col = Collection::new();\n        let mut cards = CardAdder::new().siblings(3).add(&mut col);\n        col.storage.fix_invalid_ids(cards[0].id.0, 42).unwrap();\n        cards.sort_by(|c1, c2| c1.id.cmp(&c2.id));\n        cards[1].id.0 = 42;\n        cards[2].id.0 = 43;\n        let old_first_card = cards.remove(0);\n        cards.push(old_first_card);\n        let mut new_cards = col.storage.get_all_cards();\n        new_cards.sort_by(|c1, c2| c1.id.cmp(&c2.id));\n        assert_eq!(new_cards, cards);\n    }\n\n    #[test]\n    fn update_revlog_when_fixing_card_ids() {\n        let mut col = Collection::new();\n        CardAdder::new().due_dates([\"7\"]).add(&mut col);\n        col.storage.fix_invalid_ids(42, 42).unwrap();\n        // revlog id was also reset to 42\n        let revlog_entry = col.storage.get_revlog_entry(RevlogId(42)).unwrap().unwrap();\n        assert_eq!(revlog_entry.cid.0, 42);\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/deck/active_deck_ids_sorted.sql",
    "content": "SELECT id\nFROM decks\nWHERE id IN (\n    SELECT id\n    FROM active_decks\n  )\nORDER BY name"
  },
  {
    "path": "rslib/src/storage/deck/add_or_update_deck.sql",
    "content": "INSERT\n  OR REPLACE INTO decks (id, name, mtime_secs, usn, common, kind)\nVALUES (?, ?, ?, ?, ?, ?)"
  },
  {
    "path": "rslib/src/storage/deck/all_decks_and_original_of_search_cards.sql",
    "content": "WITH cids AS (\n  SELECT cid\n  FROM search_cids\n)\nSELECT did\nFROM cards\nWHERE id IN cids\nUNION\nSELECT odid\nFROM cards\nWHERE odid != 0\n  AND id IN cids"
  },
  {
    "path": "rslib/src/storage/deck/all_decks_of_search_notes.sql",
    "content": "SELECT nid,\n  did\nFROM cards\nWHERE nid IN (\n    SELECT nid\n    FROM search_nids\n  )\nGROUP BY nid\nHAVING ord = MIN(ord)"
  },
  {
    "path": "rslib/src/storage/deck/alloc_id.sql",
    "content": "SELECT CASE\n    WHEN ?1 IN (\n      SELECT id\n      FROM decks\n    ) THEN (\n      SELECT max(id) + 1\n      FROM decks\n    )\n    ELSE ?1\n  END;"
  },
  {
    "path": "rslib/src/storage/deck/cards_for_deck.sql",
    "content": "SELECT id\nFROM cards\nWHERE did = ?1\n  OR (\n    odid != 0\n    AND odid = ?1\n  )"
  },
  {
    "path": "rslib/src/storage/deck/due_counts.sql",
    "content": "SELECT did,\n  -- new\n  sum(queue = :new_queue),\n  -- reviews\n  sum(\n    queue = :review_queue\n    AND due <= :day_cutoff\n  ),\n  -- interday learning\n  sum(\n    queue = :daylearn_queue\n    AND due <= :day_cutoff\n  ),\n  -- intraday learning\n  sum(\n    (\n      (\n        queue = :learn_queue\n        AND due < :learn_cutoff\n      )\n      OR (\n        queue = :preview_queue\n        AND due <= :learn_cutoff\n      )\n    )\n  ),\n  -- total\n  COUNT(1)\nFROM cards"
  },
  {
    "path": "rslib/src/storage/deck/get_deck.sql",
    "content": "SELECT id,\n  name,\n  mtime_secs,\n  usn,\n  common,\n  kind\nFROM decks"
  },
  {
    "path": "rslib/src/storage/deck/missing-decks.sql",
    "content": "SELECT DISTINCT did\nFROM cards\nWHERE did NOT IN (\n    SELECT id\n    FROM decks\n  );"
  },
  {
    "path": "rslib/src/storage/deck/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::iter;\n\nuse prost::Message;\nuse rusqlite::named_params;\nuse rusqlite::params;\nuse rusqlite::Row;\nuse unicase::UniCase;\n\nuse super::SqliteStorage;\nuse crate::card::CardQueue;\nuse crate::decks::immediate_parent_name;\nuse crate::decks::DeckCommon;\nuse crate::decks::DeckKindContainer;\nuse crate::decks::DeckSchema11;\nuse crate::decks::DueCounts;\nuse crate::error::DbErrorKind;\nuse crate::prelude::*;\n\nfn row_to_deck(row: &Row) -> Result<Deck> {\n    let common = DeckCommon::decode(row.get_ref_unwrap(4).as_blob()?)?;\n    let kind = DeckKindContainer::decode(row.get_ref_unwrap(5).as_blob()?)?;\n    let id = row.get(0)?;\n    Ok(Deck {\n        id,\n        name: NativeDeckName::from_native_str(row.get_ref_unwrap(1).as_str()?),\n        mtime_secs: row.get(2)?,\n        usn: row.get(3)?,\n        common,\n        kind: kind.kind.ok_or_else(|| {\n            AnkiError::db_error(\n                format!(\"invalid deck kind: {id}\"),\n                DbErrorKind::MissingEntity,\n            )\n        })?,\n    })\n}\n\nfn row_to_due_counts(row: &Row) -> Result<(DeckId, DueCounts)> {\n    let deck_id = row.get(0)?;\n    let new = row.get(1)?;\n    let review = row.get(2)?;\n    let interday_learning: u32 = row.get(3)?;\n    let intraday_learning: u32 = row.get(4)?;\n    let total_cards: u32 = row.get(5)?;\n    // used as-is in v1/v2; recalculated in v3 after limits are applied\n    let learning = intraday_learning + interday_learning;\n    Ok((\n        deck_id,\n        DueCounts {\n            new,\n            review,\n            learning,\n            intraday_learning,\n            interday_learning,\n            total_cards,\n        },\n    ))\n}\n\nimpl SqliteStorage {\n    pub(crate) fn get_all_decks_as_schema11(&self) -> Result<HashMap<DeckId, DeckSchema11>> {\n        self.get_all_decks()\n            .map(|r| r.into_iter().map(|d| (d.id, d.into())).collect())\n    }\n\n    pub(crate) fn get_deck(&self, did: DeckId) -> Result<Option<Deck>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get_deck.sql\"), \" where id = ?\"))?\n            .query_and_then([did], row_to_deck)?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn get_deck_by_name(&self, machine_name: &str) -> Result<Option<Deck>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get_deck.sql\"), \" WHERE name = ?\"))?\n            .query_and_then([machine_name], row_to_deck)?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn get_all_decks(&self) -> Result<Vec<Deck>> {\n        self.db\n            .prepare(include_str!(\"get_deck.sql\"))?\n            .query_and_then([], row_to_deck)?\n            .collect()\n    }\n\n    pub(crate) fn get_decks_map(&self) -> Result<HashMap<DeckId, Deck>> {\n        self.db\n            .prepare(include_str!(\"get_deck.sql\"))?\n            .query_and_then([], row_to_deck)?\n            .map(|res| res.map(|d| (d.id, d)))\n            .collect()\n    }\n\n    /// Get all deck names in sorted, human-readable form (::)\n    pub(crate) fn get_all_deck_names(&self) -> Result<Vec<(DeckId, String)>> {\n        self.db\n            .prepare(\"select id, name from decks order by name\")?\n            .query_and_then([], |row| {\n                Ok((\n                    row.get(0)?,\n                    row.get_ref_unwrap(1).as_str()?.replace('\\x1f', \"::\"),\n                ))\n            })?\n            .collect()\n    }\n\n    pub(crate) fn get_deck_id(&self, machine_name: &str) -> Result<Option<DeckId>> {\n        self.db\n            .prepare(\"select id from decks where name = ?\")?\n            .query_and_then([machine_name], |row| row.get(0))?\n            .next()\n            .transpose()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_decks_for_search_cards(&self) -> Result<Vec<Deck>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_deck.sql\"),\n                \" WHERE id IN (SELECT DISTINCT did FROM cards WHERE id IN\",\n                \" (SELECT cid FROM search_cids))\",\n            ))?\n            .query_and_then([], row_to_deck)?\n            .collect()\n    }\n\n    pub(crate) fn get_decks_and_original_for_search_cards(&self) -> Result<Vec<Deck>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_deck.sql\"),\n                \" WHERE id IN (\",\n                include_str!(\"all_decks_and_original_of_search_cards.sql\"),\n                \")\",\n            ))?\n            .query_and_then([], row_to_deck)?\n            .collect()\n    }\n\n    /// Returns the deck id of the first existing card of every searched note.\n    pub(crate) fn all_decks_of_search_notes(&self) -> Result<HashMap<NoteId, DeckId>> {\n        self.db\n            .prepare_cached(include_str!(\"all_decks_of_search_notes.sql\"))?\n            .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))?\n            .collect()\n    }\n\n    // caller should ensure name unique\n    pub(crate) fn add_deck(&self, deck: &mut Deck) -> Result<()> {\n        assert_eq!(deck.id.0, 0);\n        deck.id.0 = self\n            .db\n            .prepare(include_str!(\"alloc_id.sql\"))?\n            .query_row([TimestampMillis::now()], |r| r.get(0))?;\n        self.add_or_update_deck_with_existing_id(deck)\n            .inspect_err(|_err| {\n                // restore id of 0\n                deck.id.0 = 0;\n            })\n    }\n\n    pub(crate) fn update_deck(&self, deck: &Deck) -> Result<()> {\n        require!(deck.id.0 != 0, \"deck with id 0\");\n        let mut stmt = self.db.prepare_cached(include_str!(\"update_deck.sql\"))?;\n        let mut common = vec![];\n        deck.common.encode(&mut common)?;\n        let kind_enum = DeckKindContainer {\n            kind: Some(deck.kind.clone()),\n        };\n        let mut kind = vec![];\n        kind_enum.encode(&mut kind)?;\n        let count = stmt.execute(params![\n            deck.name.as_native_str(),\n            deck.mtime_secs,\n            deck.usn,\n            common,\n            kind,\n            deck.id\n        ])?;\n\n        require!(count != 0, \"update_deck() called with non-existent deck\");\n        Ok(())\n    }\n\n    /// Used for syncing&undo; will keep existing ID. Shouldn't be used to add\n    /// new decks locally, since it does not allocate an id.\n    pub(crate) fn add_or_update_deck_with_existing_id(&self, deck: &Deck) -> Result<()> {\n        require!(deck.id.0 != 0, \"deck with id 0\");\n        let mut stmt = self\n            .db\n            .prepare_cached(include_str!(\"add_or_update_deck.sql\"))?;\n        let mut common = vec![];\n        deck.common.encode(&mut common)?;\n        let kind_enum = DeckKindContainer {\n            kind: Some(deck.kind.clone()),\n        };\n        let mut kind = vec![];\n        kind_enum.encode(&mut kind)?;\n        stmt.execute(params![\n            deck.id,\n            deck.name.as_native_str(),\n            deck.mtime_secs,\n            deck.usn,\n            common,\n            kind\n        ])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn remove_deck(&self, did: DeckId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from decks where id = ?\")?\n            .execute([did])?;\n        Ok(())\n    }\n\n    pub(crate) fn all_cards_in_single_deck(&self, did: DeckId) -> Result<Vec<CardId>> {\n        self.db\n            .prepare_cached(include_str!(\"cards_for_deck.sql\"))?\n            .query_and_then([did], |r| r.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    /// Returns the descendants of the given [Deck] in preorder.\n    pub(crate) fn child_decks(&self, parent: &Deck) -> Result<Vec<Deck>> {\n        let prefix_start = format!(\"{}\\x1f\", parent.name);\n        let prefix_end = format!(\"{}\\x20\", parent.name);\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_deck.sql\"),\n                \" where name >= ? and name < ? order by name\"\n            ))?\n            .query_and_then([prefix_start, prefix_end], row_to_deck)?\n            .collect()\n    }\n\n    pub(crate) fn deck_id_with_children(&self, parent: &Deck) -> Result<Vec<DeckId>> {\n        let prefix_start = format!(\"{}\\x1f\", parent.name);\n        let prefix_end = format!(\"{}\\x20\", parent.name);\n        self.db\n            .prepare_cached(\"select id from decks where id = ? or (name >= ? and name < ?)\")?\n            .query_and_then(params![parent.id, prefix_start, prefix_end], |row| {\n                row.get(0).map_err(Into::into)\n            })?\n            .collect()\n    }\n\n    pub(crate) fn deck_with_children(&self, deck_id: DeckId) -> Result<Vec<Deck>> {\n        let deck = self.get_deck(deck_id)?.or_not_found(deck_id)?;\n        let prefix_start = format!(\"{}\\x1f\", deck.name);\n        let prefix_end = format!(\"{}\\x20\", deck.name);\n        iter::once(Ok(deck))\n            .chain(\n                self.db\n                    .prepare_cached(concat!(\n                        include_str!(\"get_deck.sql\"),\n                        \" where name > ? and name < ?\"\n                    ))?\n                    .query_and_then([prefix_start, prefix_end], row_to_deck)?,\n            )\n            .collect()\n    }\n\n    /// Return the parents of `child`, with the most immediate parent coming\n    /// first.\n    pub(crate) fn parent_decks(&self, child: &Deck) -> Result<Vec<Deck>> {\n        let mut decks: Vec<Deck> = vec![];\n        while let Some(parent_name) = immediate_parent_name(\n            decks\n                .last()\n                .map(|d| &d.name)\n                .unwrap_or_else(|| &child.name)\n                .as_native_str(),\n        ) {\n            if let Some(parent_did) = self.get_deck_id(parent_name)? {\n                let parent = self.get_deck(parent_did)?.unwrap();\n                decks.push(parent);\n            } else {\n                // missing parent\n                break;\n            }\n        }\n\n        Ok(decks)\n    }\n\n    pub(crate) fn due_counts(\n        &self,\n        day_cutoff: u32,\n        learn_cutoff: u32,\n    ) -> Result<HashMap<DeckId, DueCounts>> {\n        let params = named_params! {\n            \":new_queue\": CardQueue::New as u8,\n            \":review_queue\": CardQueue::Review as u8,\n            \":day_cutoff\": day_cutoff,\n            \":learn_queue\": CardQueue::Learn as u8,\n            \":learn_cutoff\": learn_cutoff,\n            \":daylearn_queue\": CardQueue::DayLearn as u8,\n            \":preview_queue\": CardQueue::PreviewRepeat as u8,\n        }\n        .to_vec();\n        let sql = concat!(include_str!(\"due_counts.sql\"), \" group by did\");\n\n        self.db\n            .prepare_cached(sql)?\n            .query_and_then(&*params, row_to_due_counts)?\n            .collect()\n    }\n\n    /// Decks referenced by cards but missing.\n    pub(crate) fn missing_decks(&self) -> Result<Vec<DeckId>> {\n        self.db\n            .prepare(include_str!(\"missing-decks.sql\"))?\n            .query_and_then([], |r| r.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn deck_is_empty(&self, did: DeckId) -> Result<bool> {\n        self.db\n            .prepare_cached(\"select null from cards where did=?\")?\n            .query([did])?\n            .next()\n            .map(|o| o.is_none())\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn clear_deck_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update decks set usn = 0 where usn != 0\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    /// Write active decks into temporary active_decks table.\n    pub(crate) fn update_active_decks(&self, current: &Deck) -> Result<()> {\n        self.db.execute_batch(concat!(\n            \"drop table if exists active_decks;\",\n            \"create temporary table active_decks (id integer not null unique);\"\n        ))?;\n\n        let top = current.name.as_native_str();\n        let prefix_start = &format!(\"{top}\\x1f\");\n        let prefix_end = &format!(\"{top}\\x20\");\n\n        self.db\n            .prepare_cached(include_str!(\"update_active.sql\"))?\n            .execute([top, prefix_start, prefix_end])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn get_active_deck_ids_sorted(&self) -> Result<Vec<DeckId>> {\n        self.db\n            .prepare_cached(include_str!(\"active_deck_ids_sorted.sql\"))?\n            .query_and_then([], |row| row.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    // Upgrading/downgrading/legacy\n\n    pub(super) fn add_default_deck(&self, tr: &I18n) -> Result<()> {\n        let mut deck = Deck::new_normal();\n        deck.id.0 = 1;\n        // fixme: separate key\n        deck.name = NativeDeckName::from_native_str(tr.deck_config_default_name());\n        self.add_or_update_deck_with_existing_id(&deck)\n    }\n\n    pub(crate) fn upgrade_decks_to_schema15(&self, server: bool) -> Result<()> {\n        let usn = self.usn(server)?;\n        let decks = self\n            .get_schema11_decks()\n            .map_err(|e| AnkiError::JsonError {\n                info: format!(\"decoding decks: {e}\"),\n            })?;\n        let mut names = HashSet::new();\n        for (_id, deck) in decks {\n            let oldname = deck.name().to_string();\n            let mut deck = Deck::from(deck);\n            if deck.human_name() != oldname {\n                deck.set_modified(usn);\n            }\n            loop {\n                let name = UniCase::new(deck.name.as_native_str().to_string());\n                if !names.contains(&name) {\n                    names.insert(name);\n                    break;\n                }\n                deck.name.add_suffix(\"_\");\n                deck.set_modified(usn);\n            }\n            self.add_or_update_deck_with_existing_id(&deck)?;\n        }\n        self.db.execute(\"update col set decks = ''\", [])?;\n        Ok(())\n    }\n\n    pub(crate) fn downgrade_decks_from_schema15(&self) -> Result<()> {\n        let decks = self.get_all_decks_as_schema11()?;\n        self.set_schema11_decks(decks)\n    }\n\n    fn get_schema11_decks(&self) -> Result<HashMap<DeckId, DeckSchema11>> {\n        let mut stmt = self.db.prepare(\"select decks from col\")?;\n        let decks = stmt\n            .query_and_then([], |row| -> Result<HashMap<DeckId, DeckSchema11>> {\n                let v: HashMap<DeckId, DeckSchema11> =\n                    serde_json::from_str(row.get_ref_unwrap(0).as_str()?)?;\n                Ok(v)\n            })?\n            .next()\n            .ok_or_else(|| AnkiError::db_error(\"col table empty\", DbErrorKind::MissingEntity))??;\n        Ok(decks)\n    }\n\n    pub(crate) fn set_schema11_decks(&self, decks: HashMap<DeckId, DeckSchema11>) -> Result<()> {\n        let json = serde_json::to_string(&decks)?;\n        self.db.execute(\"update col set decks = ?\", [json])?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/deck/update_active.sql",
    "content": "INSERT INTO active_decks\nSELECT id\nFROM decks\nWHERE name = ?\n  OR (\n    name >= ?\n    AND name < ?\n  )"
  },
  {
    "path": "rslib/src/storage/deck/update_deck.sql",
    "content": "UPDATE decks\nSET name = ?,\n  mtime_secs = ?,\n  usn = ?,\n  common = ?,\n  kind = ?\nWHERE id = ?"
  },
  {
    "path": "rslib/src/storage/deckconfig/add.sql",
    "content": "INSERT INTO deck_config (id, name, mtime_secs, usn, config)\nVALUES (\n    (\n      CASE\n        WHEN ?1 IN (\n          SELECT id\n          FROM deck_config\n        ) THEN (\n          SELECT max(id) + 1\n          FROM deck_config\n        )\n        ELSE ?1\n      END\n    ),\n    ?,\n    ?,\n    ?,\n    ?\n  );"
  },
  {
    "path": "rslib/src/storage/deckconfig/add_if_unique.sql",
    "content": "INSERT\n  OR IGNORE INTO deck_config (id, name, mtime_secs, usn, config)\nVALUES (?, ?, ?, ?, ?);"
  },
  {
    "path": "rslib/src/storage/deckconfig/add_or_update.sql",
    "content": "INSERT\n  OR REPLACE INTO deck_config (id, name, mtime_secs, usn, config)\nVALUES (?, ?, ?, ?, ?);"
  },
  {
    "path": "rslib/src/storage/deckconfig/get.sql",
    "content": "SELECT id,\n  name,\n  mtime_secs,\n  usn,\n  config\nFROM deck_config"
  },
  {
    "path": "rslib/src/storage/deckconfig/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse prost::Message;\nuse rusqlite::params;\nuse rusqlite::Row;\nuse serde_json::Value;\n\nuse super::SqliteStorage;\nuse crate::deckconfig::ensure_deck_config_values_valid;\nuse crate::deckconfig::DeckConfSchema11;\nuse crate::deckconfig::DeckConfig;\nuse crate::deckconfig::DeckConfigId;\nuse crate::deckconfig::DeckConfigInner;\nuse crate::prelude::*;\n\nfn row_to_deckconf(row: &Row, fix_invalid: bool) -> Result<DeckConfig> {\n    let mut config = DeckConfigInner::decode(row.get_ref_unwrap(4).as_blob()?)?;\n    if fix_invalid {\n        ensure_deck_config_values_valid(&mut config);\n    }\n    Ok(DeckConfig {\n        id: row.get(0)?,\n        name: row.get(1)?,\n        mtime_secs: row.get(2)?,\n        usn: row.get(3)?,\n        inner: config,\n    })\n}\n\nimpl SqliteStorage {\n    pub(crate) fn all_deck_config(&self) -> Result<Vec<DeckConfig>> {\n        self.db\n            .prepare_cached(include_str!(\"get.sql\"))?\n            .query_and_then([], |row| row_to_deckconf(row, true))?\n            .collect()\n    }\n\n    /// Does not cap values to those expected by the latest schema.\n    pub(crate) fn all_deck_config_for_schema16_upgrade(&self) -> Result<Vec<DeckConfig>> {\n        self.db\n            .prepare_cached(include_str!(\"get.sql\"))?\n            .query_and_then([], |row| row_to_deckconf(row, false))?\n            .collect()\n    }\n\n    pub(crate) fn get_deck_config_map(&self) -> Result<HashMap<DeckConfigId, DeckConfig>> {\n        self.db\n            .prepare_cached(include_str!(\"get.sql\"))?\n            .query_and_then([], |row| row_to_deckconf(row, true))?\n            .map(|res| res.map(|d| (d.id, d)))\n            .collect()\n    }\n\n    pub(crate) fn get_deck_config(&self, dcid: DeckConfigId) -> Result<Option<DeckConfig>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" where id = ?\"))?\n            .query_and_then(params![dcid], |row| row_to_deckconf(row, true))?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn get_deck_config_id_by_name(&self, name: &str) -> Result<Option<DeckConfigId>> {\n        self.db\n            .prepare_cached(\"select id from deck_config WHERE name = ?\")?\n            .query_and_then([name], |row| Ok::<_, AnkiError>(DeckConfigId(row.get(0)?)))?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn add_deck_conf(&self, conf: &mut DeckConfig) -> Result<()> {\n        let mut conf_bytes = vec![];\n        conf.inner.encode(&mut conf_bytes)?;\n        self.db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![\n                conf.id,\n                conf.name,\n                conf.mtime_secs,\n                conf.usn,\n                conf_bytes,\n            ])?;\n        let id = self.db.last_insert_rowid();\n        if conf.id.0 != id {\n            conf.id.0 = id;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn add_deck_conf_if_unique(&self, conf: &DeckConfig) -> Result<bool> {\n        let mut conf_bytes = vec![];\n        conf.inner.encode(&mut conf_bytes)?;\n        self.db\n            .prepare_cached(include_str!(\"add_if_unique.sql\"))?\n            .execute(params![\n                conf.id,\n                conf.name,\n                conf.mtime_secs,\n                conf.usn,\n                conf_bytes,\n            ])\n            .map(|added| added == 1)\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn update_deck_conf(&self, conf: &DeckConfig) -> Result<()> {\n        let mut conf_bytes = vec![];\n        conf.inner.encode(&mut conf_bytes)?;\n        self.db\n            .prepare_cached(include_str!(\"update.sql\"))?\n            .execute(params![\n                conf.name,\n                conf.mtime_secs,\n                conf.usn,\n                conf_bytes,\n                conf.id,\n            ])?;\n        Ok(())\n    }\n\n    /// Used for syncing&undo; will keep provided ID. Shouldn't be used to add\n    /// new config normally, since it does not allocate an id.\n    pub(crate) fn add_or_update_deck_config_with_existing_id(\n        &self,\n        conf: &DeckConfig,\n    ) -> Result<()> {\n        require!(conf.id.0 != 0, \"deck with id 0\");\n        let mut conf_bytes = vec![];\n        conf.inner.encode(&mut conf_bytes)?;\n        self.db\n            .prepare_cached(include_str!(\"add_or_update.sql\"))?\n            .execute(params![\n                conf.id,\n                conf.name,\n                conf.mtime_secs,\n                conf.usn,\n                conf_bytes,\n            ])?;\n        Ok(())\n    }\n\n    pub(crate) fn remove_deck_conf(&self, dcid: DeckConfigId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from deck_config where id=?\")?\n            .execute(params![dcid])?;\n        Ok(())\n    }\n\n    pub(crate) fn clear_deck_conf_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update deck_config set usn = 0 where usn != 0\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    // Creating/upgrading/downgrading\n\n    pub(super) fn add_default_deck_config(&self, tr: &I18n) -> Result<()> {\n        let mut conf = DeckConfig::default();\n        conf.id.0 = 1;\n        conf.name = tr.deck_config_default_name().into();\n        self.add_deck_conf(&mut conf)\n    }\n\n    // schema 11->14\n\n    fn add_deck_conf_schema14(&self, conf: &mut DeckConfSchema11) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![\n                conf.id,\n                conf.name,\n                conf.mtime,\n                conf.usn,\n                &serde_json::to_vec(conf)?,\n            ])?;\n        let id = self.db.last_insert_rowid();\n        if conf.id.0 != id {\n            conf.id.0 = id;\n        }\n        Ok(())\n    }\n\n    pub(super) fn upgrade_deck_conf_to_schema14(&self) -> Result<()> {\n        let conf: HashMap<DeckConfigId, DeckConfSchema11> =\n            self.db\n                .query_row_and_then(\"select dconf from col\", [], |row| -> Result<_> {\n                    let text = row.get_ref_unwrap(0).as_str()?;\n                    // try direct parse\n                    serde_json::from_str(text)\n                        .or_else(|_| {\n                            // failed, and could be caused by duplicate keys. Serialize into\n                            // a value first to discard them, then try again\n                            let conf: Value = serde_json::from_str(text)?;\n                            serde_json::from_value(conf)\n                        })\n                        .map_err(|e| AnkiError::JsonError {\n                            info: format!(\"decoding deck config: {e}\"),\n                        })\n                })?;\n        for (id, mut conf) in conf.into_iter() {\n            // buggy clients may have failed to set inner id to match hash key\n            conf.id = id;\n            self.add_deck_conf_schema14(&mut conf)?;\n        }\n        self.db.execute_batch(\"update col set dconf=''\")?;\n\n        Ok(())\n    }\n\n    // schema 14->15\n\n    fn all_deck_config_schema14(&self) -> Result<Vec<DeckConfSchema11>> {\n        self.db\n            .prepare_cached(\"select config from deck_config\")?\n            .query_and_then([], |row| -> Result<_> {\n                Ok(serde_json::from_slice(row.get_ref_unwrap(0).as_blob()?)?)\n            })?\n            .collect()\n    }\n\n    pub(super) fn upgrade_deck_conf_to_schema15(&self) -> Result<()> {\n        for conf in self.all_deck_config_schema14()? {\n            let mut conf: DeckConfig = conf.into();\n            // schema 15 stored starting ease of 2.5 as 250\n            conf.inner.initial_ease *= 100.0;\n            self.update_deck_conf(&conf)?;\n        }\n\n        Ok(())\n    }\n\n    // schema 15->16\n\n    pub(super) fn upgrade_deck_conf_to_schema16(&self, server: bool) -> Result<()> {\n        let mut invalid_configs = vec![];\n        for mut conf in self.all_deck_config_for_schema16_upgrade()? {\n            // schema 16 changed starting ease of 250 to 2.5\n            conf.inner.initial_ease /= 100.0;\n            // new deck configs created with schema 15 had the wrong\n            // ease set - reset any deck configs at the minimum ease\n            // to the default 250%\n            if conf.inner.initial_ease <= 1.3 {\n                conf.inner.initial_ease = 2.5;\n                invalid_configs.push(conf.id);\n            }\n            self.update_deck_conf(&conf)?;\n        }\n\n        self.fix_low_card_eases_for_configs(&invalid_configs, server)\n    }\n\n    // schema 15->11\n\n    pub(super) fn downgrade_deck_conf_from_schema16(&self) -> Result<()> {\n        let allconf = self.all_deck_config()?;\n        let confmap: HashMap<DeckConfigId, DeckConfSchema11> = allconf\n            .into_iter()\n            .map(|c| -> DeckConfSchema11 { c.into() })\n            .map(|c| (c.id, c))\n            .collect();\n        self.db.execute(\n            \"update col set dconf=?\",\n            params![serde_json::to_string(&confmap)?],\n        )?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/deckconfig/update.sql",
    "content": "UPDATE deck_config\nSET name = ?,\n  mtime_secs = ?,\n  usn = ?,\n  config = ?\nWHERE id = ?;"
  },
  {
    "path": "rslib/src/storage/graves/add.sql",
    "content": "INSERT\n  OR IGNORE INTO graves (usn, oid, type)\nVALUES (?, ?, ?)"
  },
  {
    "path": "rslib/src/storage/graves/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::convert::TryFrom;\n\nuse num_enum::TryFromPrimitive;\nuse rusqlite::params;\n\nuse super::SqliteStorage;\nuse crate::prelude::*;\nuse crate::sync::collection::graves::Graves;\n\n#[derive(TryFromPrimitive)]\n#[repr(u8)]\nenum GraveKind {\n    Card,\n    Note,\n    Deck,\n}\n\nimpl SqliteStorage {\n    pub(crate) fn clear_all_graves(&self) -> Result<()> {\n        self.db.execute(\"delete from graves\", [])?;\n        Ok(())\n    }\n\n    pub(crate) fn add_card_grave(&self, cid: CardId, usn: Usn) -> Result<()> {\n        self.add_grave(cid.0, GraveKind::Card, usn)\n    }\n\n    pub(crate) fn add_note_grave(&self, nid: NoteId, usn: Usn) -> Result<()> {\n        self.add_grave(nid.0, GraveKind::Note, usn)\n    }\n\n    pub(crate) fn add_deck_grave(&self, did: DeckId, usn: Usn) -> Result<()> {\n        self.add_grave(did.0, GraveKind::Deck, usn)\n    }\n\n    pub(crate) fn remove_card_grave(&self, cid: CardId) -> Result<()> {\n        self.remove_grave(cid.0, GraveKind::Card)\n    }\n\n    pub(crate) fn remove_note_grave(&self, nid: NoteId) -> Result<()> {\n        self.remove_grave(nid.0, GraveKind::Note)\n    }\n\n    pub(crate) fn remove_deck_grave(&self, did: DeckId) -> Result<()> {\n        self.remove_grave(did.0, GraveKind::Deck)\n    }\n\n    pub(crate) fn pending_graves(&self, pending_usn: Usn) -> Result<Graves> {\n        let mut stmt = self.db.prepare(&format!(\n            \"select oid, type from graves where {}\",\n            pending_usn.pending_object_clause()\n        ))?;\n        let mut rows = stmt.query([pending_usn])?;\n        let mut graves = Graves::default();\n        while let Some(row) = rows.next()? {\n            let oid: i64 = row.get(0)?;\n            let kind =\n                GraveKind::try_from(row.get::<_, u8>(1)?).or_invalid(\"invalid grave kind\")?;\n            match kind {\n                GraveKind::Card => graves.cards.push(CardId(oid)),\n                GraveKind::Note => graves.notes.push(NoteId(oid)),\n                GraveKind::Deck => graves.decks.push(DeckId(oid)),\n            }\n        }\n        Ok(graves)\n    }\n\n    pub(crate) fn update_pending_grave_usns(&self, new_usn: Usn) -> Result<()> {\n        self.db\n            .prepare(\"update graves set usn=? where usn=-1\")?\n            .execute([new_usn])?;\n        Ok(())\n    }\n\n    fn add_grave(&self, oid: i64, kind: GraveKind, usn: Usn) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![usn, oid, kind as u8])?;\n        Ok(())\n    }\n\n    /// Only useful when undoing\n    fn remove_grave(&self, oid: i64, kind: GraveKind) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"remove.sql\"))?\n            .execute(params![oid, kind as u8])?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/graves/remove.sql",
    "content": "DELETE FROM graves\nWHERE oid = ?\n  AND type = ?"
  },
  {
    "path": "rslib/src/storage/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod card;\nmod collection_timestamps;\nmod config;\nmod dbcheck;\nmod deck;\nmod deckconfig;\nmod graves;\nmod note;\nmod notetype;\nmod revlog;\nmod sqlite;\nmod sync;\nmod sync_check;\nmod tag;\nmod upgrades;\n\nuse std::fmt::Write;\n\npub(crate) use sqlite::ProcessTextFlags;\npub(crate) use sqlite::SqliteStorage;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SchemaVersion {\n    V11,\n    V18,\n}\n\nimpl SchemaVersion {\n    pub(super) fn has_journal_mode_delete(self) -> bool {\n        self == Self::V11\n    }\n}\n\n/// Write a list of IDs as '(x,y,...)' into the provided string.\npub fn ids_to_string<D, I>(buf: &mut String, ids: I)\nwhere\n    D: std::fmt::Display,\n    I: IntoIterator<Item = D>,\n{\n    buf.push('(');\n    write_comma_separated_ids(buf, ids);\n    buf.push(')');\n}\n\n/// Write a list of Ids as 'x,y,...' into the provided string.\npub(crate) fn write_comma_separated_ids<D, I>(buf: &mut String, ids: I)\nwhere\n    D: std::fmt::Display,\n    I: IntoIterator<Item = D>,\n{\n    let mut trailing_sep = false;\n    for id in ids {\n        write!(buf, \"{id},\").unwrap();\n        trailing_sep = true;\n    }\n    if trailing_sep {\n        buf.pop();\n    }\n}\n\npub(crate) fn comma_separated_ids<T>(ids: &[T]) -> String\nwhere\n    T: std::fmt::Display,\n{\n    let mut buf = String::new();\n    write_comma_separated_ids(&mut buf, ids);\n\n    buf\n}\n\n#[cfg(test)]\nmod test {\n    use super::ids_to_string;\n\n    #[test]\n    fn ids_string() {\n        let mut s = String::new();\n        ids_to_string(&mut s, [0; 0]);\n        assert_eq!(s, \"()\");\n        s.clear();\n        ids_to_string(&mut s, [7]);\n        assert_eq!(s, \"(7)\");\n        s.clear();\n        ids_to_string(&mut s, [7, 6]);\n        assert_eq!(s, \"(7,6)\");\n        s.clear();\n        ids_to_string(&mut s, [7, 6, 5]);\n        assert_eq!(s, \"(7,6,5)\");\n        s.clear();\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/note/add.sql",
    "content": "INSERT INTO notes (\n    id,\n    guid,\n    mid,\n    mod,\n    usn,\n    tags,\n    flds,\n    sfld,\n    csum,\n    flags,\n    data\n  )\nVALUES (\n    (\n      CASE\n        WHEN ?1 IN (\n          SELECT id\n          FROM notes\n        ) THEN (\n          SELECT max(id) + 1\n          FROM notes\n        )\n        ELSE ?1\n      END\n    ),\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    0,\n    \"\"\n  )"
  },
  {
    "path": "rslib/src/storage/note/add_if_unique.sql",
    "content": "INSERT\n  OR IGNORE INTO notes (\n    id,\n    guid,\n    mid,\n    mod,\n    usn,\n    tags,\n    flds,\n    sfld,\n    csum,\n    flags,\n    data\n  )\nVALUES (\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    0,\n    \"\"\n  )"
  },
  {
    "path": "rslib/src/storage/note/add_or_update.sql",
    "content": "INSERT\n  OR REPLACE INTO notes (\n    id,\n    guid,\n    mid,\n    mod,\n    usn,\n    tags,\n    flds,\n    sfld,\n    csum,\n    flags,\n    data\n  )\nVALUES (\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    0,\n    \"\"\n  )"
  },
  {
    "path": "rslib/src/storage/note/get.sql",
    "content": "SELECT id,\n  guid,\n  mid,\n  mod,\n  usn,\n  tags,\n  flds,\n  cast(sfld AS text),\n  csum\nFROM notes"
  },
  {
    "path": "rslib/src/storage/note/get_tags.sql",
    "content": "SELECT id,\n  mod,\n  usn,\n  tags\nFROM notes"
  },
  {
    "path": "rslib/src/storage/note/get_without_fields.sql",
    "content": "SELECT id,\n  guid,\n  mid,\n  mod,\n  usn,\n  tags,\n  \"\",\n  cast(sfld AS text),\n  csum\nFROM notes"
  },
  {
    "path": "rslib/src/storage/note/is_orphaned.sql",
    "content": "SELECT COUNT(id) = 0\nFROM cards\nWHERE nid = ?;"
  },
  {
    "path": "rslib/src/storage/note/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse rusqlite::params;\nuse rusqlite::Row;\nuse unicase::UniCase;\n\nuse crate::import_export::package::NoteMeta;\nuse crate::notes::NoteTags;\nuse crate::prelude::*;\nuse crate::tags::immediate_parent_name_unicase;\nuse crate::tags::join_tags;\nuse crate::tags::split_tags;\n\npub(crate) fn split_fields(fields: &str) -> Vec<String> {\n    fields.split('\\x1f').map(Into::into).collect()\n}\n\npub(crate) fn join_fields(fields: &[String]) -> String {\n    fields.join(\"\\x1f\")\n}\n\nimpl super::SqliteStorage {\n    pub fn get_note(&self, nid: NoteId) -> Result<Option<Note>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" where id = ?\"))?\n            .query_and_then(params![nid], row_to_note)?\n            .next()\n            .transpose()\n    }\n\n    pub fn get_note_without_fields(&self, nid: NoteId) -> Result<Option<Note>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_without_fields.sql\"),\n                \" where id = ?\"\n            ))?\n            .query_and_then(params![nid], row_to_note)?\n            .next()\n            .transpose()\n    }\n\n    pub fn get_all_note_ids(&self) -> Result<HashSet<NoteId>> {\n        self.db\n            .prepare(\"SELECT id FROM notes\")?\n            .query_and_then([], |row| Ok(row.get(0)?))?\n            .collect()\n    }\n\n    /// If fields have been modified, caller must call note.prepare_for_update()\n    /// prior to calling this.\n    pub(crate) fn update_note(&self, note: &Note) -> Result<()> {\n        assert_ne!(note.id.0, 0);\n        let mut stmt = self.db.prepare_cached(include_str!(\"update.sql\"))?;\n        stmt.execute(params![\n            note.guid,\n            note.notetype_id,\n            note.mtime,\n            note.usn,\n            join_tags(&note.tags),\n            join_fields(note.fields()),\n            note.sort_field.as_ref().unwrap(),\n            note.checksum.unwrap(),\n            note.id\n        ])?;\n        Ok(())\n    }\n\n    pub(crate) fn add_note(&self, note: &mut Note) -> Result<()> {\n        assert_eq!(note.id.0, 0);\n        let mut stmt = self.db.prepare_cached(include_str!(\"add.sql\"))?;\n        stmt.execute(params![\n            TimestampMillis::now(),\n            note.guid,\n            note.notetype_id,\n            note.mtime,\n            note.usn,\n            join_tags(&note.tags),\n            join_fields(note.fields()),\n            note.sort_field.as_ref().unwrap(),\n            note.checksum.unwrap(),\n        ])?;\n        note.id.0 = self.db.last_insert_rowid();\n        Ok(())\n    }\n\n    pub(crate) fn add_note_if_unique(&self, note: &Note) -> Result<bool> {\n        self.db\n            .prepare_cached(include_str!(\"add_if_unique.sql\"))?\n            .execute(params![\n                note.id,\n                note.guid,\n                note.notetype_id,\n                note.mtime,\n                note.usn,\n                join_tags(&note.tags),\n                join_fields(note.fields()),\n                note.sort_field.as_ref().unwrap(),\n                note.checksum.unwrap(),\n            ])\n            .map(|added| added == 1)\n            .map_err(Into::into)\n    }\n\n    /// Add or update the provided note, preserving ID. Used by the syncing\n    /// code.\n    pub(crate) fn add_or_update_note(&self, note: &Note) -> Result<()> {\n        let mut stmt = self.db.prepare_cached(include_str!(\"add_or_update.sql\"))?;\n        stmt.execute(params![\n            note.id,\n            note.guid,\n            note.notetype_id,\n            note.mtime,\n            note.usn,\n            join_tags(&note.tags),\n            join_fields(note.fields()),\n            note.sort_field.as_ref().unwrap(),\n            note.checksum.unwrap(),\n        ])?;\n        Ok(())\n    }\n\n    pub(crate) fn remove_note(&self, nid: NoteId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from notes where id = ?\")?\n            .execute([nid])?;\n        Ok(())\n    }\n\n    pub(crate) fn note_is_orphaned(&self, nid: NoteId) -> Result<bool> {\n        self.db\n            .prepare_cached(include_str!(\"is_orphaned.sql\"))?\n            .query_row([nid], |r| r.get(0))\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn clear_pending_note_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update notes set usn = 0 where usn = -1\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn fix_invalid_utf8_in_note(&self, nid: NoteId) -> Result<()> {\n        self.db\n            .query_row(\n                \"select cast(flds as blob), cast(tags as blob) from notes where id=?\",\n                [nid],\n                |row| {\n                    let fixed_flds: Vec<u8> = row.get(0)?;\n                    let fixed_str = String::from_utf8_lossy(&fixed_flds);\n                    let fixed_tags: Vec<u8> = row.get(1)?;\n                    let fixed_tags = String::from_utf8_lossy(&fixed_tags);\n                    self.db.execute(\n                        \"update notes set flds = ?, sfld = '', tags = ? where id = ?\",\n                        params![fixed_str, fixed_tags, nid],\n                    )\n                },\n            )\n            .map_err(Into::into)\n            .map(|_| ())\n    }\n\n    /// Returns [(nid, field 0)] of notes with the same checksum.\n    /// The caller should strip the fields and compare to see if they actually\n    /// match.\n    pub(crate) fn note_fields_by_checksum(\n        &self,\n        ntid: NotetypeId,\n        csum: u32,\n    ) -> Result<Vec<(NoteId, String)>> {\n        self.db\n            .prepare(\"select id, field_at_index(flds, 0) from notes where csum=? and mid=?\")?\n            .query_and_then(params![csum, ntid], |r| Ok((r.get(0)?, r.get(1)?)))?\n            .collect()\n    }\n\n    /// Returns [(nid, field 0)] of notes with the same checksum.\n    /// The caller should strip the fields and compare to see if they actually\n    /// match.\n    pub(crate) fn all_notes_by_type_and_checksum(\n        &self,\n    ) -> Result<HashMap<(NotetypeId, u32), Vec<NoteId>>> {\n        let mut map = HashMap::new();\n        let mut stmt = self.db.prepare(\"SELECT mid, csum, id FROM notes\")?;\n        let mut rows = stmt.query([])?;\n        while let Some(row) = rows.next()? {\n            map.entry((row.get(0)?, row.get(1)?))\n                .or_insert_with(Vec::new)\n                .push(row.get(2)?);\n        }\n        Ok(map)\n    }\n\n    pub(crate) fn all_notes_by_type_checksum_and_deck(\n        &self,\n    ) -> Result<HashMap<(NotetypeId, u32, DeckId), Vec<NoteId>>> {\n        let mut map = HashMap::new();\n        let mut stmt = self\n            .db\n            .prepare(include_str!(\"notes_types_checksums_decks.sql\"))?;\n        let mut rows = stmt.query([])?;\n        while let Some(row) = rows.next()? {\n            map.entry((row.get(1)?, row.get(2)?, row.get(3)?))\n                .or_insert_with(Vec::new)\n                .push(row.get(0)?);\n        }\n        Ok(map)\n    }\n\n    /// Return total number of notes. Slow.\n    pub(crate) fn total_notes(&self) -> Result<u32> {\n        self.db\n            .prepare(\"select count() from notes\")?\n            .query_row([], |r| r.get(0))\n            .map_err(Into::into)\n    }\n\n    /// All tags referenced by notes, and any parent tags as well.\n    pub(crate) fn all_tags_in_notes(&self) -> Result<HashSet<UniCase<String>>> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"select tags from notes where tags != ''\")?;\n        let mut query = stmt.query([])?;\n        let mut seen: HashSet<UniCase<String>> = HashSet::new();\n        while let Some(rows) = query.next()? {\n            for tag in split_tags(rows.get_ref_unwrap(0).as_str()?) {\n                seen.insert(UniCase::new(tag.to_string()));\n                let mut tag_unicase = UniCase::new(tag);\n                while let Some(parent_name) = immediate_parent_name_unicase(tag_unicase) {\n                    seen.insert(UniCase::new(parent_name.to_string()));\n                    tag_unicase = UniCase::new(&parent_name);\n                }\n            }\n        }\n        Ok(seen)\n    }\n\n    pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteId) -> Result<Option<NoteTags>> {\n        self.db\n            .prepare_cached(&format!(\"{} where id = ?\", include_str!(\"get_tags.sql\")))?\n            .query_and_then([note_id], row_to_note_tags)?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn get_note_tags_by_id_list(&self, note_ids: &[NoteId]) -> Result<Vec<NoteTags>> {\n        self.with_ids_in_searched_notes_table(note_ids, || {\n            self.db\n                .prepare_cached(&format!(\n                    \"{} where id in (select nid from search_nids)\",\n                    include_str!(\"get_tags.sql\")\n                ))?\n                .query_and_then([], row_to_note_tags)?\n                .collect()\n        })\n    }\n\n    pub(crate) fn for_each_note_tag_in_searched_notes<F>(&self, mut func: F) -> Result<()>\n    where\n        F: FnMut(&str),\n    {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"select tags from notes where id in (select nid from search_nids)\")?;\n        let mut rows = stmt.query(params![])?;\n        while let Some(row) = rows.next()? {\n            func(row.get_ref(0)?.as_str()?);\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn all_searched_notes(&self) -> Result<Vec<Note>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get.sql\"),\n                \" WHERE id IN (SELECT nid FROM search_nids)\"\n            ))?\n            .query_and_then([], row_to_note)?\n            .collect()\n    }\n\n    pub(crate) fn get_note_tags_by_predicate<F>(&mut self, want: F) -> Result<Vec<NoteTags>>\n    where\n        F: Fn(&str) -> bool,\n    {\n        let mut query_stmt = self.db.prepare_cached(include_str!(\"get_tags.sql\"))?;\n        let mut rows = query_stmt.query([])?;\n        let mut output = vec![];\n        while let Some(row) = rows.next()? {\n            let tags = row.get_ref_unwrap(3).as_str()?;\n            if want(tags) {\n                output.push(row_to_note_tags(row)?)\n            }\n        }\n        Ok(output)\n    }\n\n    pub(crate) fn update_note_tags(&mut self, note: &NoteTags) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"update_tags.sql\"))?\n            .execute(params![note.mtime, note.usn, note.tags, note.id])?;\n        Ok(())\n    }\n\n    pub(crate) fn setup_searched_notes_table(&self) -> Result<()> {\n        self.db\n            .execute_batch(include_str!(\"search_nids_setup.sql\"))?;\n        Ok(())\n    }\n\n    pub(crate) fn clear_searched_notes_table(&self) -> Result<()> {\n        self.db.execute(\"drop table if exists search_nids\", [])?;\n        Ok(())\n    }\n\n    /// Executes the closure with the note ids placed in the search_nids table.\n    /// WARNING: the column name is nid, not id.\n    pub(crate) fn with_ids_in_searched_notes_table<T>(\n        &self,\n        note_ids: &[NoteId],\n        func: impl FnOnce() -> Result<T>,\n    ) -> Result<T> {\n        self.setup_searched_notes_table()?;\n        let mut stmt = self\n            .db\n            .prepare_cached(\"insert into search_nids values (?)\")?;\n        for nid in note_ids {\n            stmt.execute([nid])?;\n        }\n        let result = func();\n        self.clear_searched_notes_table()?;\n        result\n    }\n\n    /// Cards will arrive in card id order, not search order.\n    pub(crate) fn for_each_note_in_search(\n        &self,\n        mut func: impl FnMut(Note) -> Result<()>,\n    ) -> Result<()> {\n        let mut stmt = self.db.prepare_cached(concat!(\n            include_str!(\"get.sql\"),\n            \" WHERE id IN (SELECT nid FROM search_nids)\"\n        ))?;\n        let mut rows = stmt.query([])?;\n        while let Some(row) = rows.next()? {\n            let note = row_to_note(row)?;\n            func(note)?\n        }\n\n        Ok(())\n    }\n\n    pub(crate) fn note_guid_map(&mut self) -> Result<HashMap<String, NoteMeta>> {\n        self.db\n            .prepare(\"SELECT guid, id, mod, mid FROM notes\")?\n            .query_and_then([], row_to_note_meta)?\n            .collect()\n    }\n\n    pub(crate) fn all_notes_by_guid(&mut self) -> Result<HashMap<String, NoteId>> {\n        self.db\n            .prepare(\"SELECT guid, id FROM notes\")?\n            .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))?\n            .collect()\n    }\n\n    #[cfg(test)]\n    pub(crate) fn get_all_notes(&mut self) -> Vec<Note> {\n        self.db\n            .prepare(\"SELECT * FROM notes\")\n            .unwrap()\n            .query_and_then([], row_to_note)\n            .unwrap()\n            .collect::<Result<_>>()\n            .unwrap()\n    }\n\n    #[cfg(test)]\n    pub(crate) fn notes_table_len(&mut self) -> usize {\n        self.db_scalar(\"SELECT COUNT(*) FROM notes\").unwrap()\n    }\n}\n\nfn row_to_note(row: &Row) -> Result<Note> {\n    Ok(Note::new_from_storage(\n        row.get(0)?,\n        row.get(1)?,\n        row.get(2)?,\n        row.get(3)?,\n        row.get(4)?,\n        split_tags(row.get_ref_unwrap(5).as_str()?)\n            .map(Into::into)\n            .collect(),\n        split_fields(row.get_ref_unwrap(6).as_str()?),\n        Some(row.get(7)?),\n        Some(row.get(8).unwrap_or_default()),\n    ))\n}\n\nfn row_to_note_tags(row: &Row) -> Result<NoteTags> {\n    Ok(NoteTags {\n        id: row.get(0)?,\n        mtime: row.get(1)?,\n        usn: row.get(2)?,\n        tags: row.get(3)?,\n    })\n}\n\nfn row_to_note_meta(row: &Row) -> Result<(String, NoteMeta)> {\n    Ok((\n        row.get(0)?,\n        NoteMeta::new(row.get(1)?, row.get(2)?, row.get(3)?),\n    ))\n}\n"
  },
  {
    "path": "rslib/src/storage/note/notes_types_checksums_decks.sql",
    "content": "SELECT DISTINCT notes.id,\n  notes.mid,\n  notes.csum,\n  CASE\n    WHEN cards.odid = 0 THEN cards.did\n    ELSE cards.odid\n  END AS did\nFROM notes\n  JOIN cards ON notes.id = cards.nid"
  },
  {
    "path": "rslib/src/storage/note/search_nids_setup.sql",
    "content": "DROP TABLE IF EXISTS search_nids;\nCREATE TEMPORARY TABLE search_nids (nid integer PRIMARY KEY NOT NULL);"
  },
  {
    "path": "rslib/src/storage/note/update.sql",
    "content": "UPDATE notes\nSET guid = ?,\n  mid = ?,\n  mod = ?,\n  usn = ?,\n  tags = ?,\n  flds = ?,\n  sfld = ?,\n  csum = ?\nWHERE id = ?"
  },
  {
    "path": "rslib/src/storage/note/update_tags.sql",
    "content": "UPDATE notes\nSET mod = ?,\n  usn = ?,\n  tags = ?\nWHERE id = ?"
  },
  {
    "path": "rslib/src/storage/notetype/add_notetype.sql",
    "content": "INSERT INTO notetypes (id, name, mtime_secs, usn, config)\nVALUES (\n    (\n      CASE\n        WHEN ?1 IN (\n          SELECT id\n          FROM notetypes\n        ) THEN (\n          SELECT max(id) + 1\n          FROM notetypes\n        )\n        ELSE ?1\n      END\n    ),\n    ?,\n    ?,\n    ?,\n    ?\n  );"
  },
  {
    "path": "rslib/src/storage/notetype/add_or_update.sql",
    "content": "INSERT\n  OR REPLACE INTO notetypes (id, name, mtime_secs, usn, config)\nVALUES (?, ?, ?, ?, ?);"
  },
  {
    "path": "rslib/src/storage/notetype/existing_cards.sql",
    "content": "SELECT id,\n  nid,\n  ord,\n  -- original deck\n  (\n    CASE\n      odid\n      WHEN 0 THEN did\n      ELSE odid\n    END\n  ),\n  -- new position if card is empty\n  (\n    CASE\n      type\n      WHEN 0 THEN (\n        CASE\n          odue\n          WHEN 0 THEN max(0, due)\n          ELSE max(odue, 0)\n        END\n      )\n      ELSE NULL\n    END\n  )\nFROM cards c"
  },
  {
    "path": "rslib/src/storage/notetype/field_names_for_notes.sql",
    "content": "SELECT DISTINCT name\nFROM fields\nWHERE ntid IN (\n    SELECT mid\n    FROM notes\n    WHERE id IN"
  },
  {
    "path": "rslib/src/storage/notetype/get_fields.sql",
    "content": "SELECT ord,\n  name,\n  config\nFROM fields\nWHERE ntid = ?\nORDER BY ord"
  },
  {
    "path": "rslib/src/storage/notetype/get_notetype.sql",
    "content": "SELECT id,\n  name,\n  mtime_secs,\n  usn,\n  config\nFROM notetypes"
  },
  {
    "path": "rslib/src/storage/notetype/get_notetype_names.sql",
    "content": "SELECT id,\n  name\nFROM notetypes"
  },
  {
    "path": "rslib/src/storage/notetype/get_templates.sql",
    "content": "SELECT ord,\n  name,\n  mtime_secs,\n  usn,\n  config\nFROM templates\nWHERE ntid = ?\nORDER BY ord"
  },
  {
    "path": "rslib/src/storage/notetype/get_use_counts.sql",
    "content": "SELECT nt.id,\n  nt.name,\n  (\n    SELECT COUNT(*)\n    FROM notes n\n    WHERE nt.id = n.mid\n  )\nFROM notetypes nt\nORDER BY nt.name"
  },
  {
    "path": "rslib/src/storage/notetype/highest_card_ord.sql",
    "content": "SELECT coalesce(max(ord), 0)\nFROM cards\nWHERE nid IN (\n    SELECT id\n    FROM notes\n    WHERE mid = ?\n  )"
  },
  {
    "path": "rslib/src/storage/notetype/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::collections::HashSet;\n\nuse prost::Message;\nuse rusqlite::params;\nuse rusqlite::OptionalExtension;\nuse rusqlite::Row;\nuse unicase::UniCase;\n\nuse super::ids_to_string;\nuse super::SqliteStorage;\nuse crate::error::DbErrorKind;\nuse crate::notetype::AlreadyGeneratedCardInfo;\nuse crate::notetype::CardTemplate;\nuse crate::notetype::CardTemplateConfig;\nuse crate::notetype::NoteField;\nuse crate::notetype::NoteFieldConfig;\nuse crate::notetype::NotetypeConfig;\nuse crate::notetype::NotetypeSchema11;\nuse crate::prelude::*;\n\nfn row_to_notetype_core(row: &Row) -> Result<Notetype> {\n    let config = NotetypeConfig::decode(row.get_ref_unwrap(4).as_blob()?)?;\n    Ok(Notetype {\n        id: row.get(0)?,\n        name: row.get(1)?,\n        mtime_secs: row.get(2)?,\n        usn: row.get(3)?,\n        config,\n        fields: vec![],\n        templates: vec![],\n    })\n}\n\nfn row_to_existing_card(row: &Row) -> Result<AlreadyGeneratedCardInfo> {\n    Ok(AlreadyGeneratedCardInfo {\n        id: row.get(0)?,\n        nid: row.get(1)?,\n        ord: row.get(2)?,\n        original_deck_id: row.get(3)?,\n        position_if_new: row.get(4).ok().unwrap_or_default(),\n    })\n}\n\nimpl SqliteStorage {\n    pub(crate) fn get_notetype(&self, ntid: NotetypeId) -> Result<Option<Notetype>> {\n        match self.get_notetype_core(ntid)? {\n            Some(mut nt) => {\n                nt.fields = self.get_notetype_fields(ntid)?;\n                nt.templates = self.get_notetype_templates(ntid)?;\n                Ok(Some(nt))\n            }\n            None => Ok(None),\n        }\n    }\n\n    fn get_notetype_core(&self, ntid: NotetypeId) -> Result<Option<Notetype>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get_notetype.sql\"), \" where id = ?\"))?\n            .query_and_then([ntid], row_to_notetype_core)?\n            .next()\n            .transpose()\n    }\n\n    fn get_notetype_fields(&self, ntid: NotetypeId) -> Result<Vec<NoteField>> {\n        self.db\n            .prepare_cached(include_str!(\"get_fields.sql\"))?\n            .query_and_then([ntid], |row| {\n                let config = NoteFieldConfig::decode(row.get_ref_unwrap(2).as_blob()?)?;\n                Ok(NoteField {\n                    ord: Some(row.get(0)?),\n                    name: row.get(1)?,\n                    config,\n                })\n            })?\n            .collect()\n    }\n\n    fn get_notetype_templates(&self, ntid: NotetypeId) -> Result<Vec<CardTemplate>> {\n        self.db\n            .prepare_cached(include_str!(\"get_templates.sql\"))?\n            .query_and_then([ntid], |row| {\n                let config = CardTemplateConfig::decode(row.get_ref_unwrap(4).as_blob()?)?;\n                Ok(CardTemplate {\n                    ord: row.get(0)?,\n                    name: row.get(1)?,\n                    mtime_secs: row.get(2)?,\n                    usn: row.get(3)?,\n                    config,\n                })\n            })?\n            .collect()\n    }\n\n    pub(crate) fn get_notetype_id(&self, name: &str) -> Result<Option<NotetypeId>> {\n        self.db\n            .prepare_cached(\"select id from notetypes where name = ?\")?\n            .query_row(params![name], |row| row.get(0))\n            .optional()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_notetypes_for_search_notes(&self) -> Result<Vec<Notetype>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get_notetype.sql\"),\n                \" WHERE id IN (SELECT DISTINCT mid FROM notes WHERE id IN\",\n                \" (SELECT nid FROM search_nids))\",\n            ))?\n            .query_and_then([], |r| {\n                row_to_notetype_core(r).and_then(|mut nt| {\n                    nt.fields = self.get_notetype_fields(nt.id)?;\n                    nt.templates = self.get_notetype_templates(nt.id)?;\n                    Ok(nt)\n                })\n            })?\n            .collect()\n    }\n\n    pub(crate) fn all_notetypes_of_search_notes(&self) -> Result<Vec<NotetypeId>> {\n        self.db\n            .prepare_cached(\n                \"SELECT DISTINCT mid FROM notes WHERE id IN (SELECT nid FROM search_nids)\",\n            )?\n            .query_and_then([], |r| Ok(r.get(0)?))?\n            .collect()\n    }\n\n    pub(crate) fn used_notetypes(&self) -> Result<HashSet<NotetypeId>> {\n        self.db\n            .prepare_cached(\"SELECT DISTINCT mid FROM notes\")?\n            .query_and_then([], |r| Ok(r.get(0)?))?\n            .collect()\n    }\n\n    pub fn get_all_notetype_names(&self) -> Result<Vec<(NotetypeId, String)>> {\n        self.db\n            .prepare_cached(include_str!(\"get_notetype_names.sql\"))?\n            .query_and_then([], |row| Ok((row.get(0)?, row.get(1)?)))?\n            .collect()\n    }\n\n    pub fn get_all_notetype_ids(&self) -> Result<Vec<NotetypeId>> {\n        self.db\n            .prepare_cached(\"SELECT id FROM notetypes\")?\n            .query_and_then([], |row| row.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    /// Returns list of (id, name, use_count)\n    pub fn get_notetype_use_counts(&self) -> Result<Vec<(NotetypeId, String, u32)>> {\n        self.db\n            .prepare_cached(include_str!(\"get_use_counts.sql\"))?\n            .query_and_then([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?\n            .collect()\n    }\n\n    fn update_notetype_fields(&self, ntid: NotetypeId, fields: &[NoteField]) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from fields where ntid=?\")?\n            .execute([ntid])?;\n        let mut stmt = self.db.prepare_cached(include_str!(\"update_fields.sql\"))?;\n        for (ord, field) in fields.iter().enumerate() {\n            let mut config_bytes = vec![];\n            field.config.encode(&mut config_bytes)?;\n            stmt.execute(params![ntid, ord as u32, field.name, config_bytes,])?;\n        }\n\n        Ok(())\n    }\n\n    /// A sorted list of all field names used by provided notes, for use with\n    /// the find&replace feature.\n    pub(crate) fn field_names_for_notes(&self, nids: &[NoteId]) -> Result<Vec<String>> {\n        let mut sql = include_str!(\"field_names_for_notes.sql\").to_string();\n        sql.push(' ');\n        ids_to_string(&mut sql, nids);\n        sql += \") order by name\";\n        self.db\n            .prepare(&sql)?\n            .query_and_then([], |r| r.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn note_ids_by_notetype(\n        &self,\n        nids: &[NoteId],\n    ) -> Result<Vec<(NotetypeId, NoteId)>> {\n        let mut sql = String::from(\"select mid, id from notes where id in \");\n        ids_to_string(&mut sql, nids);\n        sql += \" order by mid, id\";\n        self.db\n            .prepare(&sql)?\n            .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))?\n            .collect()\n    }\n\n    pub(crate) fn all_note_ids_by_notetype(&self) -> Result<Vec<(NotetypeId, NoteId)>> {\n        let sql = String::from(\"select mid, id from notes order by mid, id\");\n        self.db\n            .prepare(&sql)?\n            .query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))?\n            .collect()\n    }\n\n    fn update_notetype_templates(\n        &self,\n        ntid: NotetypeId,\n        templates: &[CardTemplate],\n    ) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from templates where ntid=?\")?\n            .execute([ntid])?;\n        let mut stmt = self\n            .db\n            .prepare_cached(include_str!(\"update_templates.sql\"))?;\n        for (ord, template) in templates.iter().enumerate() {\n            let mut config_bytes = vec![];\n            template.config.encode(&mut config_bytes)?;\n            stmt.execute(params![\n                ntid,\n                ord as u32,\n                template.name,\n                template.mtime_secs,\n                template.usn,\n                config_bytes,\n            ])?;\n        }\n\n        Ok(())\n    }\n\n    /// Notetype should have an existing id, and will be added if missing.\n    fn update_notetype_core(&self, nt: &Notetype) -> Result<()> {\n        require!(nt.id.0 != 0, \"notetype with id 0 passed in as existing\");\n        let mut stmt = self.db.prepare_cached(include_str!(\"add_or_update.sql\"))?;\n        let mut config_bytes = vec![];\n        nt.config.encode(&mut config_bytes)?;\n        stmt.execute(params![nt.id, nt.name, nt.mtime_secs, nt.usn, config_bytes])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn add_notetype(&self, nt: &mut Notetype) -> Result<()> {\n        assert_eq!(nt.id.0, 0);\n\n        let mut stmt = self.db.prepare_cached(include_str!(\"add_notetype.sql\"))?;\n        let mut config_bytes = vec![];\n        nt.config.encode(&mut config_bytes)?;\n        stmt.execute(params![\n            TimestampMillis::now(),\n            nt.name,\n            nt.mtime_secs,\n            nt.usn,\n            config_bytes\n        ])?;\n        nt.id.0 = self.db.last_insert_rowid();\n\n        self.update_notetype_fields(nt.id, &nt.fields)?;\n        self.update_notetype_templates(nt.id, &nt.templates)?;\n\n        Ok(())\n    }\n\n    /// Used for both regular updates, and for syncing/import.\n    pub(crate) fn add_or_update_notetype_with_existing_id(&self, nt: &Notetype) -> Result<()> {\n        self.update_notetype_core(nt)?;\n        self.update_notetype_fields(nt.id, &nt.fields)?;\n        self.update_notetype_templates(nt.id, &nt.templates)?;\n\n        Ok(())\n    }\n\n    pub(crate) fn remove_notetype(&self, ntid: NotetypeId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from templates where ntid=?\")?\n            .execute([ntid])?;\n        self.db\n            .prepare_cached(\"delete from fields where ntid=?\")?\n            .execute([ntid])?;\n        self.db\n            .prepare_cached(\"delete from notetypes where id=?\")?\n            .execute([ntid])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn existing_cards_for_notetype(\n        &self,\n        ntid: NotetypeId,\n    ) -> Result<Vec<AlreadyGeneratedCardInfo>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"existing_cards.sql\"),\n                \" where c.nid in (select id from notes where mid=?)\"\n            ))?\n            .query_and_then([ntid], row_to_existing_card)?\n            .collect()\n    }\n\n    pub(crate) fn existing_cards_for_note(\n        &self,\n        nid: NoteId,\n    ) -> Result<Vec<AlreadyGeneratedCardInfo>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"existing_cards.sql\"),\n                \" where c.nid = ?\"\n            ))?\n            .query_and_then([nid], row_to_existing_card)?\n            .collect()\n    }\n\n    pub(crate) fn clear_notetype_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update notetypes set usn = 0 where usn != 0\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn highest_card_ordinal_for_notetype(&self, ntid: NotetypeId) -> Result<u16> {\n        self.db\n            .prepare(include_str!(\"highest_card_ord.sql\"))?\n            .query_row([ntid], |row| row.get(0))\n            .map_err(Into::into)\n    }\n\n    // Upgrading/downgrading/legacy\n\n    pub(crate) fn get_all_notetypes_as_schema11(\n        &self,\n    ) -> Result<HashMap<NotetypeId, NotetypeSchema11>> {\n        let mut nts = HashMap::new();\n        for (ntid, _name) in self.get_all_notetype_names()? {\n            let full = self.get_notetype(ntid)?.unwrap();\n            nts.insert(ntid, full.into());\n        }\n        Ok(nts)\n    }\n\n    pub(crate) fn upgrade_notetypes_to_schema15(&self) -> Result<()> {\n        let nts = self\n            .get_schema11_notetypes()\n            .map_err(|e| AnkiError::JsonError {\n                info: format!(\"decoding models: {e:?}\"),\n            })?;\n        let mut names = HashSet::new();\n        for (mut ntid, nt) in nts {\n            let mut nt = Notetype::from(nt);\n            // note types with id 0 found in the wild; assign a random ID\n            if ntid.0 == 0 {\n                ntid.0 = rand::random::<u32>().max(1) as i64;\n                nt.id = ntid;\n            }\n            nt.normalize_names();\n            nt.ensure_names_unique();\n            loop {\n                let name = UniCase::new(nt.name.clone());\n                if !names.contains(&name) {\n                    names.insert(name);\n                    break;\n                }\n                nt.name.push('_');\n            }\n            self.update_notetype_core(&nt)?;\n            self.update_notetype_fields(ntid, &nt.fields)?;\n            self.update_notetype_templates(ntid, &nt.templates)?;\n        }\n        self.db.execute(\"update col set models = ''\", [])?;\n        Ok(())\n    }\n\n    pub(crate) fn downgrade_notetypes_from_schema15(&self) -> Result<()> {\n        let nts = self.get_all_notetypes_as_schema11()?;\n        self.set_schema11_notetypes(nts)\n    }\n\n    fn get_schema11_notetypes(&self) -> Result<HashMap<NotetypeId, NotetypeSchema11>> {\n        let mut stmt = self.db.prepare(\"select models from col\")?;\n        let notetypes = stmt\n            .query_and_then([], |row| -> Result<HashMap<NotetypeId, NotetypeSchema11>> {\n                let v: HashMap<NotetypeId, NotetypeSchema11> =\n                    serde_json::from_value(serde_json::from_str(row.get_ref_unwrap(0).as_str()?)?)?;\n                Ok(v)\n            })?\n            .next()\n            .ok_or_else(|| AnkiError::db_error(\"col table empty\", DbErrorKind::MissingEntity))??;\n        Ok(notetypes)\n    }\n\n    pub(crate) fn set_schema11_notetypes(\n        &self,\n        notetypes: HashMap<NotetypeId, NotetypeSchema11>,\n    ) -> Result<()> {\n        let json = serde_json::to_string(&notetypes)?;\n        self.db.execute(\"update col set models = ?\", [json])?;\n        Ok(())\n    }\n\n    pub(crate) fn get_field_names(&self, notetype_id: NotetypeId) -> Result<Vec<String>> {\n        self.db\n            .prepare_cached(\"SELECT name FROM fields WHERE ntid = ? ORDER BY ord\")?\n            .query_and_then([notetype_id], |row| Ok(row.get(0)?))?\n            .collect()\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/notetype/update_fields.sql",
    "content": "INSERT INTO fields (ntid, ord, name, config)\nVALUES (?, ?, ?, ?);"
  },
  {
    "path": "rslib/src/storage/notetype/update_notetype_config.sql",
    "content": "INSERT\n  OR REPLACE INTO notetype_config (ntid, config)\nVALUES (?, ?)"
  },
  {
    "path": "rslib/src/storage/notetype/update_templates.sql",
    "content": "INSERT INTO templates (ntid, ord, name, mtime_secs, usn, config)\nVALUES (?, ?, ?, ?, ?, ?)"
  },
  {
    "path": "rslib/src/storage/revlog/add.sql",
    "content": "INSERT\n  OR IGNORE INTO revlog (\n    id,\n    cid,\n    usn,\n    ease,\n    ivl,\n    lastIvl,\n    factor,\n    time,\n    type\n  )\nVALUES (\n    (\n      CASE\n        WHEN ?1\n        AND ?2 IN (\n          SELECT id\n          FROM revlog\n        ) THEN (\n          SELECT max(id) + 1\n          FROM revlog\n        )\n        ELSE ?2\n      END\n    ),\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?,\n    ?\n  )"
  },
  {
    "path": "rslib/src/storage/revlog/fix_props.sql",
    "content": "UPDATE revlog\nSET ivl = min(max(round(ivl), -2147483648), 2147483647),\n  lastIvl = min(max(round(lastIvl), -2147483648), 2147483647),\n  time = min(max(round(time), 0), 2147483647),\n  type = (\n    CASE\n      WHEN type = 0\n      AND time = 0\n      AND ease = 0 THEN 5\n      ELSE type\n    END\n  )\nWHERE ivl != min(max(round(ivl), -2147483648), 2147483647)\n  OR lastIvl != min(max(round(lastIvl), -2147483648), 2147483647)\n  OR time != min(max(round(time), 0), 2147483647)\n  OR type != (\n    CASE\n      WHEN type = 0\n      AND time = 0\n      AND ease = 0 THEN 5\n      ELSE type\n    END\n  )"
  },
  {
    "path": "rslib/src/storage/revlog/get.sql",
    "content": "SELECT id,\n  cid,\n  usn,\n  ease,\n  cast(ivl AS integer),\n  cast(lastIvl AS integer),\n  factor,\n  time,\n  type\nFROM revlog"
  },
  {
    "path": "rslib/src/storage/revlog/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::convert::TryFrom;\n\nuse rusqlite::params;\nuse rusqlite::types::FromSql;\nuse rusqlite::types::FromSqlError;\nuse rusqlite::types::ValueRef;\nuse rusqlite::OptionalExtension;\nuse rusqlite::Row;\n\nuse super::SqliteStorage;\nuse crate::error::Result;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::revlog::RevlogReviewKind;\n\npub(crate) struct StudiedToday {\n    pub cards: u32,\n    pub seconds: f64,\n}\n\nimpl FromSql for RevlogReviewKind {\n    fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {\n        if let ValueRef::Integer(i) = value {\n            Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?)\n        } else {\n            Err(FromSqlError::InvalidType)\n        }\n    }\n}\n\nfn row_to_revlog_entry(row: &Row) -> Result<RevlogEntry> {\n    Ok(RevlogEntry {\n        id: row.get(0)?,\n        cid: row.get(1)?,\n        usn: row.get(2)?,\n        button_chosen: row.get(3)?,\n        interval: row.get(4)?,\n        last_interval: row.get(5)?,\n        ease_factor: row.get(6)?,\n        taken_millis: row.get(7).unwrap_or_default(),\n        review_kind: row.get(8).unwrap_or_default(),\n    })\n}\n\nimpl SqliteStorage {\n    pub(crate) fn fix_revlog_properties(&self) -> Result<usize> {\n        self.db\n            .prepare(include_str!(\"fix_props.sql\"))?\n            .execute([])\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn clear_pending_revlog_usns(&self) -> Result<()> {\n        self.db\n            .prepare(\"update revlog set usn = 0 where usn = -1\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    /// Adds the entry, if its id is unique. If it is not, and `uniquify` is\n    /// true, adds it with a new id. Returns the added id.\n    /// (I.e., the option is safe to unwrap, if `uniquify` is true.)\n    pub(crate) fn add_revlog_entry(\n        &self,\n        entry: &RevlogEntry,\n        uniquify: bool,\n    ) -> Result<Option<RevlogId>> {\n        let added = self\n            .db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![\n                uniquify,\n                entry.id,\n                entry.cid,\n                entry.usn,\n                entry.button_chosen,\n                entry.interval,\n                entry.last_interval,\n                entry.ease_factor,\n                entry.taken_millis,\n                entry.review_kind as u8\n            ])?;\n        Ok((added > 0).then(|| RevlogId(self.db.last_insert_rowid())))\n    }\n\n    pub(crate) fn get_revlog_entry(&self, id: RevlogId) -> Result<Option<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" where id=?\"))?\n            .query_and_then([id], row_to_revlog_entry)?\n            .next()\n            .transpose()\n    }\n\n    /// Determine the the last review time based on the revlog.\n    pub(crate) fn time_of_last_review(&self, card_id: CardId) -> Result<Option<TimestampSecs>> {\n        self.db\n            .prepare_cached(include_str!(\"time_of_last_review.sql\"))?\n            .query_row([card_id], |row| row.get(0))\n            .optional()\n            .map_err(Into::into)\n    }\n\n    /// Only intended to be used by the undo code, as Anki can not sync revlog\n    /// deletions.\n    pub(crate) fn remove_revlog_entry(&self, id: RevlogId) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from revlog where id = ?\")?\n            .execute([id])?;\n        Ok(())\n    }\n\n    pub(crate) fn get_revlog_entries_for_card(&self, cid: CardId) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" where cid=?\"))?\n            .query_and_then([cid], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_revlog_entries_for_searched_cards_after_stamp(\n        &self,\n        after: TimestampSecs,\n    ) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get.sql\"),\n                \" where cid in (select cid from search_cids) and id >= ?\"\n            ))?\n            .query_and_then([after.0 * 1000], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_revlog_entries_for_searched_cards(&self) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get.sql\"),\n                \" where cid in (select cid from search_cids)\"\n            ))?\n            .query_and_then([], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_revlog_entries_for_searched_cards_in_card_order(\n        &self,\n    ) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get.sql\"),\n                \" where cid in (select cid from search_cids) order by cid, id\"\n            ))?\n            .query_and_then([], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_revlog_entries_for_export_dataset(&self) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(\n                include_str!(\"get.sql\"),\n                \" where (ease between 1 and 4) or (ease = 0 and factor = 0)\",\n                \" order by cid, id\"\n            ))?\n            .query_and_then([], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_all_revlog_entries_in_card_order(&self) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" order by cid, id\"))?\n            .query_and_then([], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn get_all_revlog_entries(&self, after: TimestampSecs) -> Result<Vec<RevlogEntry>> {\n        self.db\n            .prepare_cached(concat!(include_str!(\"get.sql\"), \" where id >= ?\"))?\n            .query_and_then([after.0 * 1000], row_to_revlog_entry)?\n            .collect()\n    }\n\n    pub(crate) fn studied_today(&self, day_cutoff: TimestampSecs) -> Result<StudiedToday> {\n        let start = day_cutoff.adding_secs(-86_400).as_millis();\n        self.db\n            .prepare_cached(include_str!(\"studied_today.sql\"))?\n            .query_map(\n                [\n                    start.0,\n                    RevlogReviewKind::Manual as i64,\n                    RevlogReviewKind::Rescheduled as i64,\n                ],\n                |row| {\n                    Ok(StudiedToday {\n                        cards: row.get(0)?,\n                        seconds: row.get(1)?,\n                    })\n                },\n            )?\n            .next()\n            .unwrap()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn studied_today_by_deck(\n        &self,\n        day_cutoff: TimestampSecs,\n    ) -> Result<Vec<(DeckId, usize)>> {\n        let start = day_cutoff.adding_secs(-86_400).as_millis();\n        self.db\n            .prepare_cached(include_str!(\"studied_today_by_deck.sql\"))?\n            .query_and_then([start.0], |row| -> Result<_> {\n                Ok((DeckId(row.get(0)?), row.get(1)?))\n            })?\n            .collect()\n    }\n    pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> {\n        self.db\n            .execute_batch(include_str!(\"v2_upgrade.sql\"))\n            .map_err(Into::into)\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/revlog/studied_today.sql",
    "content": "SELECT COUNT(),\n  coalesce(sum(time) / 1000.0, 0.0)\nFROM revlog\nWHERE id > ?\n  AND type != ?\n  AND type != ?"
  },
  {
    "path": "rslib/src/storage/revlog/studied_today_by_deck.sql",
    "content": "SELECT CASE\n    WHEN c.odid == 0 THEN c.did\n    ELSE c.odid\n  END AS original_did,\n  COUNT(DISTINCT r.cid) AS cnt\nFROM revlog AS r\n  JOIN cards AS c ON r.cid = c.id\nWHERE r.id > ?\n  AND r.ease > 0\n  AND (\n    r.type < 3\n    OR r.factor != 0\n  )\nGROUP BY original_did"
  },
  {
    "path": "rslib/src/storage/revlog/time_of_last_review.sql",
    "content": "SELECT id / 1000\nFROM revlog\nWHERE cid = $1\n  AND ease BETWEEN 1 AND 4\n  AND (\n    type != 3\n    OR factor != 0\n  )\nORDER BY id DESC\nLIMIT 1"
  },
  {
    "path": "rslib/src/storage/revlog/v2_upgrade.sql",
    "content": "UPDATE revlog\nSET ease = ease + 1\nWHERE ease IN (2, 3)\n  AND type IN (0, 2);"
  },
  {
    "path": "rslib/src/storage/schema11.sql",
    "content": "CREATE TABLE col (\n  id integer PRIMARY KEY,\n  crt integer NOT NULL,\n  mod integer NOT NULL,\n  scm integer NOT NULL,\n  ver integer NOT NULL,\n  dty integer NOT NULL,\n  usn integer NOT NULL,\n  ls integer NOT NULL,\n  conf text NOT NULL,\n  models text NOT NULL,\n  decks text NOT NULL,\n  dconf text NOT NULL,\n  tags text NOT NULL\n);\nCREATE TABLE notes (\n  id integer PRIMARY KEY,\n  guid text NOT NULL,\n  mid integer NOT NULL,\n  mod integer NOT NULL,\n  usn integer NOT NULL,\n  tags text NOT NULL,\n  flds text NOT NULL,\n  -- The use of type integer for sfld is deliberate, because it means that integer values in this\n  -- field will sort numerically.\n  sfld integer NOT NULL,\n  csum integer NOT NULL,\n  flags integer NOT NULL,\n  data text NOT NULL\n);\nCREATE TABLE cards (\n  id integer PRIMARY KEY,\n  nid integer NOT NULL,\n  did integer NOT NULL,\n  ord integer NOT NULL,\n  mod integer NOT NULL,\n  usn integer NOT NULL,\n  type integer NOT NULL,\n  queue integer NOT NULL,\n  due integer NOT NULL,\n  ivl integer NOT NULL,\n  factor integer NOT NULL,\n  reps integer NOT NULL,\n  lapses integer NOT NULL,\n  left integer NOT NULL,\n  odue integer NOT NULL,\n  odid integer NOT NULL,\n  flags integer NOT NULL,\n  data text NOT NULL\n);\nCREATE TABLE revlog (\n  id integer PRIMARY KEY,\n  cid integer NOT NULL,\n  usn integer NOT NULL,\n  ease integer NOT NULL,\n  ivl integer NOT NULL,\n  lastIvl integer NOT NULL,\n  factor integer NOT NULL,\n  time integer NOT NULL,\n  type integer NOT NULL\n);\nCREATE TABLE graves (\n  usn integer NOT NULL,\n  oid integer NOT NULL,\n  type integer NOT NULL\n);\n-- syncing\nCREATE INDEX ix_notes_usn ON notes (usn);\nCREATE INDEX ix_cards_usn ON cards (usn);\nCREATE INDEX ix_revlog_usn ON revlog (usn);\n-- card spacing, etc\nCREATE INDEX ix_cards_nid ON cards (nid);\n-- scheduling and deck limiting\nCREATE INDEX ix_cards_sched ON cards (did, queue, due);\n-- revlog by card\nCREATE INDEX ix_revlog_cid ON revlog (cid);\n-- field uniqueness\nCREATE INDEX ix_notes_csum ON notes (csum);\nINSERT INTO col\nVALUES (\n    1,\n    0,\n    0,\n    0,\n    0,\n    0,\n    0,\n    0,\n    '{}',\n    '{}',\n    '{}',\n    '{}',\n    '{}'\n  );"
  },
  {
    "path": "rslib/src/storage/sqlite.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::cmp::Ordering;\nuse std::collections::HashSet;\nuse std::fmt::Display;\nuse std::hash::Hasher;\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse bitflags::bitflags;\nuse fnv::FnvHasher;\nuse fsrs::FSRS;\nuse fsrs::FSRS5_DEFAULT_DECAY;\nuse regex::Regex;\nuse rusqlite::functions::FunctionFlags;\nuse rusqlite::params;\nuse rusqlite::trace::TraceEvent;\nuse rusqlite::Connection;\nuse serde_json::Value;\nuse unicase::UniCase;\n\nuse super::upgrades::SCHEMA_MAX_VERSION;\nuse super::upgrades::SCHEMA_MIN_VERSION;\nuse super::upgrades::SCHEMA_STARTING_VERSION;\nuse super::SchemaVersion;\nuse crate::cloze::strip_clozes;\nuse crate::config::schema11::schema11_config_as_string;\nuse crate::error::DbErrorKind;\nuse crate::prelude::*;\nuse crate::scheduler::timing::local_minutes_west_for_stamp;\nuse crate::scheduler::timing::v1_creation_date;\nuse crate::storage::card::data::CardData;\nuse crate::text::without_combining;\nuse crate::text::CowMapping;\n\nfn unicase_compare(s1: &str, s2: &str) -> Ordering {\n    UniCase::new(s1).cmp(&UniCase::new(s2))\n}\n\n// fixme: rollback savepoint when tags not changed\n// fixme: need to drop out of wal prior to vacuuming to fix page size of older\n// collections\n\n// currently public for dbproxy\n#[derive(Debug)]\npub struct SqliteStorage {\n    // currently crate-visible for dbproxy\n    pub(crate) db: Connection,\n}\n\nfn open_or_create_collection_db(path: &Path) -> Result<Connection> {\n    let db = Connection::open(path)?;\n\n    if std::env::var(\"TRACESQL\").is_ok() {\n        db.trace_v2(\n            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,\n            Some(trace),\n        );\n    }\n\n    db.busy_timeout(std::time::Duration::from_secs(0))?;\n\n    db.pragma_update(None, \"locking_mode\", \"exclusive\")?;\n    db.pragma_update(None, \"page_size\", 4096)?;\n    db.pragma_update(None, \"cache_size\", -40 * 1024)?;\n    db.pragma_update(None, \"legacy_file_format\", false)?;\n    db.pragma_update(None, \"journal_mode\", \"wal\")?;\n    // Android has no /tmp folder, and fails in the default config.\n    #[cfg(target_os = \"android\")]\n    db.pragma_update(None, \"temp_store\", &\"memory\")?;\n\n    db.set_prepared_statement_cache_capacity(50);\n\n    add_field_index_function(&db)?;\n    add_regexp_function(&db)?;\n    add_regexp_fields_function(&db)?;\n    add_regexp_tags_function(&db)?;\n    add_process_text_function(&db)?;\n    add_fnvhash_function(&db)?;\n    add_extract_original_position_function(&db)?;\n    add_extract_custom_data_function(&db)?;\n    add_extract_fsrs_variable(&db)?;\n    add_extract_fsrs_retrievability(&db)?;\n    add_extract_fsrs_relative_retrievability(&db)?;\n\n    db.create_collation(\"unicase\", unicase_compare)?;\n\n    Ok(db)\n}\n\nimpl SqliteStorage {\n    /// This is provided as an escape hatch for when you need to do something\n    /// not directly supported by this library. Please exercise caution when\n    /// using it.\n    pub fn db(&self) -> &Connection {\n        &self.db\n    }\n}\n/// Adds sql function field_at_index(flds, index)\n/// to split provided fields and return field at zero-based index.\n/// If out of range, returns empty string.\nfn add_field_index_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"field_at_index\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        |ctx| {\n            let mut fields = ctx.get_raw(0).as_str()?.split('\\x1f');\n            let idx: u16 = ctx.get(1)?;\n            Ok(fields.nth(idx as usize).unwrap_or(\"\").to_string())\n        },\n    )\n}\n\nbitflags! {\n    pub(crate) struct ProcessTextFlags: u8 {\n        const NoCombining = 1;\n        const StripClozes = 1 << 1;\n    }\n}\n\nfn add_process_text_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"process_text\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        |ctx| {\n            let mut text = Cow::from(ctx.get_raw(0).as_str()?);\n            let opt = ProcessTextFlags::from_bits_truncate(ctx.get_raw(1).as_i64()? as u8);\n            if opt.contains(ProcessTextFlags::StripClozes) {\n                text = text.map_cow(strip_clozes);\n            }\n            if opt.contains(ProcessTextFlags::NoCombining) {\n                text = text.map_cow(without_combining);\n            }\n            Ok(text.get_owned())\n        },\n    )\n}\n\nfn add_fnvhash_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\"fnvhash\", -1, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| {\n        let mut hasher = FnvHasher::default();\n        for idx in 0..ctx.len() {\n            hasher.write_i64(ctx.get(idx)?);\n        }\n        Ok(hasher.finish() as i64)\n    })\n}\n\n/// Adds sql function regexp(regex, string) -> is_match\n/// Taken from the rusqlite docs\ntype BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;\nfn add_regexp_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"regexp\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 2, \"called with unexpected number of arguments\");\n\n            let re: Arc<Regex> = ctx\n                .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> {\n                    Ok(Regex::new(vr.as_str()?)?)\n                })?;\n\n            let is_match = {\n                let text = ctx\n                    .get_raw(1)\n                    .as_str()\n                    .map_err(|e| rusqlite::Error::UserFunctionError(e.into()))?;\n\n                re.is_match(text)\n            };\n\n            Ok(is_match)\n        },\n    )\n}\n\n/// Adds sql function `regexp_fields(regex, note_flds, indices...) -> is_match`.\n/// If no indices are provided, all fields are matched against.\nfn add_regexp_fields_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"regexp_fields\",\n        -1,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert!(ctx.len() > 1, \"not enough arguments\");\n\n            let re: Arc<Regex> = ctx\n                .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> {\n                    Ok(Regex::new(vr.as_str()?)?)\n                })?;\n            let fields = ctx.get_raw(1).as_str()?.split('\\x1f');\n            let indices: HashSet<usize> = (2..ctx.len())\n                .map(|i| ctx.get(i))\n                .collect::<rusqlite::Result<_>>()?;\n\n            Ok(fields.enumerate().any(|(idx, field)| {\n                (indices.is_empty() || indices.contains(&idx)) && re.is_match(field)\n            }))\n        },\n    )\n}\n\n/// Adds sql function `regexp_tags(regex, tags) -> is_match`.\nfn add_regexp_tags_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"regexp_tags\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 2, \"called with unexpected number of arguments\");\n\n            let re: Arc<Regex> = ctx\n                .get_or_create_aux(0, |vr| -> std::result::Result<_, BoxError> {\n                    Ok(Regex::new(vr.as_str()?)?)\n                })?;\n            let mut tags = ctx.get_raw(1).as_str()?.split(' ');\n\n            Ok(tags.any(|tag| re.is_match(tag)))\n        },\n    )\n}\n\n/// eg. extract_original_position(c.data) -> number | null\n/// Parse original card position from c.data (this is only populated after card\n/// has been reviewed)\nfn add_extract_original_position_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"extract_original_position\",\n        1,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 1, \"called with unexpected number of arguments\");\n\n            let Ok(card_data) = ctx.get_raw(0).as_str() else {\n                return Ok(None);\n            };\n\n            match &CardData::from_str(card_data).original_position {\n                Some(position) => Ok(Some(*position as i64)),\n                None => Ok(None),\n            }\n        },\n    )\n}\n\n/// eg. extract_custom_data(card.data, 'r') -> string | null\nfn add_extract_custom_data_function(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"extract_custom_data\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 2, \"called with unexpected number of arguments\");\n\n            let Ok(card_data) = ctx.get_raw(0).as_str() else {\n                return Ok(None);\n            };\n            if card_data.is_empty() {\n                return Ok(None);\n            }\n            let Ok(key) = ctx.get_raw(1).as_str() else {\n                return Ok(None);\n            };\n            let custom_data = &CardData::from_str(card_data).custom_data;\n            let Ok(value) = serde_json::from_str::<Value>(custom_data) else {\n                return Ok(None);\n            };\n            let v = value.get(key).map(|v| match v {\n                Value::String(s) => s.to_owned(),\n                _ => v.to_string(),\n            });\n            Ok(v)\n        },\n    )\n}\n\n/// eg. extract_fsrs_variable(card.data, 's' | 'd' | 'dr') -> float | null\nfn add_extract_fsrs_variable(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"extract_fsrs_variable\",\n        2,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 2, \"called with unexpected number of arguments\");\n\n            let Ok(card_data) = ctx.get_raw(0).as_str() else {\n                return Ok(None);\n            };\n            if card_data.is_empty() {\n                return Ok(None);\n            }\n            let Ok(key) = ctx.get_raw(1).as_str() else {\n                return Ok(None);\n            };\n            let card_data = &CardData::from_str(card_data);\n            Ok(match key {\n                \"s\" => card_data.fsrs_stability,\n                \"d\" => card_data.fsrs_difficulty,\n                \"dr\" => card_data.fsrs_desired_retention,\n                _ => panic!(\"invalid key: {key}\"),\n            })\n        },\n    )\n}\n\n/// eg. extract_fsrs_retrievability(card.data, card.due, card.ivl,\n/// timing.days_elapsed, timing.next_day_at, timing.now) -> float | null\nfn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"extract_fsrs_retrievability\",\n        6,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 6, \"called with unexpected number of arguments\");\n            let Ok(card_data) = ctx.get_raw(0).as_str() else {\n                return Ok(None);\n            };\n            if card_data.is_empty() {\n                return Ok(None);\n            }\n            let card_data = &CardData::from_str(card_data);\n            let Ok(due) = ctx.get_raw(1).as_i64() else {\n                return Ok(None);\n            };\n            let Ok(now) = ctx.get_raw(5).as_i64() else {\n                return Ok(None);\n            };\n            let seconds_elapsed = if let Some(last_review_time) = card_data.last_review_time {\n                // This and any following\n                // (x as u32).saturating_sub(y as u32)\n                // must not be changed to\n                // x.saturating_sub(y) as u32\n                // as x and y are i64's and saturating_sub will therfore allow negative numbers\n                // before converting to u32 in the latter example.\n                (now as u32).saturating_sub(last_review_time.0 as u32)\n            } else if due > 365_000 {\n                // (re)learning card in seconds\n                let Ok(ivl) = ctx.get_raw(2).as_i64() else {\n                    return Ok(None);\n                };\n                let last_review_time = (due as u32).saturating_sub(ivl as u32);\n                (now as u32).saturating_sub(last_review_time)\n            } else {\n                let Ok(ivl) = ctx.get_raw(2).as_i64() else {\n                    return Ok(None);\n                };\n                // timing.days_elapsed\n                let Ok(today) = ctx.get_raw(3).as_i64() else {\n                    return Ok(None);\n                };\n                let review_day = (due as u32).saturating_sub(ivl as u32);\n                (today as u32).saturating_sub(review_day) * 86_400\n            };\n            let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY);\n            let retrievability = card_data.memory_state().map(|state| {\n                FSRS::new(None).unwrap().current_retrievability_seconds(\n                    state.into(),\n                    seconds_elapsed,\n                    decay,\n                )\n            });\n            Ok(retrievability)\n        },\n    )\n}\n\n/// eg. extract_fsrs_relative_retrievability(card.data, card.due,\n/// card.ivl, timing.days_elapsed, timing.next_day_at, timing.now) -> float |\n/// null. The higher the number, the higher the card's retrievability relative\n/// to the configured desired retention.\nfn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result<()> {\n    db.create_scalar_function(\n        \"extract_fsrs_relative_retrievability\",\n        6,\n        FunctionFlags::SQLITE_DETERMINISTIC,\n        move |ctx| {\n            assert_eq!(ctx.len(), 6, \"called with unexpected number of arguments\");\n\n            let Ok(due) = ctx.get_raw(1).as_i64() else {\n                return Ok(None);\n            };\n            let Ok(interval) = ctx.get_raw(2).as_i64() else {\n                return Ok(None);\n            };\n            /*\n            // Unused\n            let Ok(next_day_at) = ctx.get_raw(4).as_i64() else {\n                return Ok(None);\n            };\n            */\n            let Ok(now) = ctx.get_raw(5).as_i64() else {\n                return Ok(None);\n            };\n            let secs_elapsed = if due > 365_000 {\n                // (re)learning card with due in seconds\n\n                // Don't change this to now.subtracting_sub(due) as u32\n                // for the same reasons listed in the comment\n                // in add_extract_fsrs_retrievability\n                (now as u32).saturating_sub(due as u32)\n            } else {\n                // timing.days_elapsed\n                let Ok(today) = ctx.get_raw(3).as_i64() else {\n                    return Ok(None);\n                };\n                let review_day = due.saturating_sub(interval);\n                (today as u32).saturating_sub(review_day as u32) * 86_400\n            };\n            if let Ok(card_data) = ctx.get_raw(0).as_str() {\n                if !card_data.is_empty() {\n                    let card_data = &CardData::from_str(card_data);\n                    if let (Some(state), Some(mut desired_retrievability)) =\n                        (card_data.memory_state(), card_data.fsrs_desired_retention)\n                    {\n                        // avoid div by zero\n                        desired_retrievability = desired_retrievability.max(0.0001);\n                        let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY);\n\n                        let seconds_elapsed =\n                            if let Some(last_review_time) = card_data.last_review_time {\n                                // Don't change this to now.subtracting_sub(due) as u32\n                                // for the same reasons listed in the comment\n                                // in add_extract_fsrs_retrievability\n                                (now as u32).saturating_sub(last_review_time.0 as u32)\n                            } else {\n                                secs_elapsed\n                            };\n\n                        let current_retrievability = FSRS::new(None)\n                            .unwrap()\n                            .current_retrievability_seconds(state.into(), seconds_elapsed, decay)\n                            .max(0.0001);\n\n                        return Ok(Some(\n                            -(current_retrievability.powf(-1.0 / decay) - 1.)\n                                / (desired_retrievability.powf(-1.0 / decay) - 1.),\n                        ));\n                    }\n                }\n            }\n            let days_elapsed = secs_elapsed / 86_400;\n            // FSRS data missing; fall back to SM2 ordering\n            Ok(Some(\n                -((days_elapsed as f32) + 0.001) / (interval as f32).max(1.0),\n            ))\n        },\n    )\n}\n\n/// Fetch schema version from database.\n/// Return (must_create, version)\nfn schema_version(db: &Connection) -> Result<(bool, u8)> {\n    if !db\n        .prepare(\"select null from sqlite_master where type = 'table' and name = 'col'\")?\n        .exists([])?\n    {\n        return Ok((true, SCHEMA_STARTING_VERSION));\n    }\n\n    Ok((\n        false,\n        db.query_row(\"select ver from col\", [], |r| r.get(0))?,\n    ))\n}\n\nfn trace(event: TraceEvent) {\n    if let TraceEvent::Stmt(_, sql) = event {\n        println!(\"sql: {}\", sql.trim().replace('\\n', \" \"));\n    }\n}\n\nimpl SqliteStorage {\n    pub(crate) fn open_or_create(\n        path: &Path,\n        tr: &I18n,\n        server: bool,\n        check_integrity: bool,\n    ) -> Result<Self> {\n        let db = open_or_create_collection_db(path)?;\n        let (create, ver) = schema_version(&db)?;\n\n        let err = match ver {\n            v if v < SCHEMA_MIN_VERSION => Some(DbErrorKind::FileTooOld),\n            v if v > SCHEMA_MAX_VERSION => Some(DbErrorKind::FileTooNew),\n            12 | 13 => {\n                // as schema definition changed, user must perform clean\n                // shutdown to return to schema 11 prior to running this version\n                Some(DbErrorKind::FileTooNew)\n            }\n            _ => None,\n        };\n        if let Some(kind) = err {\n            return Err(AnkiError::db_error(\"\", kind));\n        }\n\n        if check_integrity {\n            match db.pragma_query_value(None, \"integrity_check\", |row| row.get::<_, String>(0)) {\n                Ok(s) => require!(s == \"ok\", \"corrupt: {s}\"),\n                Err(e) => return Err(e.into()),\n            };\n        }\n\n        let upgrade = ver != SCHEMA_MAX_VERSION;\n        if create || upgrade {\n            db.execute(\"begin exclusive\", [])?;\n        }\n\n        if create {\n            db.execute_batch(include_str!(\"schema11.sql\"))?;\n            // start at schema 11, then upgrade below\n            let crt = TimestampSecs(v1_creation_date());\n            let offset = if server {\n                None\n            } else {\n                Some(local_minutes_west_for_stamp(crt)?)\n            };\n            db.execute(\n                \"update col set crt=?, scm=?, ver=?, conf=?\",\n                params![\n                    crt,\n                    TimestampMillis::now(),\n                    SCHEMA_STARTING_VERSION,\n                    &schema11_config_as_string(offset)\n                ],\n            )?;\n        }\n\n        let storage = Self { db };\n\n        if create || upgrade {\n            storage.upgrade_to_latest_schema(ver, server)?;\n        }\n\n        if create {\n            storage.add_default_deck_config(tr)?;\n            storage.add_default_deck(tr)?;\n            storage.add_stock_notetypes(tr)?;\n        }\n\n        if create || upgrade {\n            storage.commit_trx()?;\n        }\n\n        Ok(storage)\n    }\n\n    pub(crate) fn close(self, desired_version: Option<SchemaVersion>) -> Result<()> {\n        if let Some(version) = desired_version {\n            self.downgrade_to(version)?;\n            if version.has_journal_mode_delete() {\n                self.db.pragma_update(None, \"journal_mode\", \"delete\")?;\n            }\n        }\n        Ok(())\n    }\n\n    /// Flush data from WAL file into DB, so the DB is safe to copy. Caller must\n    /// not call this while there is an active transaction.\n    pub(crate) fn checkpoint(&self) -> Result<()> {\n        if !self.db.is_autocommit() {\n            return Err(AnkiError::db_error(\n                \"active transaction\",\n                DbErrorKind::Other,\n            ));\n        }\n        self.db\n            .query_row_and_then(\"pragma wal_checkpoint(truncate)\", [], |row| {\n                let error_code: i64 = row.get(0)?;\n                if error_code != 0 {\n                    Err(AnkiError::db_error(\n                        \"unable to checkpoint\",\n                        DbErrorKind::Other,\n                    ))\n                } else {\n                    Ok(())\n                }\n            })\n    }\n\n    // Standard transaction start/stop\n    //////////////////////////////////////\n\n    pub(crate) fn begin_trx(&self) -> Result<()> {\n        self.db.prepare_cached(\"begin exclusive\")?.execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn commit_trx(&self) -> Result<()> {\n        if !self.db.is_autocommit() {\n            self.db.prepare_cached(\"commit\")?.execute([])?;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn rollback_trx(&self) -> Result<()> {\n        if !self.db.is_autocommit() {\n            self.db.execute(\"rollback\", [])?;\n        }\n        Ok(())\n    }\n\n    // Savepoints\n    //////////////////////////////////////////\n    //\n    // This is necessary at the moment because Anki's current architecture uses\n    // long-running transactions as an undo mechanism. Once a proper undo\n    // mechanism has been added to all existing functionality, we could\n    // transition these to standard commits.\n\n    pub(crate) fn begin_rust_trx(&self) -> Result<()> {\n        self.db.prepare_cached(\"savepoint rust\")?.execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn commit_rust_trx(&self) -> Result<()> {\n        self.db.prepare_cached(\"release rust\")?.execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn rollback_rust_trx(&self) -> Result<()> {\n        self.db.prepare_cached(\"rollback to rust\")?.execute([])?;\n        Ok(())\n    }\n\n    //////////////////////////////////////////\n\n    /// true if corrupt/can't access\n    pub(crate) fn quick_check_corrupt(&self) -> bool {\n        match self.db.pragma_query_value(None, \"quick_check\", |row| {\n            row.get(0).map(|v: String| v != \"ok\")\n        }) {\n            Ok(corrupt) => corrupt,\n            Err(e) => {\n                println!(\"error: {e:?}\");\n                true\n            }\n        }\n    }\n\n    pub(crate) fn optimize(&self) -> Result<()> {\n        self.db.execute_batch(\"vacuum; reindex; analyze\")?;\n        Ok(())\n    }\n\n    #[cfg(test)]\n    pub(crate) fn db_scalar<T: rusqlite::types::FromSql>(&self, sql: &str) -> Result<T> {\n        self.db.query_row(sql, [], |r| r.get(0)).map_err(Into::into)\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum SqlSortOrder {\n    Ascending,\n    Descending,\n}\n\nimpl Display for SqlSortOrder {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(\n            f,\n            \"{}\",\n            match self {\n                SqlSortOrder::Ascending => \"asc\",\n                SqlSortOrder::Descending => \"desc\",\n            }\n        )\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::scheduler::answering::test::v3_test_collection;\n    use crate::storage::card::ReviewOrderSubclause;\n\n    #[test]\n    fn missing_memory_state_falls_back_to_sm2() -> Result<()> {\n        let (mut col, _cids) = v3_test_collection(1)?;\n        col.set_config_bool(BoolKey::Fsrs, true, true)?;\n        col.answer_easy();\n\n        let timing = col.timing_today()?;\n        let sql_func = ReviewOrderSubclause::RelativeOverdueness { fsrs: true, timing }\n            .to_string()\n            .replace(\" asc\", \"\");\n        let sql = format!(\"select {sql_func} from cards\");\n\n        // value from fsrs\n        let mut pos: Option<f64>;\n        pos = col.storage.db_scalar(&sql).unwrap();\n        assert_eq!(pos, Some(0.0));\n        // erasing the memory state should not result in None output\n        col.storage.db.execute(\"update cards set data=''\", [])?;\n        pos = col.storage.db_scalar(&sql).unwrap();\n        assert!(pos.is_some());\n        // but it won't match the fsrs value\n        assert!(pos.unwrap() < -0.0);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/sync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rusqlite::params;\nuse rusqlite::types::FromSql;\nuse rusqlite::ToSql;\n\nuse super::*;\nuse crate::prelude::*;\n\nimpl SqliteStorage {\n    pub(crate) fn usn(&self, server: bool) -> Result<Usn> {\n        if server {\n            Ok(Usn(self\n                .db\n                .prepare_cached(\"select usn from col\")?\n                .query_row([], |row| row.get(0))?))\n        } else {\n            Ok(Usn(-1))\n        }\n    }\n\n    pub(crate) fn set_usn(&self, usn: Usn) -> Result<()> {\n        self.db\n            .prepare_cached(\"update col set usn = ?\")?\n            .execute([usn])?;\n        Ok(())\n    }\n\n    pub(crate) fn increment_usn(&self) -> Result<()> {\n        self.db\n            .prepare_cached(\"update col set usn = usn + 1\")?\n            .execute([])?;\n        Ok(())\n    }\n\n    pub(crate) fn objects_pending_sync<T: FromSql>(&self, table: &str, usn: Usn) -> Result<Vec<T>> {\n        self.db\n            .prepare_cached(&format!(\n                \"select id from {} where {}\",\n                table,\n                usn.pending_object_clause()\n            ))?\n            .query_and_then([usn], |r| r.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn maybe_update_object_usns<I: ToSql>(\n        &self,\n        table: &str,\n        ids: &[I],\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<()> {\n        if let Some(new_usn) = server_usn_if_client {\n            let mut stmt = self\n                .db\n                .prepare_cached(&format!(\"update {table} set usn=? where id=?\"))?;\n            for id in ids {\n                stmt.execute(params![new_usn, id])?;\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl Usn {\n    /// Used when gathering pending objects during sync.\n    pub(crate) fn pending_object_clause(self) -> &'static str {\n        if self.0 == -1 {\n            \"usn = ?\"\n        } else {\n            \"usn >= ?\"\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/sync_check.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::*;\nuse crate::error::SyncErrorKind;\nuse crate::prelude::*;\nuse crate::sync::collection::sanity::SanityCheckCounts;\nuse crate::sync::collection::sanity::SanityCheckDueCounts;\n\nimpl SqliteStorage {\n    fn table_has_usn(&self, table: &str) -> Result<bool> {\n        Ok(self\n            .db\n            .prepare(&format!(\"select null from {table} where usn=-1\"))?\n            .query([])?\n            .next()?\n            .is_some())\n    }\n\n    fn table_count(&self, table: &str) -> Result<u32> {\n        self.db\n            .query_row(&format!(\"select count() from {table}\"), [], |r| r.get(0))\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn sanity_check_info(&self) -> Result<SanityCheckCounts> {\n        for table in &[\n            \"cards\",\n            \"notes\",\n            \"revlog\",\n            \"graves\",\n            \"decks\",\n            \"deck_config\",\n            \"tags\",\n            \"notetypes\",\n        ] {\n            if self.table_has_usn(table)? {\n                return Err(AnkiError::sync_error(\n                    format!(\"table had usn=-1: {table}\"),\n                    SyncErrorKind::Other,\n                ));\n            }\n        }\n\n        Ok(SanityCheckCounts {\n            counts: SanityCheckDueCounts::default(),\n            cards: self.table_count(\"cards\")?,\n            notes: self.table_count(\"notes\")?,\n            revlog: self.table_count(\"revlog\")?,\n            graves: self.table_count(\"graves\")?,\n            notetypes: self.table_count(\"notetypes\")?,\n            decks: self.table_count(\"decks\")?,\n            deck_config: self.table_count(\"deck_config\")?,\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/tag/add.sql",
    "content": "INSERT\n  OR REPLACE INTO tags (tag, usn, collapsed)\nVALUES (?, ?, ?)"
  },
  {
    "path": "rslib/src/storage/tag/alloc_id.sql",
    "content": "SELECT CASE\n    WHEN ?1 IN (\n      SELECT id\n      FROM tags\n    ) THEN (\n      SELECT max(id) + 1\n      FROM tags\n    )\n    ELSE ?1\n  END;"
  },
  {
    "path": "rslib/src/storage/tag/get.sql",
    "content": "SELECT tag,\n  usn,\n  collapsed\nFROM tags"
  },
  {
    "path": "rslib/src/storage/tag/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse rusqlite::params;\nuse rusqlite::Row;\n\nuse super::SqliteStorage;\nuse crate::error::Result;\nuse crate::tags::Tag;\nuse crate::types::Usn;\n\nfn row_to_tag(row: &Row) -> Result<Tag> {\n    Ok(Tag {\n        name: row.get(0)?,\n        usn: row.get(1)?,\n        expanded: !row.get(2)?,\n    })\n}\n\nimpl SqliteStorage {\n    /// All tags in the collection, in alphabetical order.\n    pub fn all_tags(&self) -> Result<Vec<Tag>> {\n        self.db\n            .prepare_cached(include_str!(\"get.sql\"))?\n            .query_and_then([], row_to_tag)?\n            .collect()\n    }\n\n    pub(crate) fn expanded_tags(&self) -> Result<Vec<String>> {\n        self.db\n            .prepare_cached(\"select tag from tags where collapsed = false\")?\n            .query_and_then([], |r| r.get::<_, String>(0).map_err(Into::into))?\n            .collect::<Result<Vec<_>>>()\n    }\n\n    pub(crate) fn restore_expanded_tags(&self, tags: &[String]) -> Result<()> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"update tags set collapsed = false where tag = ?\")?;\n        for tag in tags {\n            stmt.execute([tag])?;\n        }\n        Ok(())\n    }\n\n    pub(crate) fn get_tag(&self, name: &str) -> Result<Option<Tag>> {\n        self.db\n            .prepare_cached(&format!(\"{} where tag = ?\", include_str!(\"get.sql\")))?\n            .query_and_then([name], row_to_tag)?\n            .next()\n            .transpose()\n    }\n\n    pub(crate) fn register_tag(&self, tag: &Tag) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"add.sql\"))?\n            .execute(params![tag.name, tag.usn, !tag.expanded])?;\n        Ok(())\n    }\n\n    pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result<Option<String>> {\n        self.db\n            .prepare_cached(\"select tag from tags where tag = ?\")?\n            .query_and_then(params![tag], |row| row.get(0))?\n            .next()\n            .transpose()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_tags_by_predicate<F>(&self, mut want: F) -> Result<Vec<Tag>>\n    where\n        F: FnMut(&str) -> bool,\n    {\n        let mut query_stmt = self.db.prepare_cached(include_str!(\"get.sql\"))?;\n        let mut rows = query_stmt.query([])?;\n        let mut output = vec![];\n        while let Some(row) = rows.next()? {\n            let tag = row.get_ref_unwrap(0).as_str()?;\n            if want(tag) {\n                output.push(Tag {\n                    name: tag.to_owned(),\n                    usn: row.get(1)?,\n                    expanded: !row.get(2)?,\n                })\n            }\n        }\n        Ok(output)\n    }\n\n    pub(crate) fn remove_single_tag(&self, tag: &str) -> Result<()> {\n        self.db\n            .prepare_cached(\"delete from tags where tag = ?\")?\n            .execute([tag])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"update.sql\"))?\n            .execute(params![&tag.name, tag.usn, !tag.expanded])?;\n        Ok(())\n    }\n\n    pub(crate) fn clear_all_tags(&self) -> Result<()> {\n        self.db.execute(\"delete from tags\", [])?;\n        Ok(())\n    }\n\n    pub(crate) fn clear_tag_usns(&self) -> Result<()> {\n        self.db\n            .execute(\"update tags set usn = 0 where usn != 0\", [])?;\n        Ok(())\n    }\n\n    // fixme: in the future we could just register tags as part of the sync\n    // instead of sending the tag list separately\n    pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result<Vec<String>> {\n        self.db\n            .prepare_cached(&format!(\n                \"select tag from tags where {}\",\n                usn.pending_object_clause()\n            ))?\n            .query_and_then([usn], |r| r.get(0).map_err(Into::into))?\n            .collect()\n    }\n\n    pub(crate) fn update_tag_usns(&self, tags: &[String], new_usn: Usn) -> Result<()> {\n        let mut stmt = self\n            .db\n            .prepare_cached(\"update tags set usn=? where tag=?\")?;\n        for tag in tags {\n            stmt.execute(params![new_usn, tag])?;\n        }\n        Ok(())\n    }\n\n    // Upgrading/downgrading\n\n    pub(super) fn upgrade_tags_to_schema14(&self) -> Result<()> {\n        let tags = self\n            .db\n            .query_row_and_then(\"select tags from col\", [], |row| {\n                let tags: Result<HashMap<String, Usn>> =\n                    serde_json::from_str(row.get_ref_unwrap(0).as_str()?).map_err(Into::into);\n                tags\n            })?;\n        let mut stmt = self\n            .db\n            .prepare_cached(\"insert or ignore into tags (tag, usn) values (?, ?)\")?;\n        for (tag, usn) in tags.into_iter() {\n            stmt.execute(params![tag, usn])?;\n        }\n        self.db.execute_batch(\"update col set tags=''\")?;\n\n        Ok(())\n    }\n\n    pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> {\n        let alltags = self.all_tags()?;\n        let tagsmap: HashMap<String, Usn> = alltags.into_iter().map(|t| (t.name, t.usn)).collect();\n        self.db.execute(\n            \"update col set tags=?\",\n            params![serde_json::to_string(&tagsmap)?],\n        )?;\n        Ok(())\n    }\n\n    pub(super) fn upgrade_tags_to_schema17(&self) -> Result<()> {\n        let tags = self\n            .db\n            .prepare_cached(\"select tag, usn from tags\")?\n            .query_and_then([], |r| Ok(Tag::new(r.get(0)?, r.get(1)?)))?\n            .collect::<Result<Vec<Tag>>>()?;\n        self.db\n            .execute_batch(include_str![\"../upgrades/schema17_upgrade.sql\"])?;\n        tags.into_iter()\n            .try_for_each(|tag| -> Result<()> { self.register_tag(&tag) })\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/tag/update.sql",
    "content": "UPDATE tags\nSET tag = ?1,\n  usn = ?,\n  collapsed = ?\nWHERE tag = ?1"
  },
  {
    "path": "rslib/src/storage/upgrades/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/// The minimum schema version we can open.\npub(super) const SCHEMA_MIN_VERSION: u8 = 11;\n/// The version new files are initially created with.\npub(super) const SCHEMA_STARTING_VERSION: u8 = 11;\n/// The maximum schema version we can open.\npub(super) const SCHEMA_MAX_VERSION: u8 = 18;\n\nuse super::SchemaVersion;\nuse super::SqliteStorage;\nuse crate::error::Result;\n\nimpl SqliteStorage {\n    pub(super) fn upgrade_to_latest_schema(&self, ver: u8, server: bool) -> Result<()> {\n        if ver < 14 {\n            self.db\n                .execute_batch(include_str!(\"schema14_upgrade.sql\"))?;\n            self.upgrade_deck_conf_to_schema14()?;\n            self.upgrade_tags_to_schema14()?;\n            self.upgrade_config_to_schema14()?;\n        }\n        if ver < 15 {\n            self.db\n                .execute_batch(include_str!(\"schema15_upgrade.sql\"))?;\n            self.upgrade_notetypes_to_schema15()?;\n            self.upgrade_decks_to_schema15(server)?;\n            self.upgrade_deck_conf_to_schema15()?;\n        }\n        if ver < 16 {\n            self.upgrade_deck_conf_to_schema16(server)?;\n            self.db.execute_batch(\"update col set ver = 16\")?;\n        }\n        if ver < 17 {\n            self.upgrade_tags_to_schema17()?;\n            self.db.execute_batch(\"update col set ver = 17\")?;\n        }\n        if ver < 18 {\n            self.db\n                .execute_batch(include_str!(\"schema18_upgrade.sql\"))?;\n        }\n\n        // in some future schema upgrade, we may want to change\n        // _collapsed to _expanded in DeckCommon and invert existing values, so\n        // that we can avoid serializing the values in the default case, and use\n        // DeckCommon::default() in new_normal() and new_filtered()\n\n        Ok(())\n    }\n\n    pub(super) fn downgrade_to(&self, ver: SchemaVersion) -> Result<()> {\n        match ver {\n            SchemaVersion::V11 => self.downgrade_to_schema_11(),\n            SchemaVersion::V18 => Ok(()),\n        }\n    }\n\n    fn downgrade_to_schema_11(&self) -> Result<()> {\n        self.begin_trx()?;\n\n        self.db\n            .execute_batch(include_str!(\"schema18_downgrade.sql\"))?;\n        self.downgrade_deck_conf_from_schema16()?;\n        self.downgrade_decks_from_schema15()?;\n        self.downgrade_notetypes_from_schema15()?;\n        self.downgrade_config_from_schema14()?;\n        self.downgrade_tags_from_schema14()?;\n        self.db\n            .execute_batch(include_str!(\"schema11_downgrade.sql\"))?;\n\n        self.commit_trx()?;\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use anki_io::new_tempfile;\n\n    use super::*;\n    use crate::collection::CollectionBuilder;\n    use crate::prelude::*;\n\n    #[test]\n    #[allow(clippy::assertions_on_constants)]\n    fn assert_18_is_latest_schema_version() {\n        assert_eq!(\n            18, SCHEMA_MAX_VERSION,\n            \"must implement SqliteStorage::downgrade_to(SchemaVersion::V18)\"\n        );\n    }\n\n    #[test]\n    fn valid_ease_factor_survives_upgrade_roundtrip() -> Result<()> {\n        let tempfile = new_tempfile()?;\n        let mut col = CollectionBuilder::default()\n            .set_collection_path(tempfile.path())\n            .build()?;\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n        col.storage\n            .db\n            .execute(\"update cards set factor = 1400\", [])?;\n        col.close(Some(SchemaVersion::V11))?;\n        let col = CollectionBuilder::default()\n            .set_collection_path(tempfile.path())\n            .build()?;\n        let card = &col.storage.get_all_cards()[0];\n        assert_eq!(card.ease_factor, 1400);\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/storage/upgrades/schema11_downgrade.sql",
    "content": "DROP TABLE config;\nDROP TABLE deck_config;\nDROP TABLE tags;\nDROP TABLE fields;\nDROP TABLE templates;\nDROP TABLE notetypes;\nDROP TABLE decks;\nDROP INDEX idx_cards_odid;\nDROP INDEX idx_notes_mid;\nUPDATE col\nSET ver = 11;"
  },
  {
    "path": "rslib/src/storage/upgrades/schema14_upgrade.sql",
    "content": "CREATE TABLE deck_config (\n  id integer PRIMARY KEY NOT NULL,\n  name text NOT NULL COLLATE unicase,\n  mtime_secs integer NOT NULL,\n  usn integer NOT NULL,\n  config blob NOT NULL\n);\nCREATE TABLE config (\n  KEY text NOT NULL PRIMARY KEY,\n  usn integer NOT NULL,\n  mtime_secs integer NOT NULL,\n  val blob NOT NULL\n) without rowid;\nCREATE TABLE tags (\n  tag text NOT NULL PRIMARY KEY COLLATE unicase,\n  usn integer NOT NULL\n) without rowid;\nUPDATE col\nSET ver = 14;"
  },
  {
    "path": "rslib/src/storage/upgrades/schema15_upgrade.sql",
    "content": "CREATE TABLE fields (\n  ntid integer NOT NULL,\n  ord integer NOT NULL,\n  name text NOT NULL COLLATE unicase,\n  config blob NOT NULL,\n  PRIMARY KEY (ntid, ord)\n) without rowid;\nCREATE UNIQUE INDEX idx_fields_name_ntid ON fields (name, ntid);\nCREATE TABLE templates (\n  ntid integer NOT NULL,\n  ord integer NOT NULL,\n  name text NOT NULL COLLATE unicase,\n  mtime_secs integer NOT NULL,\n  usn integer NOT NULL,\n  config blob NOT NULL,\n  PRIMARY KEY (ntid, ord)\n) without rowid;\nCREATE UNIQUE INDEX idx_templates_name_ntid ON templates (name, ntid);\nCREATE INDEX idx_templates_usn ON templates (usn);\nCREATE TABLE notetypes (\n  id integer NOT NULL PRIMARY KEY,\n  name text NOT NULL COLLATE unicase,\n  mtime_secs integer NOT NULL,\n  usn integer NOT NULL,\n  config blob NOT NULL\n);\nCREATE UNIQUE INDEX idx_notetypes_name ON notetypes (name);\nCREATE INDEX idx_notetypes_usn ON notetypes (usn);\nCREATE TABLE decks (\n  id integer PRIMARY KEY NOT NULL,\n  name text NOT NULL COLLATE unicase,\n  mtime_secs integer NOT NULL,\n  usn integer NOT NULL,\n  common blob NOT NULL,\n  kind blob NOT NULL\n);\nCREATE UNIQUE INDEX idx_decks_name ON decks (name);\nCREATE INDEX idx_notes_mid ON notes (mid);\nCREATE INDEX idx_cards_odid ON cards (odid)\nWHERE odid != 0;\nUPDATE col\nSET ver = 15;\nANALYZE;"
  },
  {
    "path": "rslib/src/storage/upgrades/schema17_upgrade.sql",
    "content": "DROP TABLE tags;\nCREATE TABLE tags (\n  tag text NOT NULL PRIMARY KEY COLLATE unicase,\n  usn integer NOT NULL,\n  collapsed boolean NOT NULL,\n  config blob NULL\n) without rowid;"
  },
  {
    "path": "rslib/src/storage/upgrades/schema18_downgrade.sql",
    "content": "ALTER TABLE graves\n  RENAME TO graves_old;\nCREATE TABLE graves (\n  usn integer NOT NULL,\n  oid integer NOT NULL,\n  type integer NOT NULL\n);\nINSERT INTO graves (usn, oid, type)\nSELECT usn,\n  oid,\n  type\nFROM graves_old;\nDROP TABLE graves_old;\nUPDATE col\nSET ver = 17;"
  },
  {
    "path": "rslib/src/storage/upgrades/schema18_upgrade.sql",
    "content": "ALTER TABLE graves\n  RENAME TO graves_old;\nCREATE TABLE graves (\n  oid integer NOT NULL,\n  type integer NOT NULL,\n  usn integer NOT NULL,\n  PRIMARY KEY (oid, type)\n) WITHOUT ROWID;\nINSERT\n  OR IGNORE INTO graves (oid, type, usn)\nSELECT oid,\n  type,\n  usn\nFROM graves_old;\nDROP TABLE graves_old;\nCREATE INDEX idx_graves_pending ON graves (usn);\nUPDATE col\nSET ver = 18;"
  },
  {
    "path": "rslib/src/sync/collection/changes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! The current sync protocol sends changed notetypes, decks, tags and config\n//! all in a single request.\n\nuse std::collections::HashMap;\n\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_json::Value;\nuse serde_tuple::Serialize_tuple;\nuse tracing::debug;\nuse tracing::trace;\n\nuse crate::deckconfig::DeckConfSchema11;\nuse crate::decks::DeckSchema11;\nuse crate::error::SyncErrorKind;\nuse crate::notetype::NotetypeSchema11;\nuse crate::prelude::*;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::start::ServerSyncState;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::tags::Tag;\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct ApplyChangesRequest {\n    pub changes: UnchunkedChanges,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct UnchunkedChanges {\n    #[serde(rename = \"models\")]\n    notetypes: Vec<NotetypeSchema11>,\n    #[serde(rename = \"decks\")]\n    decks_and_config: DecksAndConfig,\n    tags: Vec<String>,\n\n    // the following are only sent if local is newer\n    #[serde(skip_serializing_if = \"Option::is_none\", rename = \"conf\")]\n    config: Option<HashMap<String, Value>>,\n    #[serde(skip_serializing_if = \"Option::is_none\", rename = \"crt\")]\n    creation_stamp: Option<TimestampSecs>,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug, Default)]\npub struct DecksAndConfig {\n    decks: Vec<DeckSchema11>,\n    config: Vec<DeckConfSchema11>,\n}\n\nimpl NormalSyncer<'_> {\n    // This was assumed to a cheap operation when originally written - it didn't\n    // anticipate the large deck trees and note types some users would create.\n    // They should be chunked in the future, like other objects. Syncing tags\n    // explicitly is also probably of limited usefulness.\n    pub(in crate::sync) async fn process_unchunked_changes(\n        &mut self,\n        state: &ClientSyncState,\n    ) -> Result<()> {\n        debug!(\"gathering local changes\");\n        let local = self.col.local_unchunked_changes(\n            state.pending_usn,\n            Some(state.server_usn),\n            state.local_is_newer,\n        )?;\n\n        debug!(\n            notetypes = local.notetypes.len(),\n            decks = local.decks_and_config.decks.len(),\n            deck_config = local.decks_and_config.config.len(),\n            tags = local.tags.len(),\n            \"sending\"\n        );\n\n        self.progress.update(false, |p| {\n            p.local_update += local.notetypes.len()\n                + local.decks_and_config.decks.len()\n                + local.decks_and_config.config.len()\n                + local.tags.len();\n        })?;\n        let remote = self\n            .server\n            .apply_changes(ApplyChangesRequest { changes: local }.try_into_sync_request()?)\n            .await?\n            .json()?;\n        self.progress.check_cancelled()?;\n\n        debug!(\n            notetypes = remote.notetypes.len(),\n            decks = remote.decks_and_config.decks.len(),\n            deck_config = remote.decks_and_config.config.len(),\n            tags = remote.tags.len(),\n            \"received\"\n        );\n\n        self.progress.update(false, |p| {\n            p.remote_update += remote.notetypes.len()\n                + remote.decks_and_config.decks.len()\n                + remote.decks_and_config.config.len()\n                + remote.tags.len();\n        })?;\n\n        self.col.apply_changes(remote, state.server_usn)?;\n        self.progress.check_cancelled()?;\n        Ok(())\n    }\n}\n\nimpl Collection {\n    // Local->remote unchunked changes\n    //----------------------------------------------------------------\n\n    pub(in crate::sync) fn local_unchunked_changes(\n        &mut self,\n        pending_usn: Usn,\n        server_usn_if_client: Option<Usn>,\n        local_is_newer: bool,\n    ) -> Result<UnchunkedChanges> {\n        let mut changes = UnchunkedChanges {\n            notetypes: self.changed_notetypes(pending_usn, server_usn_if_client)?,\n            decks_and_config: DecksAndConfig {\n                decks: self.changed_decks(pending_usn, server_usn_if_client)?,\n                config: self.changed_deck_config(pending_usn, server_usn_if_client)?,\n            },\n            tags: self.changed_tags(pending_usn, server_usn_if_client)?,\n            ..Default::default()\n        };\n        if local_is_newer {\n            changes.config = Some(self.changed_config()?);\n            changes.creation_stamp = Some(self.storage.creation_stamp()?);\n        }\n\n        Ok(changes)\n    }\n\n    fn changed_notetypes(\n        &mut self,\n        pending_usn: Usn,\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<Vec<NotetypeSchema11>> {\n        let ids = self\n            .storage\n            .objects_pending_sync(\"notetypes\", pending_usn)?;\n        self.storage\n            .maybe_update_object_usns(\"notetypes\", &ids, server_usn_if_client)?;\n        self.state.notetype_cache.clear();\n        ids.into_iter()\n            .map(|id| {\n                self.storage.get_notetype(id).map(|opt| {\n                    let mut nt: NotetypeSchema11 = opt.unwrap().into();\n                    nt.usn = server_usn_if_client.unwrap_or(nt.usn);\n                    nt\n                })\n            })\n            .collect()\n    }\n\n    fn changed_decks(\n        &mut self,\n        pending_usn: Usn,\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<Vec<DeckSchema11>> {\n        let ids = self.storage.objects_pending_sync(\"decks\", pending_usn)?;\n        self.storage\n            .maybe_update_object_usns(\"decks\", &ids, server_usn_if_client)?;\n        self.state.deck_cache.clear();\n        ids.into_iter()\n            .map(|id| {\n                self.storage.get_deck(id).map(|opt| {\n                    let mut deck = opt.unwrap();\n                    deck.usn = server_usn_if_client.unwrap_or(deck.usn);\n                    deck.into()\n                })\n            })\n            .collect()\n    }\n\n    fn changed_deck_config(\n        &self,\n        pending_usn: Usn,\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<Vec<DeckConfSchema11>> {\n        let ids = self\n            .storage\n            .objects_pending_sync(\"deck_config\", pending_usn)?;\n        self.storage\n            .maybe_update_object_usns(\"deck_config\", &ids, server_usn_if_client)?;\n        ids.into_iter()\n            .map(|id| {\n                self.storage.get_deck_config(id).map(|opt| {\n                    let mut conf: DeckConfSchema11 = opt.unwrap().into();\n                    conf.usn = server_usn_if_client.unwrap_or(conf.usn);\n                    conf\n                })\n            })\n            .collect()\n    }\n\n    fn changed_tags(\n        &self,\n        pending_usn: Usn,\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<Vec<String>> {\n        let changed = self.storage.tags_pending_sync(pending_usn)?;\n        if let Some(usn) = server_usn_if_client {\n            self.storage.update_tag_usns(&changed, usn)?;\n        }\n        Ok(changed)\n    }\n\n    /// Currently this is all config, as legacy clients overwrite the local\n    /// items with the provided value.\n    fn changed_config(&self) -> Result<HashMap<String, Value>> {\n        let conf = self.storage.get_all_config()?;\n        self.storage.clear_config_usns()?;\n        Ok(conf)\n    }\n\n    // Remote->local unchunked changes\n    //----------------------------------------------------------------\n\n    pub(in crate::sync) fn apply_changes(\n        &mut self,\n        remote: UnchunkedChanges,\n        latest_usn: Usn,\n    ) -> Result<()> {\n        self.merge_notetypes(remote.notetypes, latest_usn)?;\n        self.merge_decks(remote.decks_and_config.decks, latest_usn)?;\n        self.merge_deck_config(remote.decks_and_config.config)?;\n        self.merge_tags(remote.tags, latest_usn)?;\n        if let Some(crt) = remote.creation_stamp {\n            self.set_creation_stamp(crt)?;\n        }\n        if let Some(config) = remote.config {\n            self.storage\n                .set_all_config(config, latest_usn, TimestampSecs::now())?;\n        }\n\n        Ok(())\n    }\n\n    fn merge_notetypes(&mut self, notetypes: Vec<NotetypeSchema11>, latest_usn: Usn) -> Result<()> {\n        for nt in notetypes {\n            let mut nt: Notetype = nt.into();\n            let proceed = if let Some(existing_nt) = self.storage.get_notetype(nt.id)? {\n                if existing_nt.mtime_secs <= nt.mtime_secs {\n                    if (existing_nt.fields.len() != nt.fields.len())\n                        || (existing_nt.templates.len() != nt.templates.len())\n                    {\n                        return Err(AnkiError::sync_error(\n                            \"notetype schema changed\",\n                            SyncErrorKind::ResyncRequired,\n                        ));\n                    }\n                    true\n                } else {\n                    false\n                }\n            } else {\n                true\n            };\n            if proceed {\n                self.ensure_notetype_name_unique(&mut nt, latest_usn)?;\n                self.storage.add_or_update_notetype_with_existing_id(&nt)?;\n                self.state.notetype_cache.remove(&nt.id);\n            }\n        }\n        Ok(())\n    }\n\n    fn merge_decks(&mut self, decks: Vec<DeckSchema11>, latest_usn: Usn) -> Result<()> {\n        for deck in decks {\n            let proceed = if let Some(existing_deck) = self.storage.get_deck(deck.id())? {\n                existing_deck.mtime_secs <= deck.common().mtime\n            } else {\n                true\n            };\n            if proceed {\n                let mut deck = deck.into();\n                self.ensure_deck_name_unique(&mut deck, latest_usn)?;\n                self.storage.add_or_update_deck_with_existing_id(&deck)?;\n                self.state.deck_cache.remove(&deck.id);\n            }\n        }\n        Ok(())\n    }\n\n    fn merge_deck_config(&self, dconf: Vec<DeckConfSchema11>) -> Result<()> {\n        for conf in dconf {\n            let proceed = if let Some(existing_conf) = self.storage.get_deck_config(conf.id)? {\n                existing_conf.mtime_secs <= conf.mtime\n            } else {\n                true\n            };\n            if proceed {\n                let conf = conf.into();\n                self.storage\n                    .add_or_update_deck_config_with_existing_id(&conf)?;\n            }\n        }\n        Ok(())\n    }\n\n    fn merge_tags(&mut self, tags: Vec<String>, latest_usn: Usn) -> Result<()> {\n        for tag in tags {\n            self.register_tag(&mut Tag::new(tag, latest_usn))?;\n        }\n        Ok(())\n    }\n}\n\npub fn server_apply_changes(\n    req: ApplyChangesRequest,\n    col: &mut Collection,\n    state: &mut ServerSyncState,\n) -> Result<UnchunkedChanges> {\n    let server_changes =\n        col.local_unchunked_changes(state.client_usn, None, !state.client_is_newer)?;\n    trace!(?req.changes, ?server_changes);\n    col.apply_changes(req.changes, state.server_usn)?;\n    Ok(server_changes)\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/chunks.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse itertools::Itertools;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_tuple::Serialize_tuple;\nuse tracing::debug;\n\nuse crate::card::Card;\nuse crate::card::CardQueue;\nuse crate::card::CardType;\nuse crate::notes::Note;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::serde::deserialize_int_from_number;\nuse crate::storage::card::data::card_data_string;\nuse crate::storage::card::data::CardData;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::start::ServerSyncState;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::tags::join_tags;\nuse crate::tags::split_tags;\n\npub(in crate::sync) struct ChunkableIds {\n    revlog: Vec<RevlogId>,\n    cards: Vec<CardId>,\n    notes: Vec<NoteId>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct Chunk {\n    #[serde(default)]\n    pub done: bool,\n    #[serde(skip_serializing_if = \"Vec::is_empty\", default)]\n    pub revlog: Vec<RevlogEntry>,\n    #[serde(skip_serializing_if = \"Vec::is_empty\", default)]\n    pub cards: Vec<CardEntry>,\n    #[serde(skip_serializing_if = \"Vec::is_empty\", default)]\n    pub notes: Vec<NoteEntry>,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug)]\npub struct NoteEntry {\n    pub id: NoteId,\n    pub guid: String,\n    #[serde(rename = \"mid\")]\n    pub ntid: NotetypeId,\n    #[serde(rename = \"mod\")]\n    pub mtime: TimestampSecs,\n    pub usn: Usn,\n    pub tags: String,\n    pub fields: String,\n    pub sfld: String, // always empty\n    pub csum: String, // always empty\n    pub flags: u32,\n    pub data: String,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug)]\npub struct CardEntry {\n    pub id: CardId,\n    pub nid: NoteId,\n    pub did: DeckId,\n    pub ord: u16,\n    #[serde(deserialize_with = \"deserialize_int_from_number\")]\n    pub mtime: TimestampSecs,\n    pub usn: Usn,\n    pub ctype: CardType,\n    pub queue: CardQueue,\n    #[serde(deserialize_with = \"deserialize_int_from_number\")]\n    pub due: i32,\n    #[serde(deserialize_with = \"deserialize_int_from_number\")]\n    pub ivl: u32,\n    pub factor: u16,\n    pub reps: u32,\n    pub lapses: u32,\n    pub left: u32,\n    #[serde(deserialize_with = \"deserialize_int_from_number\")]\n    pub odue: i32,\n    pub odid: DeckId,\n    pub flags: u8,\n    pub data: String,\n}\n\nimpl NormalSyncer<'_> {\n    pub(in crate::sync) async fn process_chunks_from_server(\n        &mut self,\n        state: &ClientSyncState,\n    ) -> Result<()> {\n        loop {\n            let chunk = self.server.chunk(EmptyInput::request()).await?.json()?;\n\n            debug!(\n                done = chunk.done,\n                cards = chunk.cards.len(),\n                notes = chunk.notes.len(),\n                revlog = chunk.revlog.len(),\n                \"received\"\n            );\n\n            self.progress.update(false, |p| {\n                p.remote_update += chunk.cards.len() + chunk.notes.len() + chunk.revlog.len()\n            })?;\n\n            let done = chunk.done;\n            self.col.apply_chunk(chunk, state.pending_usn)?;\n\n            self.progress.check_cancelled()?;\n\n            if done {\n                return Ok(());\n            }\n        }\n    }\n\n    pub(in crate::sync) async fn send_chunks_to_server(\n        &mut self,\n        state: &ClientSyncState,\n    ) -> Result<()> {\n        let mut ids = self.col.get_chunkable_ids(state.pending_usn)?;\n\n        loop {\n            let chunk: Chunk = self.col.get_chunk(&mut ids, Some(state.server_usn))?;\n            let done = chunk.done;\n\n            debug!(\n                done = chunk.done,\n                cards = chunk.cards.len(),\n                notes = chunk.notes.len(),\n                revlog = chunk.revlog.len(),\n                \"sending\"\n            );\n\n            self.progress.update(false, |p| {\n                p.local_update += chunk.cards.len() + chunk.notes.len() + chunk.revlog.len()\n            })?;\n\n            self.server\n                .apply_chunk(ApplyChunkRequest { chunk }.try_into_sync_request()?)\n                .await?;\n\n            self.progress.check_cancelled()?;\n\n            if done {\n                return Ok(());\n            }\n        }\n    }\n}\n\nimpl Collection {\n    // Remote->local chunks\n    //----------------------------------------------------------------\n\n    /// pending_usn is used to decide whether the local objects are newer.\n    /// If the provided objects are not modified locally, the USN inside\n    /// the individual objects is used.\n    pub(in crate::sync) fn apply_chunk(&mut self, chunk: Chunk, pending_usn: Usn) -> Result<()> {\n        self.merge_revlog(chunk.revlog)?;\n        self.merge_cards(chunk.cards, pending_usn)?;\n        self.merge_notes(chunk.notes, pending_usn)\n    }\n\n    fn merge_revlog(&self, entries: Vec<RevlogEntry>) -> Result<()> {\n        for entry in entries {\n            self.storage.add_revlog_entry(&entry, false)?;\n        }\n        Ok(())\n    }\n\n    fn merge_cards(&self, entries: Vec<CardEntry>, pending_usn: Usn) -> Result<()> {\n        for entry in entries {\n            self.add_or_update_card_if_newer(entry, pending_usn)?;\n        }\n        Ok(())\n    }\n\n    fn add_or_update_card_if_newer(&self, entry: CardEntry, pending_usn: Usn) -> Result<()> {\n        let proceed = if let Some(existing_card) = self.storage.get_card(entry.id)? {\n            !existing_card.usn.is_pending_sync(pending_usn) || existing_card.mtime < entry.mtime\n        } else {\n            true\n        };\n        if proceed {\n            let card = entry.into();\n            self.storage.add_or_update_card(&card)?;\n        }\n        Ok(())\n    }\n\n    fn merge_notes(&mut self, entries: Vec<NoteEntry>, pending_usn: Usn) -> Result<()> {\n        for entry in entries {\n            self.add_or_update_note_if_newer(entry, pending_usn)?;\n        }\n        Ok(())\n    }\n\n    fn add_or_update_note_if_newer(&mut self, entry: NoteEntry, pending_usn: Usn) -> Result<()> {\n        let proceed = if let Some(existing_note) = self.storage.get_note(entry.id)? {\n            !existing_note.usn.is_pending_sync(pending_usn) || existing_note.mtime < entry.mtime\n        } else {\n            true\n        };\n        if proceed {\n            let mut note: Note = entry.into();\n            let nt = self\n                .get_notetype(note.notetype_id)?\n                .or_invalid(\"note missing notetype\")?;\n            note.prepare_for_update(&nt, false)?;\n            self.storage.add_or_update_note(&note)?;\n        }\n        Ok(())\n    }\n\n    // Local->remote chunks\n    //----------------------------------------------------------------\n\n    pub(in crate::sync) fn get_chunkable_ids(&self, pending_usn: Usn) -> Result<ChunkableIds> {\n        Ok(ChunkableIds {\n            revlog: self.storage.objects_pending_sync(\"revlog\", pending_usn)?,\n            cards: self.storage.objects_pending_sync(\"cards\", pending_usn)?,\n            notes: self.storage.objects_pending_sync(\"notes\", pending_usn)?,\n        })\n    }\n\n    /// Fetch a chunk of ids from `ids`, returning the referenced objects.\n    pub(in crate::sync) fn get_chunk(\n        &self,\n        ids: &mut ChunkableIds,\n        server_usn_if_client: Option<Usn>,\n    ) -> Result<Chunk> {\n        // get a bunch of IDs\n        let mut limit = CHUNK_SIZE as i32;\n        let mut revlog_ids = vec![];\n        let mut card_ids = vec![];\n        let mut note_ids = vec![];\n        let mut chunk = Chunk::default();\n        while limit > 0 {\n            let last_limit = limit;\n            if let Some(id) = ids.revlog.pop() {\n                revlog_ids.push(id);\n                limit -= 1;\n            }\n            if let Some(id) = ids.notes.pop() {\n                note_ids.push(id);\n                limit -= 1;\n            }\n            if let Some(id) = ids.cards.pop() {\n                card_ids.push(id);\n                limit -= 1;\n            }\n            if limit == last_limit {\n                // all empty\n                break;\n            }\n        }\n        if limit > 0 {\n            chunk.done = true;\n        }\n\n        // remove pending status\n        if !self.server {\n            self.storage\n                .maybe_update_object_usns(\"revlog\", &revlog_ids, server_usn_if_client)?;\n            self.storage\n                .maybe_update_object_usns(\"cards\", &card_ids, server_usn_if_client)?;\n            self.storage\n                .maybe_update_object_usns(\"notes\", &note_ids, server_usn_if_client)?;\n        }\n\n        // the fetch associated objects, and return\n        chunk.revlog = revlog_ids\n            .into_iter()\n            .map(|id| {\n                self.storage.get_revlog_entry(id).map(|e| {\n                    let mut e = e.unwrap();\n                    e.usn = server_usn_if_client.unwrap_or(e.usn);\n                    e\n                })\n            })\n            .collect::<Result<_>>()?;\n        chunk.cards = card_ids\n            .into_iter()\n            .map(|id| {\n                self.storage.get_card(id).map(|e| {\n                    let mut e: CardEntry = e.unwrap().into();\n                    e.usn = server_usn_if_client.unwrap_or(e.usn);\n                    e\n                })\n            })\n            .collect::<Result<_>>()?;\n        chunk.notes = note_ids\n            .into_iter()\n            .map(|id| {\n                self.storage.get_note(id).map(|e| {\n                    let mut e: NoteEntry = e.unwrap().into();\n                    e.usn = server_usn_if_client.unwrap_or(e.usn);\n                    e\n                })\n            })\n            .collect::<Result<_>>()?;\n\n        Ok(chunk)\n    }\n}\n\nimpl From<CardEntry> for Card {\n    fn from(e: CardEntry) -> Self {\n        let data = CardData::from_str(&e.data);\n        Card {\n            id: e.id,\n            note_id: e.nid,\n            deck_id: e.did,\n            template_idx: e.ord,\n            mtime: e.mtime,\n            usn: e.usn,\n            ctype: e.ctype,\n            queue: e.queue,\n            due: e.due,\n            interval: e.ivl,\n            ease_factor: e.factor,\n            reps: e.reps,\n            lapses: e.lapses,\n            remaining_steps: e.left,\n            original_due: e.odue,\n            original_deck_id: e.odid,\n            flags: e.flags,\n            original_position: data.original_position,\n            memory_state: data.memory_state(),\n            desired_retention: data.fsrs_desired_retention,\n            decay: data.decay,\n            last_review_time: data.last_review_time,\n            custom_data: data.custom_data,\n        }\n    }\n}\n\nimpl From<Card> for CardEntry {\n    fn from(e: Card) -> Self {\n        CardEntry {\n            id: e.id,\n            nid: e.note_id,\n            did: e.deck_id,\n            ord: e.template_idx,\n            mtime: e.mtime,\n            usn: e.usn,\n            ctype: e.ctype,\n            queue: e.queue,\n            due: e.due,\n            ivl: e.interval,\n            factor: e.ease_factor,\n            reps: e.reps,\n            lapses: e.lapses,\n            left: e.remaining_steps,\n            odue: e.original_due,\n            odid: e.original_deck_id,\n            flags: e.flags,\n            data: card_data_string(&e),\n        }\n    }\n}\n\nimpl From<NoteEntry> for Note {\n    fn from(e: NoteEntry) -> Self {\n        let fields = e.fields.split('\\x1f').map(ToString::to_string).collect();\n        Note::new_from_storage(\n            e.id,\n            e.guid,\n            e.ntid,\n            e.mtime,\n            e.usn,\n            split_tags(&e.tags).map(ToString::to_string).collect(),\n            fields,\n            None,\n            None,\n        )\n    }\n}\n\nimpl From<Note> for NoteEntry {\n    fn from(e: Note) -> Self {\n        NoteEntry {\n            id: e.id,\n            fields: e.fields().iter().join(\"\\x1f\"),\n            guid: e.guid,\n            ntid: e.notetype_id,\n            mtime: e.mtime,\n            usn: e.usn,\n            tags: join_tags(&e.tags),\n            sfld: String::new(),\n            csum: String::new(),\n            flags: 0,\n            data: String::new(),\n        }\n    }\n}\n\npub fn server_chunk(col: &mut Collection, state: &mut ServerSyncState) -> Result<Chunk> {\n    if state.server_chunk_ids.is_none() {\n        state.server_chunk_ids = Some(col.get_chunkable_ids(state.client_usn)?);\n    }\n    col.get_chunk(state.server_chunk_ids.as_mut().unwrap(), None)\n}\n\npub fn server_apply_chunk(\n    req: ApplyChunkRequest,\n    col: &mut Collection,\n    state: &mut ServerSyncState,\n) -> Result<()> {\n    col.apply_chunk(req.chunk, state.client_usn)\n}\n\nimpl Usn {\n    pub(crate) fn is_pending_sync(self, pending_usn: Usn) -> bool {\n        if pending_usn.0 == -1 {\n            self.0 == -1\n        } else {\n            self.0 >= pending_usn.0\n        }\n    }\n}\n\npub const CHUNK_SIZE: usize = 250;\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct ApplyChunkRequest {\n    pub chunk: Chunk,\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/download.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_io::atomic_rename;\nuse anki_io::new_tempfile_in_parent_of;\nuse anki_io::read_file;\nuse anki_io::write_file;\nuse reqwest::Client;\n\nuse crate::collection::CollectionBuilder;\nuse crate::prelude::*;\nuse crate::storage::SchemaVersion;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::SyncAuth;\n\nimpl Collection {\n    /// Download collection from AnkiWeb. Caller must re-open afterwards.\n    pub async fn full_download(self, auth: SyncAuth, client: Client) -> Result<()> {\n        self.full_download_with_server(HttpSyncClient::new(auth, client))\n            .await\n    }\n\n    // pub for tests\n    pub(super) async fn full_download_with_server(self, server: HttpSyncClient) -> Result<()> {\n        let col_path = self.col_path.clone();\n        let _col_folder = col_path.parent().or_invalid(\"couldn't get col_folder\")?;\n        let progress = self.new_progress_handler();\n        self.close(None)?;\n        let out_data = server\n            .download_with_progress(EmptyInput::request(), progress)\n            .await?\n            .data;\n        // check file ok\n        let temp_file = new_tempfile_in_parent_of(&col_path)?;\n        write_file(temp_file.path(), out_data)?;\n        let col = CollectionBuilder::new(temp_file.path())\n            .set_check_integrity(true)\n            .build()?;\n        col.storage.db.execute_batch(\"update col set ls=mod\")?;\n        col.close(None)?;\n        atomic_rename(temp_file, &col_path, true)?;\n        Ok(())\n    }\n}\n\npub fn server_download(\n    col: &mut Option<Collection>,\n    schema_version: SchemaVersion,\n) -> HttpResult<Vec<u8>> {\n    let col_path = {\n        let mut col = col.take().or_internal_err(\"take col\")?;\n        let path = col.col_path.clone();\n        col.transact_no_undo(|col| col.storage.increment_usn())\n            .or_internal_err(\"incr usn\")?;\n        col.close(Some(schema_version)).or_internal_err(\"close\")?;\n        path\n    };\n    let data = read_file(col_path).or_internal_err(\"read col\")?;\n    Ok(data)\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/finish.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::prelude::*;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\n\nimpl NormalSyncer<'_> {\n    pub(in crate::sync) async fn finalize(&mut self, state: &ClientSyncState) -> Result<()> {\n        let new_server_mtime = self.server.finish(EmptyInput::request()).await?.json()?;\n        self.col.finalize_sync(state, new_server_mtime)\n    }\n}\n\nimpl Collection {\n    fn finalize_sync(\n        &self,\n        state: &ClientSyncState,\n        new_server_mtime: TimestampMillis,\n    ) -> Result<()> {\n        self.storage.set_last_sync(new_server_mtime)?;\n        let mut usn = state.server_usn;\n        usn.0 += 1;\n        self.storage.set_usn(usn)?;\n        self.storage.set_modified_time(new_server_mtime)\n    }\n}\n\npub fn server_finish(col: &mut Collection) -> Result<TimestampMillis> {\n    let now = TimestampMillis::now();\n    col.storage.set_last_sync(now)?;\n    col.storage.increment_usn()?;\n    col.storage.commit_rust_trx()?;\n    col.storage.set_modified_time(now)?;\n    Ok(now)\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/graves.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::prelude::*;\nuse crate::sync::collection::chunks::CHUNK_SIZE;\nuse crate::sync::collection::start::ServerSyncState;\n\n#[derive(Serialize, Deserialize, Debug, Default, Clone)]\npub struct ApplyGravesRequest {\n    pub chunk: Graves,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default, Clone)]\npub struct Graves {\n    pub(crate) cards: Vec<CardId>,\n    pub(crate) decks: Vec<DeckId>,\n    pub(crate) notes: Vec<NoteId>,\n}\n\nimpl Graves {\n    pub(in crate::sync) fn take_chunk(&mut self) -> Option<Graves> {\n        let mut limit = CHUNK_SIZE;\n        let mut out = Graves::default();\n        while limit > 0 && !self.cards.is_empty() {\n            out.cards.push(self.cards.pop().unwrap());\n            limit -= 1;\n        }\n        while limit > 0 && !self.notes.is_empty() {\n            out.notes.push(self.notes.pop().unwrap());\n            limit -= 1;\n        }\n        while limit > 0 && !self.decks.is_empty() {\n            out.decks.push(self.decks.pop().unwrap());\n            limit -= 1;\n        }\n        if limit == CHUNK_SIZE {\n            None\n        } else {\n            Some(out)\n        }\n    }\n}\n\nimpl Collection {\n    pub fn apply_graves(&self, graves: Graves, latest_usn: Usn) -> Result<()> {\n        for nid in graves.notes {\n            self.storage.remove_note(nid)?;\n            self.storage.add_note_grave(nid, latest_usn)?;\n        }\n        for cid in graves.cards {\n            self.storage.remove_card(cid)?;\n            self.storage.add_card_grave(cid, latest_usn)?;\n        }\n        for did in graves.decks {\n            self.storage.remove_deck(did)?;\n            self.storage.add_deck_grave(did, latest_usn)?;\n        }\n        Ok(())\n    }\n}\n\npub fn server_apply_graves(\n    req: ApplyGravesRequest,\n    col: &mut Collection,\n    state: &mut ServerSyncState,\n) -> Result<()> {\n    col.apply_graves(req.chunk, state.server_usn)\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/meta.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse ammonia::Url;\nuse anki_io::metadata;\nuse axum::http::StatusCode;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse tracing::debug;\nuse tracing::info;\n\nuse crate::config::SchedulerVersion;\nuse crate::prelude::*;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::SyncActionRequired;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::error::HttpError;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\nuse crate::sync::version::SYNC_VERSION_09_V2_SCHEDULER;\nuse crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE;\nuse crate::sync::version::SYNC_VERSION_MAX;\nuse crate::sync::version::SYNC_VERSION_MIN;\nuse crate::version::sync_client_version;\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct SyncMeta {\n    #[serde(rename = \"mod\")]\n    pub modified: TimestampMillis,\n    #[serde(rename = \"scm\")]\n    pub schema: TimestampMillis,\n    pub usn: Usn,\n    #[serde(rename = \"ts\")]\n    pub current_time: TimestampSecs,\n    #[serde(rename = \"msg\")]\n    pub server_message: String,\n    #[serde(rename = \"cont\")]\n    pub should_continue: bool,\n    /// Used by clients prior to sync version 11\n    #[serde(rename = \"hostNum\")]\n    pub host_number: u32,\n    #[serde(default)]\n    pub empty: bool,\n    /// This field is not set by col.sync_meta(), and must be filled in\n    /// separately.\n    pub media_usn: Usn,\n    #[serde(skip)]\n    pub v2_scheduler_or_later: bool,\n    #[serde(skip)]\n    pub v2_timezone: bool,\n    #[serde(skip)]\n    pub collection_bytes: u64,\n}\n\nimpl SyncMeta {\n    pub(in crate::sync) fn compared_to_remote(\n        &self,\n        remote: SyncMeta,\n        new_endpoint: Option<String>,\n    ) -> ClientSyncState {\n        let local = self;\n        let required = if remote.modified == local.modified {\n            SyncActionRequired::NoChanges\n        } else if remote.schema != local.schema {\n            let upload_ok = !local.empty || remote.empty;\n            let download_ok = !remote.empty || local.empty;\n            SyncActionRequired::FullSyncRequired {\n                upload_ok,\n                download_ok,\n            }\n        } else {\n            SyncActionRequired::NormalSyncRequired\n        };\n\n        ClientSyncState {\n            required,\n            local_is_newer: local.modified > remote.modified,\n            usn_at_last_sync: local.usn,\n            server_usn: remote.usn,\n            pending_usn: Usn(-1),\n            server_message: remote.server_message,\n            host_number: remote.host_number,\n            new_endpoint,\n            server_media_usn: remote.media_usn,\n        }\n    }\n}\n\nimpl HttpSyncClient {\n    /// Fetch server meta. Returns a new endpoint if one was provided.\n    pub(in crate::sync) async fn meta_with_redirect(\n        &mut self,\n    ) -> Result<(SyncMeta, Option<String>)> {\n        let mut new_endpoint = None;\n        let response = match self.meta(MetaRequest::request()).await {\n            Ok(remote) => remote,\n            Err(HttpError {\n                code: StatusCode::PERMANENT_REDIRECT,\n                context,\n                ..\n            }) => {\n                debug!(endpoint = context, \"redirect to new location\");\n                let url = Url::try_from(context.as_str())\n                    .or_bad_request(\"couldn't parse new location\")?;\n                new_endpoint = Some(context);\n                self.endpoint = url;\n                self.meta(MetaRequest::request()).await?\n            }\n            err => err?,\n        };\n        let remote = response.json()?;\n        Ok((remote, new_endpoint))\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MetaRequest {\n    #[serde(rename = \"v\")]\n    pub sync_version: u8,\n    #[serde(rename = \"cv\")]\n    pub client_version: String,\n}\n\nimpl Collection {\n    pub fn sync_meta(&self) -> Result<SyncMeta> {\n        let stamps = self.storage.get_collection_timestamps()?;\n        let collection_bytes = metadata(&self.col_path)?.len();\n        Ok(SyncMeta {\n            modified: stamps.collection_change,\n            schema: stamps.schema_change,\n            // server=true is used for the client case as well, as we\n            // want the actual usn and not -1\n            usn: self.storage.usn(true)?,\n            current_time: TimestampSecs::now(),\n            server_message: \"\".into(),\n            should_continue: true,\n            host_number: 0,\n            empty: !self.storage.have_at_least_one_card()?,\n            v2_scheduler_or_later: self.scheduler_version() == SchedulerVersion::V2,\n            v2_timezone: self.get_creation_utc_offset().is_some(),\n            collection_bytes,\n            // must be filled in by calling code\n            media_usn: Usn(0),\n        })\n    }\n}\n\npub fn server_meta(req: MetaRequest, col: &mut Collection) -> HttpResult<SyncMeta> {\n    if !matches!(req.sync_version, SYNC_VERSION_MIN..=SYNC_VERSION_MAX) {\n        return Err(HttpError {\n            // old clients expected this code\n            code: StatusCode::NOT_IMPLEMENTED,\n            context: \"unsupported version\".into(),\n            source: None,\n        });\n    }\n    let mut meta = col.sync_meta().or_internal_err(\"sync meta\")?;\n    if meta.collection_bytes > *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED {\n        info!(\"collection is too large, forcing one-way sync\");\n        meta.schema = TimestampMillis::now();\n    }\n    if meta.v2_scheduler_or_later && req.sync_version < SYNC_VERSION_09_V2_SCHEDULER {\n        meta.server_message = \"Your client does not support the v2 scheduler\".into();\n        meta.should_continue = false;\n    } else if meta.v2_timezone && req.sync_version < SYNC_VERSION_10_V2_TIMEZONE {\n        meta.server_message = \"Your client does not support the new timezone handling.\".into();\n        meta.should_continue = false;\n    }\n    Ok(meta)\n}\n\nimpl MetaRequest {\n    pub fn request() -> SyncRequest<Self> {\n        MetaRequest {\n            sync_version: SYNC_VERSION_MAX,\n            client_version: sync_client_version().into(),\n        }\n        .try_into_sync_request()\n        .expect(\"infallible meta request\")\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod changes;\npub mod chunks;\npub mod download;\npub mod finish;\npub mod graves;\npub mod meta;\npub mod normal;\npub mod progress;\npub mod protocol;\npub mod sanity;\npub mod start;\npub mod status;\npub mod tests;\npub mod upload;\n"
  },
  {
    "path": "rslib/src/sync/collection/normal.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse reqwest::Client;\nuse tracing::debug;\n\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::SyncError;\nuse crate::error::SyncErrorKind;\nuse crate::prelude::Usn;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::collection::progress::SyncStage;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::status::online_sync_status_check;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::SyncAuth;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\n\npub struct NormalSyncer<'a> {\n    pub(in crate::sync) col: &'a mut Collection,\n    pub(in crate::sync) server: HttpSyncClient,\n    pub(in crate::sync) progress: ThrottlingProgressHandler<NormalSyncProgress>,\n}\n\n#[derive(Default, Debug, Clone, Copy)]\npub struct NormalSyncProgress {\n    pub stage: SyncStage,\n    pub local_update: usize,\n    pub local_remove: usize,\n    pub remote_update: usize,\n    pub remote_remove: usize,\n}\n\n#[derive(PartialEq, Eq, Debug, Clone, Copy)]\npub enum SyncActionRequired {\n    NoChanges,\n    FullSyncRequired { upload_ok: bool, download_ok: bool },\n    NormalSyncRequired,\n}\n\n#[derive(Debug)]\npub struct ClientSyncState {\n    pub required: SyncActionRequired,\n    pub server_message: String,\n    pub host_number: u32,\n    pub new_endpoint: Option<String>,\n\n    pub(in crate::sync) local_is_newer: bool,\n    pub(in crate::sync) usn_at_last_sync: Usn,\n    // latest server usn; local -1 entries will be rewritten to this\n    pub(in crate::sync) server_usn: Usn,\n    // -1 in client case; used to locate pending entries\n    pub(in crate::sync) pending_usn: Usn,\n    pub(in crate::sync) server_media_usn: Usn,\n}\n\nimpl NormalSyncer<'_> {\n    pub fn new(col: &mut Collection, server: HttpSyncClient) -> NormalSyncer<'_> {\n        NormalSyncer {\n            progress: col.new_progress_handler(),\n            col,\n            server,\n        }\n    }\n\n    pub async fn sync(&mut self) -> error::Result<SyncOutput> {\n        debug!(\"fetching meta...\");\n        let local = self.col.sync_meta()?;\n        let local_bytes = local.collection_bytes;\n        let limit = *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\n        if self.server.endpoint.as_str().contains(\"ankiweb\") && local.collection_bytes > limit {\n            return Err(AnkiError::sync_error(\n                format!(\"{local_bytes} > {limit}\"),\n                SyncErrorKind::UploadTooLarge,\n            ));\n        }\n        let state = online_sync_status_check(local, &mut self.server).await?;\n        debug!(?state, \"fetched\");\n        match state.required {\n            SyncActionRequired::NoChanges => Ok(state.into()),\n            SyncActionRequired::FullSyncRequired { .. } => Ok(state.into()),\n            SyncActionRequired::NormalSyncRequired => {\n                self.col.discard_undo_and_study_queues();\n                let timing = self.col.timing_today()?;\n                self.col.unbury_if_day_rolled_over(timing)?;\n                self.col.storage.begin_trx()?;\n                match self.normal_sync_inner(state).await {\n                    Ok(success) => {\n                        self.col.storage.commit_trx()?;\n                        Ok(success)\n                    }\n                    Err(e) => {\n                        self.col.storage.rollback_trx()?;\n\n                        let _ = self.server.abort(EmptyInput::request()).await;\n\n                        if let AnkiError::SyncError {\n                            source:\n                                SyncError {\n                                    kind: SyncErrorKind::SanityCheckFailed { client, server },\n                                    ..\n                                },\n                        } = &e\n                        {\n                            debug!(client_counts=?client, server_counts=?server, \"sanity check failed\");\n                            self.col.set_schema_modified()?;\n                        }\n\n                        Err(e)\n                    }\n                }\n            }\n        }\n    }\n\n    /// Sync. Caller must have created a transaction, and should call\n    /// abort on failure.\n    async fn normal_sync_inner(&mut self, mut state: ClientSyncState) -> error::Result<SyncOutput> {\n        self.progress\n            .update(false, |p| p.stage = SyncStage::Syncing)?;\n\n        debug!(\"start\");\n        self.start_and_process_deletions(&state).await?;\n        debug!(\"unchunked changes\");\n        self.process_unchunked_changes(&state).await?;\n        debug!(\"begin stream from server\");\n        self.process_chunks_from_server(&state).await?;\n        debug!(\"begin stream to server\");\n        self.send_chunks_to_server(&state).await?;\n\n        self.progress\n            .update(false, |p| p.stage = SyncStage::Finalizing)?;\n\n        debug!(\"sanity check\");\n        self.sanity_check().await?;\n        debug!(\"finalize\");\n        self.finalize(&state).await?;\n        state.required = SyncActionRequired::NoChanges;\n        Ok(state.into())\n    }\n}\n\n#[derive(Debug)]\npub struct SyncOutput {\n    pub required: SyncActionRequired,\n    pub server_message: String,\n    pub host_number: u32,\n    pub new_endpoint: Option<String>,\n    #[allow(unused)]\n    pub(crate) server_media_usn: Usn,\n}\n\nimpl From<ClientSyncState> for SyncOutput {\n    fn from(s: ClientSyncState) -> Self {\n        SyncOutput {\n            required: s.required,\n            server_message: s.server_message,\n            host_number: s.host_number,\n            new_endpoint: s.new_endpoint,\n            server_media_usn: s.server_media_usn,\n        }\n    }\n}\n\nimpl Collection {\n    pub async fn normal_sync(\n        &mut self,\n        auth: SyncAuth,\n        client: Client,\n    ) -> error::Result<SyncOutput> {\n        NormalSyncer::new(self, HttpSyncClient::new(auth, client))\n            .sync()\n            .await\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/progress.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse reqwest::Client;\n\nuse crate::error;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::SyncAuth;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum SyncStage {\n    #[default]\n    Connecting,\n    Syncing,\n    Finalizing,\n}\n\n#[derive(Debug, Default, Clone, Copy)]\npub struct FullSyncProgress {\n    pub transferred_bytes: usize,\n    pub total_bytes: usize,\n}\n\npub async fn sync_abort(auth: SyncAuth, client: Client) -> error::Result<()> {\n    HttpSyncClient::new(auth, client)\n        .abort(EmptyInput::request())\n        .await?\n        .json()\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/protocol.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::marker::PhantomData;\n\nuse ammonia::Url;\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse strum::IntoStaticStr;\n\nuse crate::prelude::TimestampMillis;\nuse crate::sync::collection::changes::ApplyChangesRequest;\nuse crate::sync::collection::changes::UnchunkedChanges;\nuse crate::sync::collection::chunks::ApplyChunkRequest;\nuse crate::sync::collection::chunks::Chunk;\nuse crate::sync::collection::graves::ApplyGravesRequest;\nuse crate::sync::collection::graves::Graves;\nuse crate::sync::collection::meta::MetaRequest;\nuse crate::sync::collection::meta::SyncMeta;\nuse crate::sync::collection::sanity::SanityCheckRequest;\nuse crate::sync::collection::sanity::SanityCheckResponse;\nuse crate::sync::collection::start::StartRequest;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::error::HttpResult;\nuse crate::sync::login::HostKeyRequest;\nuse crate::sync::login::HostKeyResponse;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\n#[derive(IntoStaticStr, Deserialize, PartialEq, Eq, Debug)]\n#[serde(rename_all = \"camelCase\")]\n#[strum(serialize_all = \"camelCase\")]\npub enum SyncMethod {\n    HostKey,\n    Meta,\n    Start,\n    ApplyGraves,\n    ApplyChanges,\n    Chunk,\n    ApplyChunk,\n    SanityCheck2,\n    Finish,\n    Abort,\n    Upload,\n    Download,\n}\n\npub trait AsSyncEndpoint: Into<&'static str> {\n    fn as_sync_endpoint(&self, base: &Url) -> Url;\n}\n\nimpl AsSyncEndpoint for SyncMethod {\n    fn as_sync_endpoint(&self, base: &Url) -> Url {\n        base.join(\"sync/\").unwrap().join(self.into()).unwrap()\n    }\n}\n\n#[async_trait]\npub trait SyncProtocol: Send + Sync + 'static {\n    async fn host_key(\n        &self,\n        req: SyncRequest<HostKeyRequest>,\n    ) -> HttpResult<SyncResponse<HostKeyResponse>>;\n    async fn meta(&self, req: SyncRequest<MetaRequest>) -> HttpResult<SyncResponse<SyncMeta>>;\n    async fn start(&self, req: SyncRequest<StartRequest>) -> HttpResult<SyncResponse<Graves>>;\n    async fn apply_graves(\n        &self,\n        req: SyncRequest<ApplyGravesRequest>,\n    ) -> HttpResult<SyncResponse<()>>;\n    async fn apply_changes(\n        &self,\n        req: SyncRequest<ApplyChangesRequest>,\n    ) -> HttpResult<SyncResponse<UnchunkedChanges>>;\n    async fn chunk(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Chunk>>;\n    async fn apply_chunk(\n        &self,\n        req: SyncRequest<ApplyChunkRequest>,\n    ) -> HttpResult<SyncResponse<()>>;\n    async fn sanity_check(\n        &self,\n        req: SyncRequest<SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<SanityCheckResponse>>;\n    async fn finish(\n        &self,\n        req: SyncRequest<EmptyInput>,\n    ) -> HttpResult<SyncResponse<TimestampMillis>>;\n    async fn abort(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<()>>;\n    async fn upload(&self, req: SyncRequest<Vec<u8>>) -> HttpResult<SyncResponse<UploadResponse>>;\n    async fn download(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Vec<u8>>>;\n}\n\n/// The sync protocol expects '{}' to be sent in requests without args.\n/// Serde serializes/deserializes empty structs as 'null', so we add an empty\n/// value to cause it to produce a map instead. This only applies to inputs;\n/// empty outputs are returned as ()/null.\n#[derive(Serialize, Deserialize, Default)]\n#[serde(deny_unknown_fields)]\npub struct EmptyInput {\n    #[serde(default)]\n    _pad: PhantomData<()>,\n}\n\nimpl EmptyInput {\n    pub(crate) fn request() -> SyncRequest<Self> {\n        Self::default()\n            .try_into_sync_request()\n            // should be infallible\n            .expect(\"empty input into request\")\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/sanity.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_tuple::Serialize_tuple;\nuse tracing::debug;\nuse tracing::info;\n\nuse crate::error::SyncErrorKind;\nuse crate::prelude::*;\nuse crate::serde::default_on_invalid;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::request::IntoSyncRequest;\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SanityCheckResponse {\n    pub status: SanityCheckStatus,\n    #[serde(rename = \"c\", default, deserialize_with = \"default_on_invalid\")]\n    pub client: Option<SanityCheckCounts>,\n    #[serde(rename = \"s\", default, deserialize_with = \"default_on_invalid\")]\n    pub server: Option<SanityCheckCounts>,\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]\n#[serde(rename_all = \"lowercase\")]\npub enum SanityCheckStatus {\n    Ok,\n    Bad,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Eq)]\npub struct SanityCheckCounts {\n    pub counts: SanityCheckDueCounts,\n    pub cards: u32,\n    pub notes: u32,\n    pub revlog: u32,\n    pub graves: u32,\n    #[serde(rename = \"models\")]\n    pub notetypes: u32,\n    pub decks: u32,\n    pub deck_config: u32,\n}\n\n#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq)]\npub struct SanityCheckDueCounts {\n    pub new: u32,\n    pub learn: u32,\n    pub review: u32,\n}\n\nimpl NormalSyncer<'_> {\n    /// Caller should force full sync after rolling back.\n    pub(in crate::sync) async fn sanity_check(&mut self) -> Result<()> {\n        let local_counts = self.col.storage.sanity_check_info()?;\n\n        debug!(\"gathered local counts; waiting for server reply\");\n        let SanityCheckResponse {\n            status,\n            client,\n            server,\n        } = self\n            .server\n            .sanity_check(\n                SanityCheckRequest {\n                    client: local_counts,\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json()?;\n        debug!(\"got server reply\");\n        if status != SanityCheckStatus::Ok {\n            Err(AnkiError::sync_error(\n                \"\",\n                SyncErrorKind::SanityCheckFailed { client, server },\n            ))\n        } else {\n            Ok(())\n        }\n    }\n}\n\npub fn server_sanity_check(\n    SanityCheckRequest { mut client }: SanityCheckRequest,\n    col: &mut Collection,\n) -> Result<SanityCheckResponse> {\n    let mut server = match col.storage.sanity_check_info() {\n        Ok(info) => info,\n        Err(err) => {\n            info!(client_counts=?client, ?err, \"sanity check failed\");\n            return Ok(SanityCheckResponse {\n                status: SanityCheckStatus::Bad,\n                client: Some(client),\n                server: None,\n            });\n        }\n    };\n\n    client.counts = Default::default();\n    // clients on schema 17 and below may send duplicate\n    // deletion markers, so we can't compare graves until\n    // the minimum syncing version is schema 18.\n    client.graves = 0;\n    server.graves = 0;\n    Ok(SanityCheckResponse {\n        status: if client == server {\n            SanityCheckStatus::Ok\n        } else {\n            info!(client_counts=?client, server_counts=?server, \"sanity check failed\");\n            SanityCheckStatus::Bad\n        },\n        client: Some(client),\n        server: Some(server),\n    })\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SanityCheckRequest {\n    pub client: SanityCheckCounts,\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/start.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Deserializer;\nuse serde::Serialize;\nuse tracing::debug;\n\nuse crate::prelude::*;\nuse crate::sync::collection::chunks::ChunkableIds;\nuse crate::sync::collection::graves::ApplyGravesRequest;\nuse crate::sync::collection::graves::Graves;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::request::IntoSyncRequest;\n\nimpl NormalSyncer<'_> {\n    pub(in crate::sync) async fn start_and_process_deletions(\n        &mut self,\n        state: &ClientSyncState,\n    ) -> Result<()> {\n        let remote: Graves = self\n            .server\n            .start(\n                StartRequest {\n                    client_usn: state.usn_at_last_sync,\n                    local_is_newer: state.local_is_newer,\n                    deprecated_client_graves: None,\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json()?;\n\n        debug!(\n            cards = remote.cards.len(),\n            notes = remote.notes.len(),\n            decks = remote.decks.len(),\n            \"removed on remote\"\n        );\n\n        let mut local = self.col.storage.pending_graves(state.pending_usn)?;\n        self.col\n            .storage\n            .update_pending_grave_usns(state.server_usn)?;\n\n        debug!(\n            cards = local.cards.len(),\n            notes = local.notes.len(),\n            decks = local.decks.len(),\n            \"locally removed  \"\n        );\n\n        while let Some(chunk) = local.take_chunk() {\n            debug!(\"sending graves chunk\");\n            self.progress.update(false, |p| {\n                p.local_remove += chunk.cards.len() + chunk.notes.len() + chunk.decks.len()\n            })?;\n            self.server\n                .apply_graves(ApplyGravesRequest { chunk }.try_into_sync_request()?)\n                .await?;\n            self.progress.check_cancelled()?;\n        }\n\n        self.progress.update(false, |p| {\n            p.remote_remove = remote.cards.len() + remote.notes.len() + remote.decks.len()\n        })?;\n        self.col.apply_graves(remote, state.server_usn)?;\n        self.progress.check_cancelled()?;\n        debug!(\"applied server graves\");\n\n        Ok(())\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct StartRequest {\n    #[serde(rename = \"minUsn\")]\n    pub client_usn: Usn,\n    #[serde(rename = \"lnewer\")]\n    pub local_is_newer: bool,\n    /// Used by old clients, and still used by AnkiDroid.\n    #[serde(rename = \"graves\", default, deserialize_with = \"legacy_graves\")]\n    pub deprecated_client_graves: Option<Graves>,\n}\n\npub fn server_start(\n    req: StartRequest,\n    col: &mut Collection,\n    state: &mut ServerSyncState,\n) -> Result<Graves> {\n    state.server_usn = col.usn()?;\n    state.client_usn = req.client_usn;\n    state.client_is_newer = req.local_is_newer;\n\n    col.discard_undo_and_study_queues();\n    col.storage.begin_rust_trx()?;\n\n    // make sure any pending cards have been unburied first if necessary\n    let timing = col.timing_today()?;\n    col.unbury_if_day_rolled_over(timing)?;\n\n    // fetch local graves\n    let server_graves = col.storage.pending_graves(state.client_usn)?;\n    // handle AnkiDroid using old protocol\n    if let Some(graves) = req.deprecated_client_graves {\n        col.apply_graves(graves, state.server_usn)?;\n    }\n\n    Ok(server_graves)\n}\n\n/// The current sync protocol is stateful, so unfortunately we need to\n/// retain a bunch of information across requests. These are set either\n/// on start, or on subsequent methods.\npub struct ServerSyncState {\n    /// The session key. This is sent on every http request, but is ignored for\n    /// methods where there is not active sync state.\n    pub skey: String,\n\n    pub(in crate::sync) server_usn: Usn,\n    pub(in crate::sync) client_usn: Usn,\n    /// Only used to determine whether we should send our\n    /// config to client.\n    pub(in crate::sync) client_is_newer: bool,\n    /// Set on the first call to chunk()\n    pub(in crate::sync) server_chunk_ids: Option<ChunkableIds>,\n}\n\nimpl ServerSyncState {\n    pub fn new(skey: impl Into<String>) -> Self {\n        Self {\n            skey: skey.into(),\n            server_usn: Default::default(),\n            client_usn: Default::default(),\n            client_is_newer: false,\n            server_chunk_ids: None,\n        }\n    }\n}\n\npub(crate) fn legacy_graves<'de, D>(deserializer: D) -> Result<Option<Graves>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    #[derive(Deserialize)]\n    #[serde(untagged)]\n    enum GraveType {\n        Normal(Graves),\n        Legacy(StringGraves),\n        Null,\n    }\n    match GraveType::deserialize(deserializer)? {\n        GraveType::Normal(normal) => Ok(Some(normal)),\n        GraveType::Legacy(stringly) => Ok(Some(Graves {\n            cards: string_list_to_ids(stringly.cards)?,\n            decks: string_list_to_ids(stringly.decks)?,\n            notes: string_list_to_ids(stringly.notes)?,\n        })),\n        GraveType::Null => Ok(None),\n    }\n}\n\n// old AnkiMobile versions\n#[derive(Deserialize)]\nstruct StringGraves {\n    cards: Vec<String>,\n    decks: Vec<String>,\n    notes: Vec<String>,\n}\n\nfn string_list_to_ids<T, E>(list: Vec<String>) -> Result<Vec<T>, E>\nwhere\n    T: From<i64>,\n    E: serde::de::Error,\n{\n    list.into_iter()\n        .map(|s| {\n            s.parse::<i64>()\n                .map_err(serde::de::Error::custom)\n                .map(Into::into)\n        })\n        .collect::<Result<Vec<T>, E>>()\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/status.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse anki_proto::sync::sync_status_response;\nuse tracing::debug;\n\nuse crate::error::SyncErrorKind;\nuse crate::prelude::*;\nuse crate::sync::collection::meta::SyncMeta;\nuse crate::sync::collection::normal::ClientSyncState;\nuse crate::sync::http_client::HttpSyncClient;\n\nimpl Collection {\n    /// Checks local collection only. If local collection is clean but changes\n    /// are pending on AnkiWeb, NoChanges will be returned.\n    pub fn sync_status_offline(&mut self) -> Result<sync_status_response::Required> {\n        let stamps = self.storage.get_collection_timestamps()?;\n        let required = if stamps.schema_changed_since_sync() {\n            sync_status_response::Required::FullSync\n        } else if stamps.collection_changed_since_sync() {\n            sync_status_response::Required::NormalSync\n        } else {\n            sync_status_response::Required::NoChanges\n        };\n\n        Ok(required)\n    }\n}\n\n/// Should be called if a call to sync_status_offline() returns NoChanges, to\n/// check if AnkiWeb has pending changes. Caller should persist new endpoint if\n/// returned.\n///\n/// This routine is outside of the collection, as we don't want to block\n/// collection access for a potentially slow network request that happens in the\n/// background.\npub async fn online_sync_status_check(\n    local: SyncMeta,\n    server: &mut HttpSyncClient,\n) -> Result<ClientSyncState, AnkiError> {\n    let (remote, new_endpoint) = server.meta_with_redirect().await?;\n    debug!(?remote, \"meta\");\n    debug!(?local, \"meta\");\n    if !remote.should_continue {\n        debug!(remote.server_message, \"server says abort\");\n        return Err(AnkiError::sync_error(\n            remote.server_message,\n            SyncErrorKind::ServerMessage,\n        ));\n    }\n    let delta = remote.current_time.0 - local.current_time.0;\n    if delta.abs() > 300 {\n        debug!(delta, \"clock off\");\n        return Err(AnkiError::sync_error(\"\", SyncErrorKind::ClockIncorrect));\n    }\n    Ok(local.compared_to_remote(remote, new_endpoint))\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/tests.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n\nuse std::future::Future;\nuse std::sync::LazyLock;\n\nuse axum::http::StatusCode;\nuse reqwest::Client;\nuse reqwest::Url;\nuse serde_json::json;\nuse tempfile::tempdir;\nuse tempfile::TempDir;\nuse tokio::sync::Mutex;\nuse tokio::sync::MutexGuard;\nuse tracing::Instrument;\nuse tracing::Span;\nuse wiremock::matchers::method;\nuse wiremock::matchers::path;\nuse wiremock::Mock;\nuse wiremock::MockServer;\nuse wiremock::ResponseTemplate;\n\nuse crate::card::CardQueue;\nuse crate::collection::CollectionBuilder;\nuse crate::deckconfig::DeckConfig;\nuse crate::decks::DeckKind;\nuse crate::error::SyncError;\nuse crate::error::SyncErrorKind;\nuse crate::log::set_global_logger;\nuse crate::notetype::all_stock_notetypes;\nuse crate::prelude::*;\nuse crate::revlog::RevlogEntry;\nuse crate::search::SortMode;\nuse crate::sync::collection::graves::ApplyGravesRequest;\nuse crate::sync::collection::meta::MetaRequest;\nuse crate::sync::collection::normal::NormalSyncer;\nuse crate::sync::collection::normal::SyncActionRequired;\nuse crate::sync::collection::normal::SyncOutput;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::start::StartRequest;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::collection::upload::CORRUPT_MESSAGE;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::http_server::default_ip_header;\nuse crate::sync::http_server::SimpleServer;\nuse crate::sync::http_server::SyncServerConfig;\nuse crate::sync::login::HostKeyRequest;\nuse crate::sync::login::SyncAuth;\nuse crate::sync::request::IntoSyncRequest;\n\nstruct TestAuth {\n    username: String,\n    password: String,\n    host_key: String,\n}\n\nstatic AUTH: LazyLock<TestAuth> = LazyLock::new(|| {\n    if let Ok(auth) = std::env::var(\"TEST_AUTH\") {\n        let mut auth = auth.split(':');\n        TestAuth {\n            username: auth.next().unwrap().into(),\n            password: auth.next().unwrap().into(),\n            host_key: auth.next().unwrap().into(),\n        }\n    } else {\n        TestAuth {\n            username: \"user\".to_string(),\n            password: \"pass\".to_string(),\n            host_key: \"b2619aa1529dfdc4248e6edbf3c1b2a2b014cf6d\".to_string(),\n        }\n    }\n});\n\npub(in crate::sync) async fn with_active_server<F, O>(op: F) -> Result<()>\nwhere\n    F: FnOnce(HttpSyncClient) -> O,\n    O: Future<Output = Result<()>>,\n{\n    let _ = set_global_logger(None);\n    // start server\n    let base_folder = tempdir()?;\n    std::env::set_var(\"SYNC_USER1\", \"user:pass\");\n    let (addr, server_fut) = SimpleServer::make_server(SyncServerConfig {\n        host: \"127.0.0.1\".parse().unwrap(),\n        port: 0,\n        base_folder: base_folder.path().into(),\n        ip_header: default_ip_header(),\n    })\n    .await\n    .unwrap();\n    tokio::spawn(server_fut.instrument(Span::current()));\n    // when not using ephemeral servers, tests need to be serialized\n    static LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));\n    let _lock: MutexGuard<()>;\n    // setup client to connect to it\n    let endpoint = if let Ok(endpoint) = std::env::var(\"TEST_ENDPOINT\") {\n        _lock = LOCK.lock().await;\n        endpoint\n    } else {\n        format!(\"http://{addr}/\")\n    };\n    let endpoint = Url::try_from(endpoint.as_str()).unwrap();\n    let auth = SyncAuth {\n        hkey: AUTH.host_key.clone(),\n        endpoint: Some(endpoint),\n        io_timeout_secs: None,\n    };\n    let client = HttpSyncClient::new(auth, Client::new());\n    op(client).await\n}\n\nfn unwrap_sync_err_kind(err: AnkiError) -> SyncErrorKind {\n    let AnkiError::SyncError {\n        source: SyncError { kind, .. },\n    } = err\n    else {\n        panic!(\"not sync err: {err:?}\");\n    };\n    kind\n}\n\n#[tokio::test]\nasync fn host_key() -> Result<()> {\n    with_active_server(|mut client| async move {\n        let err = client\n            .host_key(\n                HostKeyRequest {\n                    username: \"bad\".to_string(),\n                    password: \"bad\".to_string(),\n                }\n                .try_into_sync_request()?,\n            )\n            .await\n            .unwrap_err();\n        assert_eq!(err.code, StatusCode::FORBIDDEN);\n        assert_eq!(\n            unwrap_sync_err_kind(AnkiError::from(err)),\n            SyncErrorKind::AuthFailed\n        );\n        // hkey should be automatically set after successful login\n        client.sync_key = String::new();\n        let resp = client\n            .host_key(\n                HostKeyRequest {\n                    username: AUTH.username.clone(),\n                    password: AUTH.password.clone(),\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json()?;\n        assert_eq!(resp.key, *AUTH.host_key);\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn meta() -> Result<()> {\n    with_active_server(|client| async move {\n        // unsupported sync version\n        assert_eq!(\n            SyncProtocol::meta(\n                &client,\n                MetaRequest {\n                    sync_version: 0,\n                    client_version: \"\".to_string(),\n                }\n                .try_into_sync_request()?,\n            )\n            .await\n            .unwrap_err()\n            .code,\n            StatusCode::NOT_IMPLEMENTED\n        );\n\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn aborting_is_idempotent() -> Result<()> {\n    with_active_server(|mut client| async move {\n        // abort is a no-op if no sync in progress\n        client.abort(EmptyInput::request()).await?;\n\n        // start a sync\n        let _graves = client\n            .start(\n                StartRequest {\n                    client_usn: Default::default(),\n                    local_is_newer: false,\n                    deprecated_client_graves: None,\n                }\n                .try_into_sync_request()?,\n            )\n            .await?;\n\n        // an abort request with the wrong key is ignored\n        let orig_key = client.skey().to_string();\n        client.set_skey(\"aabbccdd\".into());\n        client.abort(EmptyInput::request()).await?;\n\n        // it should succeed with the correct key\n        client.set_skey(orig_key);\n        client.abort(EmptyInput::request()).await?;\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn new_syncs_cancel_old_ones() -> Result<()> {\n    with_active_server(|mut client| async move {\n        let ctx = SyncTestContext::new(client.clone());\n\n        // start a sync\n        let req = StartRequest {\n            client_usn: Default::default(),\n            local_is_newer: false,\n            deprecated_client_graves: None,\n        }\n        .try_into_sync_request()?;\n        let _ = client.start(req.clone()).await?;\n\n        // a new sync aborts the previous one\n        let orig_key = client.skey().to_string();\n        client.set_skey(\"1\".into());\n        let _ = client.start(req.clone()).await?;\n\n        // old sync can no longer proceed\n        client.set_skey(orig_key);\n        let graves_req = ApplyGravesRequest::default().try_into_sync_request()?;\n        assert_eq!(\n            client\n                .apply_graves(graves_req.clone())\n                .await\n                .unwrap_err()\n                .code,\n            StatusCode::CONFLICT\n        );\n\n        // with the correct key, it can continue\n        client.set_skey(\"1\".into());\n        client.apply_graves(graves_req.clone()).await?;\n        // but a full upload will break the lock\n        ctx.full_upload(ctx.col1()).await;\n        assert_eq!(\n            client\n                .apply_graves(graves_req.clone())\n                .await\n                .unwrap_err()\n                .code,\n            StatusCode::CONFLICT\n        );\n\n        // likewise with download\n        let _ = client.start(req.clone()).await?;\n        ctx.full_download(ctx.col1()).await;\n        assert_eq!(\n            client\n                .apply_graves(graves_req.clone())\n                .await\n                .unwrap_err()\n                .code,\n            StatusCode::CONFLICT\n        );\n\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn sync_roundtrip() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client);\n        upload_download(&ctx).await?;\n        regular_sync(&ctx).await?;\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn sanity_check_should_roll_back_and_force_full_sync() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client);\n        upload_download(&ctx).await?;\n\n        let mut col1 = ctx.col1();\n\n        // add a deck but don't mark it as requiring a sync, which will trigger the\n        // sanity check to fail\n        let mut deck = col1.get_or_create_normal_deck(\"unsynced deck\")?;\n        col1.add_or_update_deck(&mut deck)?;\n        col1.storage\n            .db\n            .execute(\"update decks set usn=0 where id=?\", [deck.id])?;\n\n        // the sync should fail\n        let err = NormalSyncer::new(&mut col1, ctx.cloned_client())\n            .sync()\n            .await\n            .unwrap_err();\n        assert!(matches!(\n            err,\n            AnkiError::SyncError {\n                source: SyncError {\n                    kind: SyncErrorKind::SanityCheckFailed { .. },\n                    ..\n                }\n            }\n        ));\n\n        // the server should have rolled back\n        let mut col2 = ctx.col2();\n        let out = ctx.normal_sync(&mut col2).await;\n        assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n        // and the client should have forced a one-way sync\n        let out = ctx.normal_sync(&mut col1).await;\n        assert_eq!(\n            out.required,\n            SyncActionRequired::FullSyncRequired {\n                upload_ok: true,\n                download_ok: true,\n            }\n        );\n\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn sync_errors_should_prompt_db_check() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client);\n        upload_download(&ctx).await?;\n\n        let mut col1 = ctx.col1();\n\n        // Add a a new notetype, and a note that uses it, but don't mark the notetype as\n        // requiring a sync, which will cause the sync to fail as the note is added.\n        let mut nt = all_stock_notetypes(&col1.tr).remove(0);\n        nt.name = \"new\".into();\n        col1.add_notetype(&mut nt, false)?;\n        let mut note = nt.new_note();\n        note.set_field(0, \"test\")?;\n        col1.add_note(&mut note, DeckId(1))?;\n        col1.storage.db.execute(\"update notetypes set usn=0\", [])?;\n\n        // the sync should fail\n        let err = NormalSyncer::new(&mut col1, ctx.cloned_client())\n            .sync()\n            .await\n            .unwrap_err();\n        let AnkiError::SyncError {\n            source: SyncError { info: _, kind },\n        } = err\n        else {\n            panic!()\n        };\n        assert_eq!(kind, SyncErrorKind::DatabaseCheckRequired);\n\n        // the server should have rolled back\n        let mut col2 = ctx.col2();\n        let out = ctx.normal_sync(&mut col2).await;\n        assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n        // and the client should be able to sync again without a forced one-way sync\n        let err = NormalSyncer::new(&mut col1, ctx.cloned_client())\n            .sync()\n            .await\n            .unwrap_err();\n        let AnkiError::SyncError {\n            source: SyncError { info: _, kind },\n        } = err\n        else {\n            panic!()\n        };\n        assert_eq!(kind, SyncErrorKind::DatabaseCheckRequired);\n\n        Ok(())\n    })\n    .await\n}\n\n/// Old AnkiMobile versions sent grave ids as strings\n#[tokio::test]\nasync fn string_grave_ids_are_handled() -> Result<()> {\n    with_active_server(|client| async move {\n        let req = json!({\n            \"minUsn\": 0,\n            \"lnewer\": false,\n            \"graves\": {\n                \"cards\": vec![\"1\"],\n                \"decks\": vec![\"2\", \"3\"],\n                \"notes\": vec![\"4\"],\n            }\n        });\n        let req = serde_json::to_vec(&req)\n            .unwrap()\n            .try_into_sync_request()\n            .unwrap();\n        // should not return err 400\n        client.start(req.into_output_type()).await.unwrap();\n        client.abort(EmptyInput::request()).await?;\n        Ok(())\n    })\n    .await?;\n    // a missing value should be handled\n    with_active_server(|client| async move {\n        let req = json!({\n            \"minUsn\": 0,\n            \"lnewer\": false,\n        });\n        let req = serde_json::to_vec(&req)\n            .unwrap()\n            .try_into_sync_request()\n            .unwrap();\n        client.start(req.into_output_type()).await.unwrap();\n        client.abort(EmptyInput::request()).await?;\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn invalid_uploads_should_be_handled() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client);\n        let res = ctx\n            .client\n            .upload(b\"fake data\".to_vec().try_into_sync_request()?)\n            .await?;\n        assert_eq!(\n            res.upload_response(),\n            UploadResponse::Err(CORRUPT_MESSAGE.into())\n        );\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn meta_redirect_is_handled() -> Result<()> {\n    with_active_server(|client| async move {\n        let mock_server = MockServer::start().await;\n        Mock::given(method(\"POST\"))\n            .and(path(\"/sync/meta\"))\n            .respond_with(\n                ResponseTemplate::new(308).insert_header(\"location\", client.endpoint.as_str()),\n            )\n            .mount(&mock_server)\n            .await;\n        // starting from in-sync state\n        let mut ctx = SyncTestContext::new(client);\n        upload_download(&ctx).await?;\n        // add another note to trigger a normal sync\n        let mut col1 = ctx.col1();\n        col1_setup(&mut col1);\n        // switch to bad endpoint\n        let orig_url = ctx.client.endpoint.to_string();\n        ctx.client.endpoint = Url::try_from(mock_server.uri().as_str()).unwrap();\n        // sync should succeed\n        let out = ctx.normal_sync(&mut col1).await;\n        // client should have received new endpoint\n        assert_eq!(out.new_endpoint, Some(orig_url));\n        // client should not have tried the old endpoint more than once\n        assert_eq!(mock_server.received_requests().await.unwrap().len(), 1);\n        Ok(())\n    })\n    .await\n}\n\npub(in crate::sync) struct SyncTestContext {\n    pub folder: TempDir,\n    pub client: HttpSyncClient,\n}\n\nimpl SyncTestContext {\n    pub fn new(client: HttpSyncClient) -> Self {\n        Self {\n            folder: tempdir().expect(\"create temp dir\"),\n            client,\n        }\n    }\n\n    pub fn col1(&self) -> Collection {\n        let base = self.folder.path();\n        CollectionBuilder::new(base.join(\"col1.anki2\"))\n            .with_desktop_media_paths()\n            .build()\n            .unwrap()\n    }\n\n    pub fn col2(&self) -> Collection {\n        let base = self.folder.path();\n        CollectionBuilder::new(base.join(\"col2.anki2\"))\n            .with_desktop_media_paths()\n            .build()\n            .unwrap()\n    }\n\n    async fn normal_sync(&self, col: &mut Collection) -> SyncOutput {\n        NormalSyncer::new(col, self.cloned_client())\n            .sync()\n            .await\n            .unwrap()\n    }\n\n    async fn full_upload(&self, col: Collection) {\n        col.full_upload_with_server(self.cloned_client())\n            .await\n            .unwrap()\n    }\n\n    async fn full_download(&self, col: Collection) {\n        col.full_download_with_server(self.cloned_client())\n            .await\n            .unwrap()\n    }\n\n    fn cloned_client(&self) -> HttpSyncClient {\n        self.client.clone()\n    }\n}\n\n// Setup + full syncs\n/////////////////////\n\nfn col1_setup(col: &mut Collection) {\n    let nt = col.get_notetype_by_name(\"Basic\").unwrap().unwrap();\n    let mut note = nt.new_note();\n    note.set_field(0, \"1\").unwrap();\n    col.add_note(&mut note, DeckId(1)).unwrap();\n}\n\nasync fn upload_download(ctx: &SyncTestContext) -> Result<()> {\n    let mut col1 = ctx.col1();\n    col1_setup(&mut col1);\n\n    let out = ctx.normal_sync(&mut col1).await;\n    assert!(matches!(\n        out.required,\n        SyncActionRequired::FullSyncRequired { .. }\n    ));\n\n    ctx.full_upload(col1).await;\n\n    // another collection\n    let mut col2 = ctx.col2();\n\n    // won't allow ankiweb clobber\n    let out = ctx.normal_sync(&mut col2).await;\n    assert_eq!(\n        out.required,\n        SyncActionRequired::FullSyncRequired {\n            upload_ok: false,\n            download_ok: true,\n        }\n    );\n\n    // fetch so we're in sync\n    ctx.full_download(col2).await;\n\n    Ok(())\n}\n\n// Regular syncs\n/////////////////////\n\nasync fn regular_sync(ctx: &SyncTestContext) -> Result<()> {\n    // add a deck\n    let mut col1 = ctx.col1();\n    let mut col2 = ctx.col2();\n\n    let mut deck = col1.get_or_create_normal_deck(\"new deck\")?;\n\n    // give it a new option group\n    let mut dconf = DeckConfig {\n        name: \"new dconf\".into(),\n        ..Default::default()\n    };\n    col1.add_or_update_deck_config(&mut dconf)?;\n    if let DeckKind::Normal(deck) = &mut deck.kind {\n        deck.config_id = dconf.id.0;\n    }\n    col1.add_or_update_deck(&mut deck)?;\n\n    // and a new notetype\n    let mut nt = all_stock_notetypes(&col1.tr).remove(0);\n    nt.name = \"new\".into();\n    col1.add_notetype(&mut nt, false)?;\n\n    // add another note+card+tag\n    let mut note = nt.new_note();\n    note.set_field(0, \"2\")?;\n    note.tags.push(\"tag\".into());\n    col1.add_note(&mut note, deck.id)?;\n\n    // mock revlog entry\n    col1.storage.add_revlog_entry(\n        &RevlogEntry {\n            id: RevlogId(123),\n            cid: CardId(456),\n            usn: Usn(-1),\n            interval: 10,\n            ..Default::default()\n        },\n        true,\n    )?;\n\n    // config + creation\n    col1.set_config(\"test\", &\"test1\")?;\n    // bumping this will affect 'last studied at' on decks at the moment\n    // col1.storage.set_creation_stamp(TimestampSecs(12345))?;\n\n    // and sync our changes\n    let remote_meta = ctx\n        .client\n        .meta(MetaRequest::request())\n        .await\n        .unwrap()\n        .json()\n        .unwrap();\n    let out = col1.sync_meta()?.compared_to_remote(remote_meta, None);\n    assert_eq!(out.required, SyncActionRequired::NormalSyncRequired);\n\n    let out = ctx.normal_sync(&mut col1).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n    // sync the other collection\n    let out = ctx.normal_sync(&mut col2).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n    let ntid = nt.id;\n    let deckid = deck.id;\n    let dconfid = dconf.id;\n    let noteid = note.id;\n    let cardid = col1.search_cards(note.id, SortMode::NoOrder)?[0];\n    let revlogid = RevlogId(123);\n\n    let compare_sides = |col1: &mut Collection, col2: &mut Collection| -> Result<()> {\n        assert_eq!(\n            col1.get_notetype(ntid)?.unwrap(),\n            col2.get_notetype(ntid)?.unwrap()\n        );\n        assert_eq!(\n            col1.get_deck(deckid)?.unwrap(),\n            col2.get_deck(deckid)?.unwrap()\n        );\n        assert_eq!(\n            col1.get_deck_config(dconfid, false)?.unwrap(),\n            col2.get_deck_config(dconfid, false)?.unwrap()\n        );\n        assert_eq!(\n            col1.storage.get_note(noteid)?.unwrap(),\n            col2.storage.get_note(noteid)?.unwrap()\n        );\n        assert_eq!(\n            col1.storage.get_card(cardid)?.unwrap(),\n            col2.storage.get_card(cardid)?.unwrap()\n        );\n        assert_eq!(\n            col1.storage.get_revlog_entry(revlogid)?,\n            col2.storage.get_revlog_entry(revlogid)?,\n        );\n        assert_eq!(\n            col1.storage.get_all_config()?,\n            col2.storage.get_all_config()?\n        );\n        assert_eq!(\n            col1.storage.creation_stamp()?,\n            col2.storage.creation_stamp()?\n        );\n\n        // server doesn't send tag usns, so we can only compare tags, not usns,\n        // as the usns may not match\n        assert_eq!(\n            col1.storage\n                .all_tags()?\n                .into_iter()\n                .map(|t| t.name)\n                .collect::<Vec<_>>(),\n            col2.storage\n                .all_tags()?\n                .into_iter()\n                .map(|t| t.name)\n                .collect::<Vec<_>>()\n        );\n        std::thread::sleep(std::time::Duration::from_millis(1));\n        Ok(())\n    };\n\n    // make sure everything has been transferred across\n    compare_sides(&mut col1, &mut col2)?;\n\n    // make some modifications\n    let mut note = col2.storage.get_note(note.id)?.unwrap();\n    note.set_field(1, \"new\")?;\n    note.tags.push(\"tag2\".into());\n    col2.update_note(&mut note)?;\n\n    col2.get_and_update_card(cardid, |card| {\n        card.queue = CardQueue::Review;\n        Ok(())\n    })?;\n\n    let mut deck = col2.storage.get_deck(deck.id)?.unwrap();\n    deck.name = NativeDeckName::from_native_str(\"newer\");\n    col2.add_or_update_deck(&mut deck)?;\n\n    let mut nt = col2.storage.get_notetype(nt.id)?.unwrap();\n    nt.name = \"newer\".into();\n    col2.update_notetype(&mut nt, false)?;\n\n    // sync the changes back\n    let out = ctx.normal_sync(&mut col2).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n    let out = ctx.normal_sync(&mut col1).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n    // should still match\n    compare_sides(&mut col1, &mut col2)?;\n\n    // deletions should sync too\n    for table in &[\"cards\", \"notes\", \"decks\"] {\n        assert_eq!(\n            col1.storage\n                .db_scalar::<u8>(&format!(\"select count() from {table}\"))?,\n            2\n        );\n    }\n\n    // fixme: inconsistent usn arg\n    std::thread::sleep(std::time::Duration::from_millis(1));\n    col1.remove_cards_and_orphaned_notes(&[cardid])?;\n    let usn = col1.usn()?;\n    col1.remove_note_only_undoable(noteid, usn)?;\n    col1.remove_decks_and_child_decks(&[deckid])?;\n\n    let out = ctx.normal_sync(&mut col1).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n    let out = ctx.normal_sync(&mut col2).await;\n    assert_eq!(out.required, SyncActionRequired::NoChanges);\n\n    for table in &[\"cards\", \"notes\", \"decks\"] {\n        assert_eq!(\n            col2.storage\n                .db_scalar::<u8>(&format!(\"select count() from {table}\"))?,\n            1\n        );\n    }\n\n    // removing things like a notetype forces a full sync\n    std::thread::sleep(std::time::Duration::from_millis(1));\n    col2.remove_notetype(ntid)?;\n    let out = ctx.normal_sync(&mut col2).await;\n    assert!(matches!(\n        out.required,\n        SyncActionRequired::FullSyncRequired { .. }\n    ));\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/src/sync/collection/upload.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::io::Write;\n\nuse anki_io::atomic_rename;\nuse anki_io::new_tempfile_in_parent_of;\nuse anki_io::write_file;\nuse axum::response::IntoResponse;\nuse axum::response::Response;\nuse flate2::write::GzEncoder;\nuse flate2::Compression;\nuse futures::StreamExt;\nuse reqwest::Client;\nuse tokio_util::io::ReaderStream;\n\nuse crate::collection::CollectionBuilder;\nuse crate::error::SyncErrorKind;\nuse crate::prelude::*;\nuse crate::storage::SchemaVersion;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::SyncAuth;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\n\n/// Old clients didn't display a useful message on HTTP 400, and were expected\n/// to show the error message returned by the server.\npub const CORRUPT_MESSAGE: &str =\n    \"Your upload was corrupt. Please use Check Database, or restore from backup.\";\n\nimpl Collection {\n    /// Upload collection to AnkiWeb. Caller must re-open afterwards.\n    pub async fn full_upload(self, auth: SyncAuth, client: Client) -> Result<()> {\n        self.full_upload_with_server(HttpSyncClient::new(auth, client))\n            .await\n    }\n\n    // pub for tests\n    pub(super) async fn full_upload_with_server(mut self, server: HttpSyncClient) -> Result<()> {\n        self.before_upload()?;\n        let col_path = self.col_path.clone();\n        let progress = self.new_progress_handler();\n        self.close(Some(SchemaVersion::V18))?;\n        let col_data = fs::read(&col_path)?;\n\n        let total_bytes = col_data.len();\n        if server.endpoint.as_str().contains(\"ankiweb\") {\n            check_upload_limit(\n                total_bytes,\n                *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED as usize,\n            )?;\n        }\n\n        match server\n            .upload_with_progress(col_data.try_into_sync_request()?, progress)\n            .await?\n            .upload_response()\n        {\n            UploadResponse::Ok => Ok(()),\n            UploadResponse::Err(msg) => {\n                Err(AnkiError::sync_error(msg, SyncErrorKind::ServerMessage))\n            }\n        }\n    }\n}\n\n/// Collection must already be open, and will be replaced on success.\npub fn handle_received_upload(\n    col: &mut Option<Collection>,\n    new_data: Vec<u8>,\n) -> HttpResult<UploadResponse> {\n    let max_bytes = *MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED as usize;\n    if new_data.len() >= max_bytes {\n        return Ok(UploadResponse::Err(\"collection exceeds size limit\".into()));\n    }\n    let path = col\n        .as_ref()\n        .or_internal_err(\"col was closed\")?\n        .col_path\n        .clone();\n    // write to temp file\n    let temp_file = new_tempfile_in_parent_of(&path).or_internal_err(\"temp file\")?;\n    write_file(temp_file.path(), &new_data).or_internal_err(\"temp file\")?;\n    // check the collection is valid\n    if let Err(err) = CollectionBuilder::new(temp_file.path())\n        .set_check_integrity(true)\n        .build()\n    {\n        tracing::info!(?err, \"uploaded file was corrupt/failed to open\");\n        return Ok(UploadResponse::Err(CORRUPT_MESSAGE.into()));\n    }\n    // close collection and rename\n    if let Some(col) = col.take() {\n        col.close(None)\n            .or_internal_err(\"closing current collection\")?;\n    }\n    atomic_rename(temp_file, &path, true).or_internal_err(\"rename upload\")?;\n    Ok(UploadResponse::Ok)\n}\n\nimpl IntoResponse for UploadResponse {\n    fn into_response(self) -> Response {\n        match self {\n            // the legacy protocol expects this exact string\n            UploadResponse::Ok => \"OK\".to_string(),\n            UploadResponse::Err(e) => e,\n        }\n        .into_response()\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum UploadResponse {\n    Ok,\n    Err(String),\n}\n\npub fn check_upload_limit(size: usize, limit: usize) -> Result<()> {\n    let size_of_one_mb: f64 = 1024.0 * 1024.0;\n    let collection_size_in_mb: f64 = size as f64 / size_of_one_mb;\n    let limit_size_in_mb: f64 = limit as f64 / size_of_one_mb;\n\n    if size >= limit {\n        Err(AnkiError::sync_error(\n            format!(\"{collection_size_in_mb:.2} MB > {limit_size_in_mb:.2} MB\"),\n            SyncErrorKind::UploadTooLarge,\n        ))\n    } else {\n        Ok(())\n    }\n}\n\npub async fn gzipped_data_from_vec(vec: Vec<u8>) -> Result<Vec<u8>> {\n    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());\n    let mut stream = ReaderStream::new(&vec[..]);\n    while let Some(chunk) = stream.next().await {\n        let chunk = chunk?;\n        encoder.write_all(&chunk)?;\n    }\n    encoder.finish().map_err(Into::into)\n}\n"
  },
  {
    "path": "rslib/src/sync/error.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::error::Error;\nuse std::fmt::Display;\nuse std::fmt::Formatter;\n\nuse axum::http::StatusCode;\nuse axum::response::IntoResponse;\nuse axum::response::Redirect;\nuse axum::response::Response;\n\npub type HttpResult<T, E = HttpError> = Result<T, E>;\n\n#[derive(Debug)]\npub struct HttpError {\n    pub code: StatusCode,\n    pub context: String,\n    pub source: Option<Box<dyn Error + Send + Sync>>,\n}\n\nimpl Display for HttpError {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{} (code={})\", self.context, self.code.as_u16())\n    }\n}\n\nimpl Error for HttpError {\n    fn source(&self) -> Option<&(dyn Error + 'static)> {\n        match &self.source {\n            None => None,\n            Some(err) => Some(err.as_ref()),\n        }\n    }\n}\n\nimpl HttpError {\n    pub fn new_without_source(code: StatusCode, context: impl Into<String>) -> Self {\n        Self {\n            code,\n            context: context.into(),\n            source: None,\n        }\n    }\n\n    /// Compatibility with ensure!() macro\n    pub fn fail<T>(self) -> Result<T, Self> {\n        Err(self)\n    }\n}\n\nimpl IntoResponse for HttpError {\n    fn into_response(self) -> Response {\n        let HttpError {\n            code,\n            context,\n            source,\n        } = self;\n        if code.is_server_error() && code != StatusCode::NOT_IMPLEMENTED {\n            tracing::error!(context, ?source, httpstatus = code.as_u16(),);\n        } else {\n            tracing::info!(context, ?source, httpstatus = code.as_u16(),);\n        }\n        if code == StatusCode::PERMANENT_REDIRECT {\n            Redirect::permanent(&context).into_response()\n        } else {\n            (code, code.as_str().to_string()).into_response()\n        }\n    }\n}\n\npub trait OrHttpErr {\n    type Value;\n\n    fn or_http_err(\n        self,\n        code: StatusCode,\n        context: impl Into<String>,\n    ) -> Result<Self::Value, HttpError>;\n\n    fn or_bad_request(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::BAD_REQUEST, context)\n    }\n\n    fn or_internal_err(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::INTERNAL_SERVER_ERROR, context)\n    }\n\n    fn or_forbidden(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::FORBIDDEN, context)\n    }\n\n    fn or_conflict(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::CONFLICT, context)\n    }\n\n    fn or_not_found(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::NOT_FOUND, context)\n    }\n\n    fn or_permanent_redirect(self, context: impl Into<String>) -> Result<Self::Value, HttpError>\n    where\n        Self: Sized,\n    {\n        self.or_http_err(StatusCode::PERMANENT_REDIRECT, context)\n    }\n}\n\nimpl<T, E> OrHttpErr for Result<T, E>\nwhere\n    E: Into<Box<dyn Error + Send + Sync + 'static>>,\n{\n    type Value = T;\n\n    fn or_http_err(\n        self,\n        code: StatusCode,\n        context: impl Into<String>,\n    ) -> Result<Self::Value, HttpError> {\n        self.map_err(|err| HttpError {\n            code,\n            context: context.into(),\n            source: Some(err.into()),\n        })\n    }\n}\n\nimpl<T> OrHttpErr for Option<T> {\n    type Value = T;\n\n    fn or_http_err(\n        self,\n        code: StatusCode,\n        context: impl Into<String>,\n    ) -> Result<Self::Value, HttpError> {\n        self.ok_or_else(|| HttpError {\n            code,\n            context: context.into(),\n            source: None,\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_client/full_sync.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::future::Future;\nuse std::time::Duration;\n\nuse tokio::select;\nuse tokio::time::interval;\n\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::collection::progress::FullSyncProgress;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncMethod;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::error::HttpResult;\nuse crate::sync::http_client::io_monitor::IoMonitor;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\nimpl HttpSyncClient {\n    fn full_sync_progress_monitor(\n        &self,\n        sending: bool,\n        mut progress: ThrottlingProgressHandler<FullSyncProgress>,\n    ) -> (IoMonitor, impl Future<Output = ()>) {\n        let io_monitor = IoMonitor::new();\n        let io_monitor2 = io_monitor.clone();\n        let update_progress = async move {\n            let mut interval = interval(Duration::from_millis(100));\n            loop {\n                interval.tick().await;\n                let (total_bytes, transferred_bytes) = {\n                    let guard = io_monitor2.0.lock().unwrap();\n                    (\n                        if sending {\n                            guard.total_bytes_to_send\n                        } else {\n                            guard.total_bytes_to_receive\n                        },\n                        if sending {\n                            guard.bytes_sent\n                        } else {\n                            guard.bytes_received\n                        },\n                    )\n                };\n                _ = progress.update(false, |p| {\n                    p.total_bytes = total_bytes as usize;\n                    p.transferred_bytes = transferred_bytes as usize;\n                })\n            }\n        };\n        (io_monitor, update_progress)\n    }\n\n    pub(in super::super) async fn download_with_progress(\n        &self,\n        req: SyncRequest<EmptyInput>,\n        progress: ThrottlingProgressHandler<FullSyncProgress>,\n    ) -> HttpResult<SyncResponse<Vec<u8>>> {\n        let (io_monitor, progress_fut) = self.full_sync_progress_monitor(false, progress);\n        let output = self.request_ext(SyncMethod::Download, req, io_monitor);\n        select! {\n            _ = progress_fut => unreachable!(),\n            out = output => out\n        }\n    }\n\n    pub(in super::super) async fn upload_with_progress(\n        &self,\n        req: SyncRequest<Vec<u8>>,\n        progress: ThrottlingProgressHandler<FullSyncProgress>,\n    ) -> HttpResult<SyncResponse<UploadResponse>> {\n        let (io_monitor, progress_fut) = self.full_sync_progress_monitor(true, progress);\n        let output = self.request_ext(SyncMethod::Upload, req, io_monitor);\n        select! {\n            _ = progress_fut => unreachable!(),\n            out = output => out\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_client/io_monitor.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::io::Cursor;\nuse std::io::ErrorKind;\nuse std::sync::Arc;\nuse std::sync::Mutex;\nuse std::time::Duration;\n\nuse bytes::Bytes;\nuse futures::Stream;\nuse futures::StreamExt;\nuse futures::TryStreamExt;\nuse reqwest::header::CONTENT_TYPE;\nuse reqwest::header::LOCATION;\nuse reqwest::Body;\nuse reqwest::RequestBuilder;\nuse reqwest::Response;\nuse reqwest::StatusCode;\nuse tokio::io::AsyncReadExt;\nuse tokio::select;\nuse tokio::time::interval;\nuse tokio::time::Instant;\nuse tokio_util::io::ReaderStream;\nuse tokio_util::io::StreamReader;\n\nuse crate::error::Result;\nuse crate::sync::error::HttpError;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::request::header_and_stream::decode_zstd_body_stream_for_client;\nuse crate::sync::request::header_and_stream::encode_zstd_body_stream;\nuse crate::sync::response::ORIGINAL_SIZE;\n\n/// Serves two purposes:\n/// - allows us to monitor data sending/receiving and abort if the transfer\n///   stalls\n/// - allows us to monitor amount of data moving, to provide progress reporting\n#[derive(Clone)]\npub struct IoMonitor(pub Arc<Mutex<IoMonitorInner>>);\n\nimpl IoMonitor {\n    pub fn new() -> Self {\n        Self(Arc::new(Mutex::new(IoMonitorInner {\n            last_activity: Instant::now(),\n            bytes_sent: 0,\n            total_bytes_to_send: 0,\n            bytes_received: 0,\n            total_bytes_to_receive: 0,\n        })))\n    }\n\n    pub fn wrap_stream<S, E>(\n        &self,\n        sending: bool,\n        total_bytes: u32,\n        stream: S,\n    ) -> impl Stream<Item = HttpResult<Bytes>> + Send + Sync + 'static\n    where\n        S: Stream<Item = Result<Bytes, E>> + Send + Sync + 'static,\n        E: std::error::Error + Send + Sync + 'static,\n    {\n        let inner = self.0.clone();\n        {\n            let mut inner = inner.lock().unwrap();\n            inner.last_activity = Instant::now();\n            if sending {\n                inner.total_bytes_to_send += total_bytes\n            } else {\n                inner.total_bytes_to_receive += total_bytes\n            }\n        }\n        stream.map(move |res| match res {\n            Ok(bytes) => {\n                let mut inner = inner.lock().unwrap();\n                inner.last_activity = Instant::now();\n                if sending {\n                    inner.bytes_sent += bytes.len() as u32;\n                } else {\n                    inner.bytes_received += bytes.len() as u32;\n                }\n                Ok(bytes)\n            }\n            err => err.or_http_err(StatusCode::SEE_OTHER, \"stream failure\"),\n        })\n    }\n\n    /// Returns if no I/O activity observed for `stall_time`.\n    pub async fn timeout(&self, stall_time: Duration) {\n        let poll_interval = Duration::from_millis(if cfg!(test) { 10 } else { 1000 });\n        let mut interval = interval(poll_interval);\n        loop {\n            let now = interval.tick().await;\n            let last_activity = self.0.lock().unwrap().last_activity;\n            if now.duration_since(last_activity) > stall_time {\n                return;\n            }\n        }\n    }\n\n    /// Takes care of encoding provided request data and setting content type to\n    /// binary, and returns the decompressed response body.\n    pub async fn zstd_request_with_timeout(\n        &self,\n        request: RequestBuilder,\n        request_body: Vec<u8>,\n        stall_duration: Duration,\n    ) -> HttpResult<Vec<u8>> {\n        let request_total = request_body.len() as u32;\n        let request_body_stream = encode_zstd_body_stream(self.wrap_stream(\n            true,\n            request_total,\n            ReaderStream::new(Cursor::new(request_body)),\n        ));\n        let response_body_stream = async move {\n            let resp = request\n                .header(CONTENT_TYPE, \"application/octet-stream\")\n                .body(Body::wrap_stream(request_body_stream))\n                .send()\n                .await?\n                .error_for_status()?;\n            map_redirect_to_error(&resp)?;\n            let response_total = resp\n                .headers()\n                .get(&ORIGINAL_SIZE)\n                .and_then(|v| v.to_str().ok())\n                .and_then(|v| v.parse::<u32>().ok())\n                .or_bad_request(\"missing original size\")?;\n            let response_stream = self.wrap_stream(\n                false,\n                response_total,\n                decode_zstd_body_stream_for_client(resp.bytes_stream()),\n            );\n            let mut reader =\n                StreamReader::new(response_stream.map_err(|e| {\n                    std::io::Error::new(ErrorKind::ConnectionAborted, format!(\"{e}\"))\n                }));\n            let mut buf = Vec::with_capacity(response_total as usize);\n            reader\n                .read_to_end(&mut buf)\n                .await\n                .or_http_err(StatusCode::SEE_OTHER, \"reading stream\")?;\n            Ok::<_, HttpError>(buf)\n        };\n        select! {\n            // happy path\n            data = response_body_stream => Ok(data?),\n            // timeout\n            _ = self.timeout(stall_duration) => {\n                Err(HttpError {\n                    code: StatusCode::REQUEST_TIMEOUT,\n                    context: \"timeout monitor\".into(),\n                    source: None,\n                })\n            }\n        }\n    }\n}\n\n/// Reqwest can't retry a redirected request as the body has been consumed, so\n/// we need to bubble it up to the sync driver to retry.\nfn map_redirect_to_error(resp: &Response) -> HttpResult<()> {\n    if resp.status() == StatusCode::PERMANENT_REDIRECT {\n        let location = resp\n            .headers()\n            .get(LOCATION)\n            .or_bad_request(\"missing location header\")?;\n        let location = String::from_utf8(location.as_bytes().to_vec())\n            .or_bad_request(\"location was not in utf8\")?;\n        None.or_permanent_redirect(location)?;\n    }\n    Ok(())\n}\n\n#[derive(Debug)]\npub struct IoMonitorInner {\n    last_activity: Instant,\n    pub bytes_sent: u32,\n    pub total_bytes_to_send: u32,\n    pub bytes_received: u32,\n    pub total_bytes_to_receive: u32,\n}\n\nimpl IoMonitor {}\n\n#[cfg(test)]\nmod test {\n    use async_stream::stream;\n    use futures::pin_mut;\n    use futures::StreamExt;\n    use tokio::select;\n    use tokio::time::sleep;\n    use wiremock::matchers::method;\n    use wiremock::matchers::path;\n    use wiremock::Mock;\n    use wiremock::MockServer;\n    use wiremock::ResponseTemplate;\n\n    use super::*;\n    use crate::sync::error::HttpError;\n\n    /// The delays in the tests are aggressively short, and false positives slip\n    /// through on a loaded system - especially on Windows. Fix by applying\n    /// a universal multiplier.\n    fn millis(millis: u64) -> Duration {\n        Duration::from_millis(millis * if cfg!(windows) { 10 } else { 5 })\n    }\n\n    #[tokio::test]\n    async fn can_fail_before_any_bytes() {\n        let monitor = IoMonitor::new();\n        let stream = monitor.wrap_stream(\n            true,\n            0,\n            stream! {\n                sleep(millis(2000)).await;\n                yield Ok::<_, HttpError>(Bytes::from(\"1\"))\n            },\n        );\n        pin_mut!(stream);\n        select! {\n            _ = stream.next() => panic!(\"expected failure\"),\n            _ = monitor.timeout(millis(100)) => ()\n        };\n    }\n\n    #[tokio::test]\n    async fn fails_when_data_stops_moving() {\n        let monitor = IoMonitor::new();\n        let stream = monitor.wrap_stream(\n            true,\n            0,\n            stream! {\n                for _ in 0..10 {\n                    sleep(millis(10)).await;\n                    yield Ok::<_, HttpError>(Bytes::from(\"1\"))\n                }\n                sleep(millis(50)).await;\n                yield Ok::<_, HttpError>(Bytes::from(\"1\"))\n            },\n        );\n        pin_mut!(stream);\n        for _ in 0..10 {\n            select! {\n                _ = stream.next() => (),\n                _ = monitor.timeout(millis(20)) => panic!(\"expected success\")\n            };\n        }\n        select! {\n            _ = stream.next() => panic!(\"expected timeout\"),\n            _ = monitor.timeout(millis(20)) => ()\n        };\n    }\n\n    #[tokio::test]\n    async fn connect_timeout_works() {\n        let monitor = IoMonitor::new();\n        let req = monitor.zstd_request_with_timeout(\n            reqwest::Client::new().post(\"http://0.0.0.1\"),\n            vec![],\n            millis(50),\n        );\n        req.await.unwrap_err();\n    }\n\n    #[tokio::test]\n    async fn http_success() {\n        let mock_server = MockServer::start().await;\n        Mock::given(method(\"POST\"))\n            .and(path(\"/\"))\n            .respond_with(ResponseTemplate::new(200).insert_header(ORIGINAL_SIZE.as_str(), \"0\"))\n            .mount(&mock_server)\n            .await;\n        let monitor = IoMonitor::new();\n        let req = monitor.zstd_request_with_timeout(\n            reqwest::Client::new().post(mock_server.uri()),\n            vec![],\n            millis(10),\n        );\n        req.await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn delay_before_reply_fails() {\n        let mock_server = MockServer::start().await;\n        Mock::given(method(\"POST\"))\n            .and(path(\"/\"))\n            .respond_with(ResponseTemplate::new(200).set_delay(millis(50)))\n            .mount(&mock_server)\n            .await;\n        let monitor = IoMonitor::new();\n        let req = monitor.zstd_request_with_timeout(\n            reqwest::Client::new().post(mock_server.uri()),\n            vec![],\n            millis(10),\n        );\n        req.await.unwrap_err();\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_client/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub(crate) mod full_sync;\npub(crate) mod io_monitor;\nmod protocol;\n\nuse std::time::Duration;\n\nuse reqwest::Client;\nuse reqwest::Error;\nuse reqwest::StatusCode;\nuse reqwest::Url;\n\nuse crate::notes;\nuse crate::sync::collection::protocol::AsSyncEndpoint;\nuse crate::sync::error::HttpError;\nuse crate::sync::error::HttpResult;\nuse crate::sync::http_client::io_monitor::IoMonitor;\nuse crate::sync::login::SyncAuth;\nuse crate::sync::request::header_and_stream::SyncHeader;\nuse crate::sync::request::header_and_stream::SYNC_HEADER_NAME;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\n#[derive(Clone)]\npub struct HttpSyncClient {\n    /// Set to the empty string for initial login\n    pub sync_key: String,\n    session_key: String,\n    client: Client,\n    pub endpoint: Url,\n    pub io_timeout: Duration,\n}\n\nimpl HttpSyncClient {\n    pub fn new(auth: SyncAuth, client: Client) -> HttpSyncClient {\n        let io_timeout = Duration::from_secs(auth.io_timeout_secs.unwrap_or(30) as u64);\n        HttpSyncClient {\n            sync_key: auth.hkey,\n            session_key: simple_session_id(),\n            client,\n            endpoint: auth\n                .endpoint\n                .unwrap_or_else(|| Url::try_from(\"https://sync.ankiweb.net/\").unwrap()),\n            io_timeout,\n        }\n    }\n\n    async fn request<I, O>(\n        &self,\n        method: impl AsSyncEndpoint,\n        request: SyncRequest<I>,\n    ) -> HttpResult<SyncResponse<O>> {\n        self.request_ext(method, request, IoMonitor::new()).await\n    }\n\n    async fn request_ext<I, O>(\n        &self,\n        method: impl AsSyncEndpoint,\n        request: SyncRequest<I>,\n        io_monitor: IoMonitor,\n    ) -> HttpResult<SyncResponse<O>> {\n        let header = SyncHeader {\n            sync_version: request.sync_version,\n            sync_key: self.sync_key.clone(),\n            client_ver: request.client_version,\n            session_key: self.session_key.clone(),\n        };\n        let data = request.data;\n        let url = method.as_sync_endpoint(&self.endpoint);\n        let request = self\n            .client\n            .post(url)\n            .header(&SYNC_HEADER_NAME, serde_json::to_string(&header).unwrap());\n        io_monitor\n            .zstd_request_with_timeout(request, data, self.io_timeout)\n            .await\n            .map(SyncResponse::from_vec)\n    }\n\n    #[cfg(test)]\n    pub(crate) fn endpoint(&self) -> &Url {\n        &self.endpoint\n    }\n\n    #[cfg(test)]\n    pub(crate) fn set_skey(&mut self, skey: String) {\n        self.session_key = skey;\n    }\n\n    #[cfg(test)]\n    pub(crate) fn skey(&self) -> &str {\n        &self.session_key\n    }\n}\n\nimpl From<Error> for HttpError {\n    fn from(err: Error) -> Self {\n        HttpError {\n            // we should perhaps make this Optional instead\n            code: err.status().unwrap_or(StatusCode::SEE_OTHER),\n            context: \"from reqwest\".into(),\n            source: Some(Box::new(err) as _),\n        }\n    }\n}\n\nfn simple_session_id() -> String {\n    let table = b\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\\\n0123456789\";\n    notes::to_base_n(rand::random::<u32>() as u64, table)\n}\n"
  },
  {
    "path": "rslib/src/sync/http_client/protocol.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse async_trait::async_trait;\n\nuse crate::prelude::TimestampMillis;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::collection::changes::ApplyChangesRequest;\nuse crate::sync::collection::changes::UnchunkedChanges;\nuse crate::sync::collection::chunks::ApplyChunkRequest;\nuse crate::sync::collection::chunks::Chunk;\nuse crate::sync::collection::graves::ApplyGravesRequest;\nuse crate::sync::collection::graves::Graves;\nuse crate::sync::collection::meta::MetaRequest;\nuse crate::sync::collection::meta::SyncMeta;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncMethod;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::sanity::SanityCheckRequest;\nuse crate::sync::collection::sanity::SanityCheckResponse;\nuse crate::sync::collection::start::StartRequest;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::error::HttpResult;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::login::HostKeyRequest;\nuse crate::sync::login::HostKeyResponse;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::begin::SyncBeginResponse;\nuse crate::sync::media::changes::MediaChangesRequest;\nuse crate::sync::media::changes::MediaChangesResponse;\nuse crate::sync::media::download::DownloadFilesRequest;\nuse crate::sync::media::protocol::JsonResult;\nuse crate::sync::media::protocol::MediaSyncMethod;\nuse crate::sync::media::protocol::MediaSyncProtocol;\nuse crate::sync::media::sanity;\nuse crate::sync::media::upload;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\n#[async_trait]\nimpl SyncProtocol for HttpSyncClient {\n    async fn host_key(\n        &self,\n        req: SyncRequest<HostKeyRequest>,\n    ) -> HttpResult<SyncResponse<HostKeyResponse>> {\n        self.request(SyncMethod::HostKey, req).await\n    }\n\n    async fn meta(&self, req: SyncRequest<MetaRequest>) -> HttpResult<SyncResponse<SyncMeta>> {\n        self.request(SyncMethod::Meta, req).await\n    }\n\n    async fn start(&self, req: SyncRequest<StartRequest>) -> HttpResult<SyncResponse<Graves>> {\n        self.request(SyncMethod::Start, req).await\n    }\n\n    async fn apply_graves(\n        &self,\n        req: SyncRequest<ApplyGravesRequest>,\n    ) -> HttpResult<SyncResponse<()>> {\n        self.request(SyncMethod::ApplyGraves, req).await\n    }\n\n    async fn apply_changes(\n        &self,\n        req: SyncRequest<ApplyChangesRequest>,\n    ) -> HttpResult<SyncResponse<UnchunkedChanges>> {\n        self.request(SyncMethod::ApplyChanges, req).await\n    }\n\n    async fn chunk(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Chunk>> {\n        self.request(SyncMethod::Chunk, req).await\n    }\n\n    async fn apply_chunk(\n        &self,\n        req: SyncRequest<ApplyChunkRequest>,\n    ) -> HttpResult<SyncResponse<()>> {\n        self.request(SyncMethod::ApplyChunk, req).await\n    }\n\n    async fn sanity_check(\n        &self,\n        req: SyncRequest<SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<SanityCheckResponse>> {\n        self.request(SyncMethod::SanityCheck2, req).await\n    }\n\n    async fn finish(\n        &self,\n        req: SyncRequest<EmptyInput>,\n    ) -> HttpResult<SyncResponse<TimestampMillis>> {\n        self.request(SyncMethod::Finish, req).await\n    }\n\n    async fn abort(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<()>> {\n        self.request(SyncMethod::Abort, req).await\n    }\n\n    async fn upload(&self, req: SyncRequest<Vec<u8>>) -> HttpResult<SyncResponse<UploadResponse>> {\n        self.upload_with_progress(req, ThrottlingProgressHandler::default())\n            .await\n    }\n\n    async fn download(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Vec<u8>>> {\n        self.download_with_progress(req, ThrottlingProgressHandler::default())\n            .await\n    }\n}\n\n#[async_trait]\nimpl MediaSyncProtocol for HttpSyncClient {\n    async fn begin(\n        &self,\n        req: SyncRequest<SyncBeginRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<SyncBeginResponse>>> {\n        self.request(MediaSyncMethod::Begin, req).await\n    }\n\n    async fn media_changes(\n        &self,\n        req: SyncRequest<MediaChangesRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaChangesResponse>>> {\n        self.request(MediaSyncMethod::MediaChanges, req).await\n    }\n\n    async fn upload_changes(\n        &self,\n        req: SyncRequest<Vec<u8>>,\n    ) -> HttpResult<SyncResponse<JsonResult<upload::MediaUploadResponse>>> {\n        self.request(MediaSyncMethod::UploadChanges, req).await\n    }\n\n    async fn download_files(\n        &self,\n        req: SyncRequest<DownloadFilesRequest>,\n    ) -> HttpResult<SyncResponse<Vec<u8>>> {\n        self.request(MediaSyncMethod::DownloadFiles, req).await\n    }\n\n    async fn media_sanity_check(\n        &self,\n        req: SyncRequest<sanity::SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<sanity::MediaSanityCheckResponse>>> {\n        self.request(MediaSyncMethod::MediaSanity, req).await\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/handlers.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::sync::Arc;\n\nuse async_trait::async_trait;\nuse media::sanity::MediaSanityCheckResponse;\nuse media::upload::MediaUploadResponse;\n\nuse crate::prelude::*;\nuse crate::sync::collection::changes::server_apply_changes;\nuse crate::sync::collection::changes::ApplyChangesRequest;\nuse crate::sync::collection::changes::UnchunkedChanges;\nuse crate::sync::collection::chunks::server_apply_chunk;\nuse crate::sync::collection::chunks::server_chunk;\nuse crate::sync::collection::chunks::ApplyChunkRequest;\nuse crate::sync::collection::chunks::Chunk;\nuse crate::sync::collection::download::server_download;\nuse crate::sync::collection::finish::server_finish;\nuse crate::sync::collection::graves::server_apply_graves;\nuse crate::sync::collection::graves::ApplyGravesRequest;\nuse crate::sync::collection::graves::Graves;\nuse crate::sync::collection::meta::server_meta;\nuse crate::sync::collection::meta::MetaRequest;\nuse crate::sync::collection::meta::SyncMeta;\nuse crate::sync::collection::protocol::EmptyInput;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::collection::sanity::server_sanity_check;\nuse crate::sync::collection::sanity::SanityCheckRequest;\nuse crate::sync::collection::sanity::SanityCheckResponse;\nuse crate::sync::collection::sanity::SanityCheckStatus;\nuse crate::sync::collection::start::server_start;\nuse crate::sync::collection::start::StartRequest;\nuse crate::sync::collection::upload::handle_received_upload;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_server::SimpleServer;\nuse crate::sync::login::HostKeyRequest;\nuse crate::sync::login::HostKeyResponse;\nuse crate::sync::media;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::begin::SyncBeginResponse;\nuse crate::sync::media::changes::MediaChangesRequest;\nuse crate::sync::media::changes::MediaChangesResponse;\nuse crate::sync::media::download::DownloadFilesRequest;\nuse crate::sync::media::protocol::JsonResult;\nuse crate::sync::media::protocol::MediaSyncProtocol;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\n#[async_trait]\nimpl SyncProtocol for Arc<SimpleServer> {\n    async fn host_key(\n        &self,\n        req: SyncRequest<HostKeyRequest>,\n    ) -> HttpResult<SyncResponse<HostKeyResponse>> {\n        self.get_host_key(req.json()?)\n    }\n\n    async fn meta(&self, req: SyncRequest<MetaRequest>) -> HttpResult<SyncResponse<SyncMeta>> {\n        self.with_authenticated_user(req, |user, req| {\n            let req = req.json()?;\n            let mut meta = user.with_col(|col| server_meta(req, col))?;\n            meta.media_usn = user.media.last_usn()?;\n            Ok(meta)\n        })\n        .await\n        .and_then(SyncResponse::try_from_obj)\n    }\n\n    async fn start(&self, req: SyncRequest<StartRequest>) -> HttpResult<SyncResponse<Graves>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let req = req.json()?;\n            user.start_new_sync(skey)?;\n            user.with_sync_state(skey, |col, state| server_start(req, col, state))\n                .and_then(SyncResponse::try_from_obj)\n        })\n        .await\n    }\n\n    async fn apply_graves(\n        &self,\n        req: SyncRequest<ApplyGravesRequest>,\n    ) -> HttpResult<SyncResponse<()>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let req = req.json()?;\n            user.with_sync_state(skey, |col, state| server_apply_graves(req, col, state))\n                .and_then(SyncResponse::try_from_obj)\n        })\n        .await\n    }\n\n    async fn apply_changes(\n        &self,\n        req: SyncRequest<ApplyChangesRequest>,\n    ) -> HttpResult<SyncResponse<UnchunkedChanges>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let req = req.json()?;\n            user.with_sync_state(skey, |col, state| server_apply_changes(req, col, state))\n                .and_then(SyncResponse::try_from_obj)\n        })\n        .await\n    }\n\n    async fn chunk(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Chunk>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let _ = req.json()?;\n            user.with_sync_state(skey, server_chunk)\n                .and_then(SyncResponse::try_from_obj)\n        })\n        .await\n    }\n\n    async fn apply_chunk(\n        &self,\n        req: SyncRequest<ApplyChunkRequest>,\n    ) -> HttpResult<SyncResponse<()>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let req = req.json()?;\n            user.with_sync_state(skey, |col, state| server_apply_chunk(req, col, state))\n                .and_then(SyncResponse::try_from_obj)\n        })\n        .await\n    }\n\n    async fn sanity_check(\n        &self,\n        req: SyncRequest<SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<SanityCheckResponse>> {\n        self.with_authenticated_user(req, |user, req| {\n            let skey = req.skey()?;\n            let req = req.json()?;\n            let resp = user.with_sync_state(skey, |col, _state| server_sanity_check(req, col))?;\n            if resp.status == SanityCheckStatus::Bad {\n                // don't wait for an abort to roll back\n                let _ = user.col.take();\n            }\n            SyncResponse::try_from_obj(resp)\n        })\n        .await\n    }\n\n    async fn finish(\n        &self,\n        req: SyncRequest<EmptyInput>,\n    ) -> HttpResult<SyncResponse<TimestampMillis>> {\n        self.with_authenticated_user(req, |user, req| {\n            let _ = req.json()?;\n            let now = user.with_sync_state(req.skey()?, |col, _state| server_finish(col))?;\n            user.sync_state = None;\n            SyncResponse::try_from_obj(now)\n        })\n        .await\n    }\n\n    async fn abort(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<()>> {\n        self.with_authenticated_user(req, |user, req| {\n            let _ = req.json()?;\n            user.abort_stateful_sync_if_active();\n            SyncResponse::try_from_obj(())\n        })\n        .await\n    }\n\n    async fn upload(&self, req: SyncRequest<Vec<u8>>) -> HttpResult<SyncResponse<UploadResponse>> {\n        self.with_authenticated_user(req, |user, req| {\n            user.abort_stateful_sync_if_active();\n            user.ensure_col_open()?;\n            handle_received_upload(&mut user.col, req.data).map(SyncResponse::from_upload_response)\n        })\n        .await\n    }\n\n    async fn download(&self, req: SyncRequest<EmptyInput>) -> HttpResult<SyncResponse<Vec<u8>>> {\n        self.with_authenticated_user(req, |user, req| {\n            let schema_version = req.sync_version.collection_schema();\n            let _ = req.json()?;\n            user.abort_stateful_sync_if_active();\n            user.ensure_col_open()?;\n            server_download(&mut user.col, schema_version).map(SyncResponse::from_vec)\n        })\n        .await\n    }\n}\n\n#[async_trait]\nimpl MediaSyncProtocol for Arc<SimpleServer> {\n    async fn begin(\n        &self,\n        req: SyncRequest<SyncBeginRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<SyncBeginResponse>>> {\n        let hkey = req.sync_key.clone();\n        self.with_authenticated_user(req, |user, req| {\n            let req = req.json()?;\n            if req.client_version.is_empty() {\n                None.or_bad_request(\"missing client version\")?;\n            }\n            SyncResponse::try_from_obj(JsonResult::ok(SyncBeginResponse {\n                usn: user.media.last_usn()?,\n                host_key: hkey,\n            }))\n        })\n        .await\n    }\n\n    async fn media_changes(\n        &self,\n        req: SyncRequest<MediaChangesRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaChangesResponse>>> {\n        self.with_authenticated_user(req, |user, req| {\n            SyncResponse::try_from_obj(JsonResult::ok(\n                user.media.media_changes_chunk(req.json()?.last_usn)?,\n            ))\n        })\n        .await\n    }\n\n    async fn upload_changes(\n        &self,\n        req: SyncRequest<Vec<u8>>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaUploadResponse>>> {\n        self.with_authenticated_user(req, |user, req| {\n            SyncResponse::try_from_obj(JsonResult::ok(\n                user.media.process_uploaded_changes(req.data)?,\n            ))\n        })\n        .await\n    }\n\n    async fn download_files(\n        &self,\n        req: SyncRequest<DownloadFilesRequest>,\n    ) -> HttpResult<SyncResponse<Vec<u8>>> {\n        self.with_authenticated_user(req, |user, req| {\n            Ok(SyncResponse::from_vec(\n                user.media.zip_files_for_download(req.json()?.files)?,\n            ))\n        })\n        .await\n    }\n\n    async fn media_sanity_check(\n        &self,\n        req: SyncRequest<media::sanity::SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaSanityCheckResponse>>> {\n        self.with_authenticated_user(req, |user, req| {\n            SyncResponse::try_from_obj(JsonResult::ok(user.media.sanity_check(req.json()?.local)?))\n        })\n        .await\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/logging.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::time::Duration;\n\nuse axum::body::Body;\nuse axum::http::Request;\nuse axum::response::Response;\nuse axum::Router;\nuse tower_http::trace::TraceLayer;\nuse tracing::info_span;\nuse tracing::Span;\n\npub fn with_logging_layer(router: Router) -> Router {\n    router.layer(\n        TraceLayer::new_for_http()\n            .make_span_with(|request: &Request<Body>| {\n                info_span!(\n                    \"request\",\n                    uri = request.uri().path(),\n                    ip = tracing::field::Empty,\n                    uid = tracing::field::Empty,\n                    client = tracing::field::Empty,\n                    session = tracing::field::Empty,\n                )\n            })\n            .on_request(())\n            .on_response(|response: &Response, latency: Duration, _span: &Span| {\n                tracing::info!(\n                    elap_ms = latency.as_millis() as u32,\n                    httpstatus = response.status().as_u16(),\n                    \"finished\"\n                );\n            })\n            .on_failure(()),\n    )\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/media_manager/download.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::io::ErrorKind;\n\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\nuse snafu::ResultExt;\n\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_server::media_manager::ServerMediaManager;\nuse crate::sync::media::database::server::entry::MediaEntry;\nuse crate::sync::media::zip::zip_files_for_download;\n\nimpl ServerMediaManager {\n    pub fn zip_files_for_download(&mut self, files: Vec<String>) -> HttpResult<Vec<u8>> {\n        let entries = self.db.get_entries_for_download(&files)?;\n        let filenames_with_data = self.gather_file_data(&entries)?;\n        zip_files_for_download(filenames_with_data).or_internal_err(\"zip files\")\n    }\n\n    /// Mutable for the missing file case.\n    fn gather_file_data(&mut self, entries: &[MediaEntry]) -> HttpResult<Vec<(String, Vec<u8>)>> {\n        let mut out = vec![];\n        for entry in entries {\n            let path = self.media_folder.join(&entry.nfc_filename);\n            match fs::read(&path) {\n                Ok(data) => out.push((entry.nfc_filename.clone(), data)),\n                Err(err) if err.kind() == ErrorKind::NotFound => {\n                    self.db\n                        .forget_missing_file(entry)\n                        .or_internal_err(\"forget missing\")?;\n                    None.or_conflict(format!(\n                        \"requested a file that doesn't exist: {}\",\n                        entry.nfc_filename\n                    ))?;\n                }\n                Err(err) => Err(err)\n                    .context(FileIoSnafu {\n                        path,\n                        op: FileOp::Read,\n                    })\n                    .or_internal_err(\"gather file data\")?,\n            }\n        }\n        Ok(out)\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/media_manager/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod download;\npub mod upload;\n\nuse std::path::Path;\nuse std::path::PathBuf;\n\nuse anki_io::create_dir_all;\n\nuse crate::prelude::*;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::media::changes::MediaChange;\nuse crate::sync::media::database::server::ServerMediaDatabase;\nuse crate::sync::media::sanity::MediaSanityCheckResponse;\n\npub(crate) struct ServerMediaManager {\n    pub media_folder: PathBuf,\n    pub db: ServerMediaDatabase,\n}\n\nimpl ServerMediaManager {\n    pub(crate) fn new(user_folder: &Path) -> HttpResult<ServerMediaManager> {\n        let media_folder = user_folder.join(\"media\");\n        create_dir_all(&media_folder).or_internal_err(\"media folder create\")?;\n        Ok(Self {\n            media_folder,\n            db: ServerMediaDatabase::new(&user_folder.join(\"media.db\"))\n                .or_internal_err(\"open media db\")?,\n        })\n    }\n\n    pub fn last_usn(&self) -> HttpResult<Usn> {\n        self.db.last_usn().or_internal_err(\"get last usn\")\n    }\n\n    pub fn media_changes_chunk(&self, after_usn: Usn) -> HttpResult<Vec<MediaChange>> {\n        self.db\n            .media_changes_chunk(after_usn)\n            .or_internal_err(\"changes chunk\")\n    }\n\n    pub fn sanity_check(&self, client_file_count: u32) -> HttpResult<MediaSanityCheckResponse> {\n        let server = self\n            .db\n            .nonempty_file_count()\n            .or_internal_err(\"get nonempty count\")?;\n        Ok(if server == client_file_count {\n            MediaSanityCheckResponse::Ok\n        } else {\n            MediaSanityCheckResponse::SanityCheckFailed\n        })\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/media_manager/upload.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fs;\nuse std::io::ErrorKind;\nuse std::path::Path;\n\nuse anki_io::write_file;\nuse anki_io::FileIoError;\nuse anki_io::FileIoSnafu;\nuse anki_io::FileOp;\nuse snafu::ResultExt;\nuse tracing::info;\n\nuse crate::error;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_server::media_manager::ServerMediaManager;\nuse crate::sync::media::database::server::entry::upload::UploadedChangeResult;\nuse crate::sync::media::upload::MediaUploadResponse;\nuse crate::sync::media::zip::unzip_and_validate_files;\n\nimpl ServerMediaManager {\n    pub fn process_uploaded_changes(\n        &mut self,\n        zip_data: Vec<u8>,\n    ) -> HttpResult<MediaUploadResponse> {\n        let extracted = unzip_and_validate_files(&zip_data).or_bad_request(\"unzip files\")?;\n        let folder = &self.media_folder;\n        let mut processed = 0;\n        let new_usn = self\n            .db\n            .with_transaction(|db, meta| {\n                for change in extracted {\n                    match db.register_uploaded_change(meta, change)? {\n                        UploadedChangeResult::FileAlreadyDeleted { filename } => {\n                            info!(filename, \"already deleted\");\n                        }\n                        UploadedChangeResult::FileIdentical { filename, sha1 } => {\n                            info!(filename, sha1 = hex::encode(sha1), \"already have\");\n                        }\n                        UploadedChangeResult::Added {\n                            filename,\n                            data,\n                            sha1,\n                        } => {\n                            info!(filename, sha1 = hex::encode(sha1), \"added\");\n                            add_or_replace_file(&folder.join(filename), data)?;\n                        }\n                        UploadedChangeResult::Replaced {\n                            filename,\n                            data,\n                            old_sha1,\n                            new_sha1,\n                        } => {\n                            info!(\n                                filename,\n                                old_sha1 = hex::encode(old_sha1),\n                                new_sha1 = hex::encode(new_sha1),\n                                \"replaced\"\n                            );\n                            add_or_replace_file(&folder.join(filename), data)?;\n                        }\n                        UploadedChangeResult::Removed { filename, sha1 } => {\n                            info!(filename, sha1 = hex::encode(sha1), \"removed\");\n                            remove_file(&folder.join(filename))?;\n                        }\n                    }\n                    processed += 1;\n                }\n                Ok(())\n            })\n            .or_internal_err(\"handle uploaded change\")?;\n        Ok(MediaUploadResponse {\n            processed,\n            current_usn: new_usn,\n        })\n    }\n}\n\nfn add_or_replace_file(path: &Path, data: Vec<u8>) -> error::Result<(), FileIoError> {\n    write_file(path, data)\n}\n\nfn remove_file(path: &Path) -> error::Result<(), FileIoError> {\n    if let Err(err) = fs::remove_file(path) {\n        // if transaction was previously aborted, the file may have already been deleted\n        if err.kind() != ErrorKind::NotFound {\n            return Err(err).context(FileIoSnafu {\n                path,\n                op: FileOp::Remove,\n            });\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod handlers;\nmod logging;\nmod media_manager;\nmod routes;\nmod user;\n\nuse std::collections::HashMap;\nuse std::future::Future;\nuse std::future::IntoFuture;\nuse std::net::IpAddr;\nuse std::net::SocketAddr;\nuse std::path::Path;\nuse std::path::PathBuf;\nuse std::pin::Pin;\nuse std::sync::Arc;\nuse std::sync::Mutex;\n\nuse anki_io::create_dir_all;\nuse axum::extract::DefaultBodyLimit;\nuse axum::routing::get;\nuse axum::Router;\nuse axum_client_ip::ClientIpSource;\nuse pbkdf2::password_hash::PasswordHash;\nuse pbkdf2::password_hash::PasswordHasher;\nuse pbkdf2::password_hash::PasswordVerifier;\nuse pbkdf2::password_hash::SaltString;\nuse pbkdf2::Pbkdf2;\nuse snafu::whatever;\nuse snafu::OptionExt;\nuse snafu::ResultExt;\nuse snafu::Whatever;\nuse tokio::net::TcpListener;\nuse tracing::Span;\n\nuse crate::error;\nuse crate::media::files::sha1_of_data;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_server::logging::with_logging_layer;\nuse crate::sync::http_server::media_manager::ServerMediaManager;\nuse crate::sync::http_server::routes::collection_sync_router;\nuse crate::sync::http_server::routes::health_check_handler;\nuse crate::sync::http_server::routes::media_sync_router;\nuse crate::sync::http_server::user::User;\nuse crate::sync::login::HostKeyRequest;\nuse crate::sync::login::HostKeyResponse;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES;\nuse crate::sync::response::SyncResponse;\n\npub struct SimpleServer {\n    state: Mutex<SimpleServerInner>,\n}\n\npub struct SimpleServerInner {\n    /// hkey->user\n    users: HashMap<String, User>,\n}\n\n#[derive(serde::Deserialize, Debug)]\npub struct SyncServerConfig {\n    #[serde(default = \"default_host\")]\n    pub host: IpAddr,\n    #[serde(default = \"default_port\")]\n    pub port: u16,\n    #[serde(default = \"default_base\", rename = \"base\")]\n    pub base_folder: PathBuf,\n    #[serde(default = \"default_ip_header\")]\n    pub ip_header: ClientIpSource,\n}\n\nfn default_host() -> IpAddr {\n    \"0.0.0.0\".parse().unwrap()\n}\n\nfn default_port() -> u16 {\n    8080\n}\n\nfn default_base() -> PathBuf {\n    dirs::home_dir()\n        .unwrap_or_else(|| panic!(\"Unable to determine home folder; please set SYNC_BASE\"))\n        .join(\".syncserver\")\n}\n\npub fn default_ip_header() -> ClientIpSource {\n    ClientIpSource::ConnectInfo\n}\n\nimpl SimpleServerInner {\n    fn new_from_env(base_folder: &Path) -> error::Result<Self, Whatever> {\n        let mut idx = 1;\n        let mut users: HashMap<String, User> = Default::default();\n        loop {\n            let envvar = format!(\"SYNC_USER{idx}\");\n            match std::env::var(&envvar) {\n                Ok(val) => {\n                    let hkey = derive_hkey(&val);\n                    let (name, pwhash) = {\n                        let (name, password) = val.split_once(':').with_whatever_context(|| {\n                            format!(\"{envvar} should be in 'username:password' format.\")\n                        })?;\n                        if std::env::var(\"PASSWORDS_HASHED\").is_ok() {\n                            (name, password.to_string())\n                        } else {\n                            (\n                                name,\n                                // Plain text passwords provided; hash them with a fixed salt.\n                                Pbkdf2\n                                    .hash_password(\n                                        password.as_bytes(),\n                                        &SaltString::from_b64(\"tonuvYGpksNFQBlEmm3lxg\").unwrap(),\n                                    )\n                                    .expect(\"couldn't hash password\")\n                                    .to_string(),\n                            )\n                        }\n                    };\n                    let folder = base_folder.join(name);\n                    create_dir_all(&folder).whatever_context(\"creating SYNC_BASE\")?;\n                    let media =\n                        ServerMediaManager::new(&folder).whatever_context(\"opening media\")?;\n                    users.insert(\n                        hkey,\n                        User {\n                            name: name.into(),\n                            password_hash: pwhash,\n                            col: None,\n                            sync_state: None,\n                            media,\n                            folder,\n                        },\n                    );\n                    idx += 1;\n                }\n                Err(_) => break,\n            }\n        }\n        if users.is_empty() {\n            whatever!(\"No users defined; SYNC_USER1 env var should be set.\");\n        }\n        Ok(Self { users })\n    }\n}\n\n// This is not what AnkiWeb does, but should suffice for this use case.\nfn derive_hkey(user_and_pass: &str) -> String {\n    hex::encode(sha1_of_data(user_and_pass.as_bytes()))\n}\n\nimpl SimpleServer {\n    pub(in crate::sync) async fn with_authenticated_user<F, I, O>(\n        &self,\n        req: SyncRequest<I>,\n        op: F,\n    ) -> HttpResult<O>\n    where\n        F: FnOnce(&mut User, SyncRequest<I>) -> HttpResult<O>,\n    {\n        let mut state = self.state.lock().unwrap();\n        let user = state\n            .users\n            .get_mut(&req.sync_key)\n            .or_forbidden(\"invalid hkey\")?;\n        Span::current().record(\"uid\", &user.name);\n        Span::current().record(\"client\", &req.client_version);\n        Span::current().record(\"session\", &req.session_key);\n        op(user, req)\n    }\n\n    pub(in crate::sync) fn get_host_key(\n        &self,\n        request: HostKeyRequest,\n    ) -> HttpResult<SyncResponse<HostKeyResponse>> {\n        let state = self.state.lock().unwrap();\n\n        // This control structure might seem a bit crude,\n        // its goal is to prevent a timing attack from gaining\n        // information about whether a specific user exists.\n        let user = {\n            // This inner block returns Ok(hkey,user) if a user with corresponding\n            // name is found and Err(user) with a random user if it isn't found.\n            // The user is needed to verify against a random hash,\n            // before returning an Error.\n            let mut result: Result<(String, &User), &User> =\n                Err(state.users.iter().next().unwrap().1);\n            for (hkey, user) in state.users.iter() {\n                if user.name == request.username {\n                    result = Ok((hkey.to_string(), user));\n                }\n            }\n            result\n        };\n\n        match user {\n            Ok((key, user)) => {\n                // Verify password\n                let pwhash =\n                    &PasswordHash::new(&user.password_hash).expect(\"couldn't parse password hash\");\n                if Pbkdf2\n                    .verify_password(request.password.as_bytes(), pwhash)\n                    .is_ok()\n                {\n                    SyncResponse::try_from_obj(HostKeyResponse { key })\n                } else {\n                    None.or_forbidden(\"invalid user/pass in get_host_key\")\n                }\n            }\n            Err(user) => {\n                // Verify random password, in order to ensure constant-timedness,\n                // then return an error\n                let pwhash =\n                    &PasswordHash::new(&user.password_hash).expect(\"couldn't parse password hash\");\n                let _ = Pbkdf2.verify_password(request.password.as_bytes(), pwhash);\n                None.or_forbidden(\"invalid user/pass in get_host_key\")\n            }\n        }\n    }\n    pub fn is_running() -> bool {\n        let config = envy::prefixed(\"SYNC_\")\n            .from_env::<SyncServerConfig>()\n            .unwrap();\n        std::net::TcpStream::connect(format!(\"{}:{}\", config.host, config.port)).is_ok()\n    }\n    pub fn new(base_folder: &Path) -> error::Result<Self, Whatever> {\n        let inner = SimpleServerInner::new_from_env(base_folder)?;\n        Ok(SimpleServer {\n            state: Mutex::new(inner),\n        })\n    }\n\n    pub async fn make_server(\n        config: SyncServerConfig,\n    ) -> error::Result<(SocketAddr, ServerFuture), Whatever> {\n        let server = Arc::new(\n            SimpleServer::new(&config.base_folder).whatever_context(\"unable to create server\")?,\n        );\n        let address = &format!(\"{}:{}\", config.host, config.port);\n        let listener = TcpListener::bind(address)\n            .await\n            .with_whatever_context(|_| format!(\"couldn't bind to {address}\"))?;\n        let addr = listener.local_addr().unwrap();\n        let server = with_logging_layer(\n            Router::new()\n                .nest(\"/sync\", collection_sync_router())\n                .nest(\"/msync\", media_sync_router())\n                .route(\"/health\", get(health_check_handler))\n                .with_state(server)\n                .layer(DefaultBodyLimit::max(*MAXIMUM_SYNC_PAYLOAD_BYTES))\n                .layer(config.ip_header.into_extension()),\n        );\n        let future = axum::serve(\n            listener,\n            server.into_make_service_with_connect_info::<SocketAddr>(),\n        )\n        .with_graceful_shutdown(async {\n            let _ = tokio::signal::ctrl_c().await;\n        })\n        .into_future();\n        tracing::info!(%addr, \"listening\");\n        Ok((addr, Box::pin(future)))\n    }\n\n    #[snafu::report]\n    #[tokio::main]\n    pub async fn run() -> error::Result<(), Whatever> {\n        let config = envy::prefixed(\"SYNC_\")\n            .from_env::<SyncServerConfig>()\n            .whatever_context(\"reading SYNC_* env vars\")?;\n        let (_addr, server_fut) = SimpleServer::make_server(config).await?;\n        server_fut.await.whatever_context(\"await server\")?;\n        Ok(())\n    }\n}\n\npub type ServerFuture = Pin<Box<dyn Future<Output = error::Result<(), std::io::Error>> + Send>>;\n"
  },
  {
    "path": "rslib/src/sync/http_server/routes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse axum::extract::Path;\nuse axum::extract::Query;\nuse axum::extract::State;\nuse axum::http::StatusCode;\nuse axum::response::IntoResponse;\nuse axum::response::Response;\nuse axum::routing::get;\nuse axum::routing::post;\nuse axum::Router;\n\nuse crate::sync::collection::protocol::SyncMethod;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::media::begin::SyncBeginQuery;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::protocol::MediaSyncMethod;\nuse crate::sync::media::protocol::MediaSyncProtocol;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::version::SyncVersion;\n\nmacro_rules! sync_method {\n    ($server:ident, $req:ident, $method:ident) => {{\n        let sync_version = $req.sync_version;\n        let obj = $server.$method($req.into_output_type()).await?;\n        obj.make_response(sync_version)\n    }};\n}\n\nasync fn sync_handler<P: SyncProtocol>(\n    Path(method): Path<SyncMethod>,\n    State(server): State<P>,\n    request: SyncRequest<Vec<u8>>,\n) -> HttpResult<Response> {\n    Ok(match method {\n        SyncMethod::HostKey => sync_method!(server, request, host_key),\n        SyncMethod::Meta => sync_method!(server, request, meta),\n        SyncMethod::Start => sync_method!(server, request, start),\n        SyncMethod::ApplyGraves => sync_method!(server, request, apply_graves),\n        SyncMethod::ApplyChanges => sync_method!(server, request, apply_changes),\n        SyncMethod::Chunk => sync_method!(server, request, chunk),\n        SyncMethod::ApplyChunk => sync_method!(server, request, apply_chunk),\n        SyncMethod::SanityCheck2 => sync_method!(server, request, sanity_check),\n        SyncMethod::Finish => sync_method!(server, request, finish),\n        SyncMethod::Abort => sync_method!(server, request, abort),\n        SyncMethod::Upload => sync_method!(server, request, upload),\n        SyncMethod::Download => sync_method!(server, request, download),\n    })\n}\n\npub fn collection_sync_router<P: SyncProtocol + Clone>() -> Router<P> {\n    Router::new().route(\"/{method}\", post(sync_handler::<P>))\n}\n\n/// The Rust code used to send a GET with query params, which was inconsistent\n/// with the rest of our code - map the request into our standard structure.\nasync fn media_begin_get<P: MediaSyncProtocol>(\n    Query(req): Query<SyncBeginQuery>,\n    server: State<P>,\n) -> HttpResult<Response> {\n    let host_key = req.host_key;\n    let mut req = SyncBeginRequest {\n        client_version: req.client_version,\n    }\n    .try_into_sync_request()\n    .or_bad_request(\"convert begin\")?;\n    req.sync_key = host_key;\n    req.sync_version = SyncVersion::multipart();\n    media_begin_post(server, req).await\n}\n\n/// Older clients would send client info in the multipart instead of the inner\n/// JSON; Inject it into the json if provided.\nasync fn media_begin_post<P: MediaSyncProtocol>(\n    server: State<P>,\n    mut req: SyncRequest<SyncBeginRequest>,\n) -> HttpResult<Response> {\n    if let Some(ver) = &req.media_client_version {\n        req.data = serde_json::to_vec(&SyncBeginRequest {\n            client_version: ver.clone(),\n        })\n        .or_internal_err(\"serialize begin request\")?;\n    }\n    media_sync_handler(Path(MediaSyncMethod::Begin), server, req.into_output_type()).await\n}\n\npub async fn health_check_handler() -> impl IntoResponse {\n    StatusCode::OK\n}\n\nasync fn media_sync_handler<P: MediaSyncProtocol>(\n    Path(method): Path<MediaSyncMethod>,\n    State(server): State<P>,\n    request: SyncRequest<Vec<u8>>,\n) -> HttpResult<Response> {\n    Ok(match method {\n        MediaSyncMethod::Begin => sync_method!(server, request, begin),\n        MediaSyncMethod::MediaChanges => sync_method!(server, request, media_changes),\n        MediaSyncMethod::UploadChanges => sync_method!(server, request, upload_changes),\n        MediaSyncMethod::DownloadFiles => sync_method!(server, request, download_files),\n        MediaSyncMethod::MediaSanity => sync_method!(server, request, media_sanity_check),\n    })\n}\n\npub fn media_sync_router<P: MediaSyncProtocol + Clone>() -> Router<P> {\n    Router::new()\n        .route(\n            \"/begin\",\n            get(media_begin_get::<P>).post(media_begin_post::<P>),\n        )\n        .route(\"/{method}\", post(media_sync_handler::<P>))\n}\n"
  },
  {
    "path": "rslib/src/sync/http_server/user.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::path::PathBuf;\n\nuse tracing::info;\n\nuse crate::collection::Collection;\nuse crate::collection::CollectionBuilder;\nuse crate::error;\nuse crate::sync::collection::start::ServerSyncState;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::http_server::media_manager::ServerMediaManager;\n\npub(in crate::sync) struct User {\n    pub name: String,\n    pub password_hash: String,\n    pub col: Option<Collection>,\n    pub sync_state: Option<ServerSyncState>,\n    pub media: ServerMediaManager,\n    pub folder: PathBuf,\n}\n\nimpl User {\n    /// Run op with access to the collection. If a sync is active, it's aborted.\n    pub(crate) fn with_col<F, T>(&mut self, op: F) -> HttpResult<T>\n    where\n        F: FnOnce(&mut Collection) -> HttpResult<T>,\n    {\n        self.abort_stateful_sync_if_active();\n        self.ensure_col_open()?;\n        op(self.col.as_mut().unwrap())\n    }\n\n    /// Run op with the existing sync state created by start_new_sync(). If\n    /// there is no existing state, or the current state's key does not\n    /// match, abort the request with a conflict.\n    pub(crate) fn with_sync_state<F, T>(&mut self, skey: &str, op: F) -> HttpResult<T>\n    where\n        F: FnOnce(&mut Collection, &mut ServerSyncState) -> error::Result<T>,\n    {\n        match &self.sync_state {\n            None => None.or_conflict(\"no active sync\")?,\n            Some(state) => {\n                if state.skey != skey {\n                    None.or_conflict(\"active sync with different key\")?;\n                }\n            }\n        };\n\n        self.ensure_col_open()?;\n        let state = self.sync_state.as_mut().unwrap();\n        let col = self.col.as_mut().or_internal_err(\"open col\")?;\n        // Failures in a sync op are usually caused by referential integrity issues (eg\n        // they've sent a note without sending its associated notetype).\n        // Returning HTTP 400 will inform the client that a DB check+full sync\n        // is required to fix the issue.\n        op(col, state)\n            .inspect_err(|_e| {\n                self.col = None;\n                self.sync_state = None;\n            })\n            .or_bad_request(\"op failed in sync_state\")\n    }\n\n    pub(crate) fn abort_stateful_sync_if_active(&mut self) {\n        if self.sync_state.is_some() {\n            info!(\"aborting active sync\");\n            self.sync_state = None;\n            self.col = None;\n        }\n    }\n\n    pub(crate) fn start_new_sync(&mut self, skey: &str) -> HttpResult<()> {\n        self.abort_stateful_sync_if_active();\n        self.sync_state = Some(ServerSyncState::new(skey));\n        Ok(())\n    }\n\n    pub(crate) fn ensure_col_open(&mut self) -> HttpResult<()> {\n        if self.col.is_none() {\n            self.col = Some(self.open_collection()?);\n        }\n        Ok(())\n    }\n\n    fn open_collection(&mut self) -> HttpResult<Collection> {\n        CollectionBuilder::new(self.folder.join(\"collection.anki2\"))\n            .set_server(true)\n            .build()\n            .or_internal_err(\"open collection\")\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/login.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse reqwest::Client;\nuse reqwest::Url;\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::prelude::*;\nuse crate::sync::collection::protocol::SyncProtocol;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::request::IntoSyncRequest;\n\n#[derive(Clone, Default)]\npub struct SyncAuth {\n    pub hkey: String,\n    pub endpoint: Option<Url>,\n    pub io_timeout_secs: Option<u32>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct HostKeyRequest {\n    #[serde(rename = \"u\")]\n    pub username: String,\n    #[serde(rename = \"p\")]\n    pub password: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct HostKeyResponse {\n    pub key: String,\n}\n\npub async fn sync_login<S: Into<String>>(\n    username: S,\n    password: S,\n    endpoint: Option<String>,\n    client: Client,\n) -> Result<SyncAuth> {\n    let auth = anki_proto::sync::SyncAuth {\n        endpoint,\n        ..Default::default()\n    }\n    .try_into()?;\n    let client = HttpSyncClient::new(auth, client);\n    let resp = client\n        .host_key(\n            HostKeyRequest {\n                username: username.into(),\n                password: password.into(),\n            }\n            .try_into_sync_request()?,\n        )\n        .await?\n        .json()?;\n    Ok(SyncAuth {\n        hkey: resp.key,\n        endpoint: None,\n        io_timeout_secs: None,\n    })\n}\n"
  },
  {
    "path": "rslib/src/sync/media/begin.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::prelude::*;\n\n// The old Rust code sent the host key in a query string\n#[derive(Debug, Serialize, Deserialize)]\npub struct SyncBeginQuery {\n    #[serde(rename = \"k\")]\n    pub host_key: String,\n    #[serde(rename = \"v\")]\n    pub client_version: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SyncBeginRequest {\n    /// Older clients provide this in the multipart wrapper; our router will\n    /// inject the value in if necessary. The route handler should check that\n    /// a value has actually been provided.\n    #[serde(rename = \"v\", default)]\n    pub client_version: String,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SyncBeginResponse {\n    pub usn: Usn,\n    /// The server used to send back a session key used for following requests,\n    /// but this is no longer required. To avoid breaking older clients, the\n    /// host key is returned in its place.\n    #[serde(rename = \"sk\")]\n    pub host_key: String,\n}\n"
  },
  {
    "path": "rslib/src/sync/media/changes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\nuse serde_tuple::Serialize_tuple;\nuse tracing::debug;\n\nuse crate::error;\nuse crate::prelude::Usn;\nuse crate::sync::media::database::client::MediaDatabase;\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct MediaChangesRequest {\n    pub last_usn: Usn,\n}\n\npub type MediaChangesResponse = Vec<MediaChange>;\n\n#[derive(Debug, Serialize_tuple, Deserialize)]\npub struct MediaChange {\n    pub fname: String,\n    pub usn: Usn,\n    pub sha1: String,\n}\n\n#[derive(Debug, Clone, Copy)]\npub enum LocalState {\n    NotInDb,\n    InDbNotPending,\n    InDbAndPending,\n}\n\n#[derive(PartialEq, Eq, Debug)]\npub enum RequiredChange {\n    // none also covers the case where we'll later upload\n    None,\n    Download,\n    Delete,\n    RemovePending,\n}\n\npub fn determine_required_change(\n    local_sha1: &str,\n    remote_sha1: &str,\n    local_state: LocalState,\n) -> RequiredChange {\n    match (local_sha1, remote_sha1, local_state) {\n        // both deleted, not in local DB\n        (\"\", \"\", LocalState::NotInDb) => RequiredChange::None,\n        // both deleted, in local DB\n        (\"\", \"\", _) => RequiredChange::Delete,\n        // added on server, add even if local deletion pending\n        (\"\", _, _) => RequiredChange::Download,\n        // deleted on server but added locally; upload later\n        (_, \"\", LocalState::InDbAndPending) => RequiredChange::None,\n        // deleted on server and not pending sync\n        (_, \"\", _) => RequiredChange::Delete,\n        // if pending but the same as server, don't need to upload\n        (lsum, rsum, LocalState::InDbAndPending) if lsum == rsum => RequiredChange::RemovePending,\n        (lsum, rsum, _) => {\n            if lsum == rsum {\n                // not pending and same as server, nothing to do\n                RequiredChange::None\n            } else {\n                // differs from server, favour server\n                RequiredChange::Download\n            }\n        }\n    }\n}\n\n/// Get a list of server filenames and the actions required on them.\n/// Returns filenames in (to_download, to_delete).\npub fn determine_required_changes(\n    ctx: &MediaDatabase,\n    records: Vec<MediaChange>,\n) -> error::Result<(Vec<String>, Vec<String>, Vec<String>)> {\n    let mut to_download = vec![];\n    let mut to_delete = vec![];\n    let mut to_remove_pending = vec![];\n\n    for remote in records {\n        let (local_sha1, local_state) = match ctx.get_entry(&remote.fname)? {\n            Some(entry) => (\n                match entry.sha1 {\n                    Some(arr) => hex::encode(arr),\n                    None => \"\".to_string(),\n                },\n                if entry.sync_required {\n                    LocalState::InDbAndPending\n                } else {\n                    LocalState::InDbNotPending\n                },\n            ),\n            None => (\"\".to_string(), LocalState::NotInDb),\n        };\n\n        let req_change = determine_required_change(&local_sha1, &remote.sha1, local_state);\n        debug!(\n            fname = &remote.fname,\n            lsha = local_sha1.chars().take(8).collect::<String>(),\n            rsha = remote.sha1.chars().take(8).collect::<String>(),\n            state = ?local_state,\n            action = ?req_change,\n            \"determine action\"\n        );\n        match req_change {\n            RequiredChange::Download => to_download.push(remote.fname),\n            RequiredChange::Delete => to_delete.push(remote.fname),\n            RequiredChange::RemovePending => to_remove_pending.push(remote.fname),\n            RequiredChange::None => (),\n        };\n    }\n\n    Ok((to_download, to_delete, to_remove_pending))\n}\n\n#[cfg(test)]\nmod test {\n\n    #[test]\n    fn required_change() {\n        use crate::sync::media::changes::determine_required_change as d;\n        use crate::sync::media::changes::LocalState as L;\n        use crate::sync::media::changes::RequiredChange as R;\n        assert_eq!(d(\"\", \"\", L::NotInDb), R::None);\n        assert_eq!(d(\"\", \"\", L::InDbNotPending), R::Delete);\n        assert_eq!(d(\"\", \"1\", L::InDbAndPending), R::Download);\n        assert_eq!(d(\"1\", \"\", L::InDbAndPending), R::None);\n        assert_eq!(d(\"1\", \"\", L::InDbNotPending), R::Delete);\n        assert_eq!(d(\"1\", \"1\", L::InDbNotPending), R::None);\n        assert_eq!(d(\"1\", \"1\", L::InDbAndPending), R::RemovePending);\n        assert_eq!(d(\"a\", \"b\", L::InDbAndPending), R::Download);\n        assert_eq!(d(\"a\", \"b\", L::InDbNotPending), R::Download);\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/client/changetracker.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::path::Path;\nuse std::time;\n\nuse anki_io::read_dir_files;\nuse tracing::debug;\n\nuse crate::media::files::filename_if_normalized;\nuse crate::media::files::mtime_as_i64;\nuse crate::media::files::sha1_of_file;\nuse crate::media::files::NONSYNCABLE_FILENAME;\nuse crate::prelude::*;\nuse crate::sync::media::database::client::MediaDatabase;\nuse crate::sync::media::database::client::MediaEntry;\nuse crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE;\n\nstruct FilesystemEntry {\n    fname: String,\n    sha1: Option<Sha1Hash>,\n    mtime: i64,\n    is_new: bool,\n}\n\npub(crate) struct ChangeTracker<'a, F>\nwhere\n    F: FnMut(usize) -> bool,\n{\n    media_folder: &'a Path,\n    progress_cb: F,\n    checked: usize,\n}\n\nimpl<F> ChangeTracker<'_, F>\nwhere\n    F: FnMut(usize) -> bool,\n{\n    pub(crate) fn new(media_folder: &Path, progress: F) -> ChangeTracker<'_, F> {\n        ChangeTracker {\n            media_folder,\n            progress_cb: progress,\n            checked: 0,\n        }\n    }\n\n    fn fire_progress_cb(&mut self) -> Result<()> {\n        if (self.progress_cb)(self.checked) {\n            Ok(())\n        } else {\n            Err(AnkiError::Interrupted)\n        }\n    }\n\n    pub(crate) fn register_changes(&mut self, ctx: &MediaDatabase) -> Result<()> {\n        ctx.transact(|ctx| {\n            // folder mtime unchanged?\n            let dirmod = mtime_as_i64(self.media_folder)?;\n\n            let mut meta = ctx.get_meta()?;\n            debug!(\n                folder_mod = dirmod,\n                db_mod = meta.folder_mtime,\n                \"begin change check\"\n            );\n            if dirmod == meta.folder_mtime {\n                debug!(\"skip check\");\n                return Ok(());\n            } else {\n                meta.folder_mtime = dirmod;\n            }\n\n            let mtimes = ctx.all_mtimes()?;\n            self.checked += mtimes.len();\n            self.fire_progress_cb()?;\n\n            let (changed, removed) = self.media_folder_changes(mtimes)?;\n\n            self.add_updated_entries(ctx, changed)?;\n            self.remove_deleted_files(ctx, removed)?;\n\n            ctx.set_meta(&meta)?;\n\n            // unconditional fire at end of op for accurate counts\n            self.fire_progress_cb()?;\n\n            Ok(())\n        })\n    }\n\n    /// Scan through the media folder, finding changes.\n    /// Returns (added/changed files, removed files).\n    fn media_folder_changes(\n        &mut self,\n        mut mtimes: HashMap<String, i64>,\n    ) -> Result<(Vec<FilesystemEntry>, Vec<String>)> {\n        let mut added_or_changed = vec![];\n\n        // loop through on-disk files\n        for dentry in read_dir_files(self.media_folder)? {\n            let dentry = dentry?;\n\n            // if the filename is not valid unicode, skip it\n            let fname_os = dentry.file_name();\n            let disk_fname = match fname_os.to_str() {\n                Some(s) => s,\n                None => continue,\n            };\n\n            // make sure the filename is normalized\n            let fname = match filename_if_normalized(disk_fname) {\n                Some(fname) => fname,\n                None => {\n                    // not normalized; skip it\n                    debug!(fname = disk_fname, \"ignore non-normalized\");\n                    continue;\n                }\n            };\n\n            // ignore blacklisted files\n            if NONSYNCABLE_FILENAME.is_match(fname.as_ref()) {\n                continue;\n            }\n\n            // ignore large files and zero byte files\n            let metadata = dentry.metadata()?;\n            if metadata.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 {\n                continue;\n            }\n            if metadata.len() == 0 {\n                continue;\n            }\n\n            // remove from mtimes for later deletion tracking\n            let previous_mtime = mtimes.remove(fname.as_ref());\n\n            // skip files that have not been modified\n            let mtime = metadata\n                .modified()?\n                .duration_since(time::UNIX_EPOCH)\n                .unwrap()\n                .as_secs() as i64;\n            if let Some(previous_mtime) = previous_mtime {\n                if previous_mtime == mtime {\n                    debug!(fname = fname.as_ref(), \"mtime unchanged\");\n                    continue;\n                }\n            }\n\n            // add entry to the list\n            let data = sha1_of_file(&dentry.path())?;\n            let sha1 = Some(data);\n            added_or_changed.push(FilesystemEntry {\n                fname: fname.to_string(),\n                sha1,\n                mtime,\n                is_new: previous_mtime.is_none(),\n            });\n            debug!(\n                fname = fname.as_ref(),\n                mtime,\n                sha1 = sha1.as_ref().map(|s| hex::encode(&s[0..4])),\n                \"added or changed\"\n            );\n\n            self.checked += 1;\n            if self.checked % 10 == 0 {\n                self.fire_progress_cb()?;\n            }\n        }\n\n        // any remaining entries from the database have been deleted\n        let removed: Vec<_> = mtimes.into_keys().collect();\n        for f in &removed {\n            debug!(fname = f, \"db entry missing on disk\");\n        }\n\n        Ok((added_or_changed, removed))\n    }\n\n    /// Add added/updated entries to the media DB.\n    ///\n    /// Skip files where the mod time differed, but checksums are the same.\n    fn add_updated_entries(\n        &mut self,\n        ctx: &MediaDatabase,\n        entries: Vec<FilesystemEntry>,\n    ) -> Result<()> {\n        for fentry in entries {\n            let mut sync_required = true;\n            if !fentry.is_new {\n                if let Some(db_entry) = ctx.get_entry(&fentry.fname)? {\n                    if db_entry.sha1 == fentry.sha1 {\n                        // mtime bumped but file contents are the same,\n                        // so we can preserve the current updated flag.\n                        // we still need to update the mtime however.\n                        sync_required = db_entry.sync_required\n                    }\n                }\n            };\n\n            ctx.set_entry(&MediaEntry {\n                fname: fentry.fname,\n                sha1: fentry.sha1,\n                mtime: fentry.mtime,\n                sync_required,\n            })?;\n\n            self.checked += 1;\n            if self.checked % 10 == 0 {\n                self.fire_progress_cb()?;\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Remove deleted files from the media DB.\n    fn remove_deleted_files(&mut self, ctx: &MediaDatabase, removed: Vec<String>) -> Result<()> {\n        for fname in removed {\n            ctx.set_entry(&MediaEntry {\n                fname,\n                sha1: None,\n                mtime: 0,\n                sync_required: true,\n            })?;\n\n            self.checked += 1;\n            if self.checked % 10 == 0 {\n                self.fire_progress_cb()?;\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use std::fs;\n    use std::fs::FileTimes;\n    use std::path::Path;\n    use std::time;\n    use std::time::Duration;\n\n    use anki_io::create_dir;\n    use anki_io::set_file_times;\n    use anki_io::write_file;\n    use tempfile::tempdir;\n\n    use super::*;\n    use crate::error::Result;\n    use crate::media::files::sha1_of_data;\n    use crate::media::MediaManager;\n    use crate::sync::media::database::client::MediaEntry;\n\n    // helper\n    fn change_mtime(p: &Path) {\n        let mtime = p.metadata().unwrap().modified().unwrap();\n        let new_mtime = mtime - Duration::from_secs(3);\n        let times = FileTimes::new()\n            .set_accessed(new_mtime)\n            .set_modified(new_mtime);\n        set_file_times(p, times).unwrap();\n    }\n\n    #[test]\n    fn change_tracking() -> Result<()> {\n        let dir = tempdir()?;\n        let media_dir = dir.path().join(\"media\");\n        create_dir(&media_dir)?;\n        let media_db = dir.path().join(\"media.db\");\n\n        let mgr = MediaManager::new(&media_dir, media_db)?;\n        assert_eq!(mgr.db.count()?, 0);\n\n        // add a file and check it's picked up\n        let f1 = media_dir.join(\"file.jpg\");\n        write_file(&f1, \"hello\")?;\n\n        change_mtime(&media_dir);\n\n        let mut progress_cb = |_n| true;\n\n        mgr.register_changes(&mut progress_cb)?;\n\n        let mut entry = mgr.db.transact(|ctx| {\n            assert_eq!(ctx.count()?, 1);\n            assert!(!ctx.get_pending_uploads(1)?.is_empty());\n            let mut entry = ctx.get_entry(\"file.jpg\")?.unwrap();\n            assert_eq!(\n                entry,\n                MediaEntry {\n                    fname: \"file.jpg\".into(),\n                    sha1: Some(sha1_of_data(b\"hello\")),\n                    mtime: f1\n                        .metadata()?\n                        .modified()?\n                        .duration_since(time::UNIX_EPOCH)\n                        .unwrap()\n                        .as_secs() as i64,\n                    sync_required: true,\n                }\n            );\n\n            // mark it as unmodified\n            entry.sync_required = false;\n            ctx.set_entry(&entry)?;\n            assert!(ctx.get_pending_uploads(1)?.is_empty());\n\n            // modify it\n            write_file(&f1, \"hello1\")?;\n            change_mtime(&f1);\n\n            change_mtime(&media_dir);\n\n            Ok(entry)\n        })?;\n\n        ChangeTracker::new(&mgr.media_folder, progress_cb).register_changes(&mgr.db)?;\n\n        mgr.db.transact(|ctx| {\n            assert_eq!(ctx.count()?, 1);\n            assert!(!ctx.get_pending_uploads(1)?.is_empty());\n            assert_eq!(\n                ctx.get_entry(\"file.jpg\")?.unwrap(),\n                MediaEntry {\n                    fname: \"file.jpg\".into(),\n                    sha1: Some(sha1_of_data(b\"hello1\")),\n                    mtime: f1\n                        .metadata()?\n                        .modified()?\n                        .duration_since(time::UNIX_EPOCH)\n                        .unwrap()\n                        .as_secs() as i64,\n                    sync_required: true,\n                }\n            );\n\n            // mark it as unmodified\n            entry.sync_required = false;\n            ctx.set_entry(&entry)?;\n            assert!(ctx.get_pending_uploads(1)?.is_empty());\n\n            Ok(())\n        })?;\n\n        // delete it\n        fs::remove_file(&f1)?;\n\n        change_mtime(&media_dir);\n\n        ChangeTracker::new(&mgr.media_folder, progress_cb).register_changes(&mgr.db)?;\n\n        assert_eq!(mgr.db.count()?, 0);\n        assert!(!mgr.db.get_pending_uploads(1)?.is_empty());\n        assert_eq!(\n            mgr.db.get_entry(\"file.jpg\")?.unwrap(),\n            MediaEntry {\n                fname: \"file.jpg\".into(),\n                sha1: None,\n                mtime: 0,\n                sync_required: true,\n            }\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/client/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::path::Path;\n\nuse rusqlite::params;\nuse rusqlite::Connection;\nuse rusqlite::OptionalExtension;\nuse rusqlite::Row;\nuse tracing::debug;\n\nuse crate::error;\nuse crate::media::files::AddedFile;\nuse crate::media::Sha1Hash;\nuse crate::prelude::Usn;\nuse crate::prelude::*;\n\npub mod changetracker;\n\npub struct Checksums(HashMap<String, Sha1Hash>);\n\nimpl Checksums {\n    // case-fold filenames when checking files to be imported\n    // to account for case-insensitive filesystems\n    pub fn get(&self, key: impl AsRef<str>) -> Option<&Sha1Hash> {\n        self.0.get(key.as_ref().to_lowercase().as_str())\n    }\n\n    pub fn contains_key(&self, key: impl AsRef<str>) -> bool {\n        self.get(key).is_some()\n    }\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct MediaEntry {\n    pub fname: String,\n    /// If None, file has been deleted\n    pub sha1: Option<Sha1Hash>,\n    // Modification time; 0 if deleted\n    pub mtime: i64,\n    /// True if changed since last sync\n    pub sync_required: bool,\n}\n\n#[derive(Debug, PartialEq, Eq)]\npub struct MediaDatabaseMetadata {\n    pub folder_mtime: i64,\n    pub last_sync_usn: Usn,\n}\n\npub struct MediaDatabase {\n    db: Connection,\n}\n\nimpl MediaDatabase {\n    pub(crate) fn new(db_path: &Path) -> error::Result<MediaDatabase> {\n        Ok(MediaDatabase {\n            db: open_or_create(db_path)?,\n        })\n    }\n\n    /// Execute the provided closure in a transaction, rolling back if\n    /// an error is returned.\n    pub(crate) fn transact<F, R>(&self, func: F) -> error::Result<R>\n    where\n        F: FnOnce(&MediaDatabase) -> error::Result<R>,\n    {\n        self.begin()?;\n\n        let mut res = func(self);\n\n        if res.is_ok() {\n            if let Err(e) = self.commit() {\n                res = Err(e);\n            }\n        }\n\n        if res.is_err() {\n            self.rollback()?;\n        }\n\n        res\n    }\n\n    fn begin(&self) -> error::Result<()> {\n        self.db.execute_batch(\"begin immediate\").map_err(Into::into)\n    }\n\n    fn commit(&self) -> error::Result<()> {\n        self.db.execute_batch(\"commit\").map_err(Into::into)\n    }\n\n    fn rollback(&self) -> error::Result<()> {\n        self.db.execute_batch(\"rollback\").map_err(Into::into)\n    }\n\n    pub(crate) fn get_entry(&self, fname: &str) -> error::Result<Option<MediaEntry>> {\n        self.db\n            .prepare_cached(\n                \"\nselect fname, csum, mtime, dirty from media where fname=?\",\n            )?\n            .query_row(params![fname], row_to_entry)\n            .optional()\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn set_entry(&self, entry: &MediaEntry) -> error::Result<()> {\n        let sha1_str = entry.sha1.map(hex::encode);\n        self.db\n            .prepare_cached(\n                \"\ninsert or replace into media (fname, csum, mtime, dirty)\nvalues (?, ?, ?, ?)\",\n            )?\n            .execute(params![\n                entry.fname,\n                sha1_str,\n                entry.mtime,\n                entry.sync_required\n            ])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn remove_entry(&self, fname: &str) -> error::Result<()> {\n        self.db\n            .prepare_cached(\n                \"\ndelete from media where fname=?\",\n            )?\n            .execute(params![fname])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn get_meta(&self) -> error::Result<MediaDatabaseMetadata> {\n        let mut stmt = self.db.prepare(\"select dirMod, lastUsn from meta\")?;\n\n        stmt.query_row([], |row| {\n            Ok(MediaDatabaseMetadata {\n                folder_mtime: row.get(0)?,\n                last_sync_usn: row.get(1)?,\n            })\n        })\n        .map_err(Into::into)\n    }\n\n    pub(crate) fn set_meta(&self, meta: &MediaDatabaseMetadata) -> error::Result<()> {\n        let mut stmt = self.db.prepare(\"update meta set dirMod = ?, lastUsn = ?\")?;\n        stmt.execute(params![meta.folder_mtime, meta.last_sync_usn])?;\n\n        Ok(())\n    }\n\n    pub(crate) fn count(&self) -> error::Result<u32> {\n        self.db\n            .query_row(\n                \"select count(*) from media where csum is not null\",\n                [],\n                |row| row.get(0),\n            )\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn get_pending_uploads(&self, max_entries: u32) -> error::Result<Vec<MediaEntry>> {\n        let mut stmt = self\n            .db\n            .prepare(\"select fname from media where dirty=1 limit ?\")?;\n        let results: error::Result<Vec<_>> = stmt\n            .query_and_then(params![max_entries], |row| {\n                let fname = row.get_ref_unwrap(0).as_str()?;\n                Ok(self.get_entry(fname)?.unwrap())\n            })?\n            .collect();\n\n        results\n    }\n\n    pub(crate) fn all_mtimes(&self) -> error::Result<HashMap<String, i64>> {\n        let mut stmt = self\n            .db\n            .prepare(\"select fname, mtime from media where csum is not null\")?;\n        let map: std::result::Result<HashMap<String, i64>, rusqlite::Error> = stmt\n            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?\n            .collect();\n        Ok(map?)\n    }\n\n    /// Returns all filenames and checksums, where the checksum is not null.\n    pub(crate) fn all_registered_checksums(&self) -> error::Result<Checksums> {\n        self.db\n            .prepare(\"SELECT fname, csum FROM media WHERE csum IS NOT NULL\")?\n            .query_and_then([], row_to_name_and_checksum)?\n            .collect::<error::Result<_>>()\n            .map(Checksums)\n    }\n\n    pub(crate) fn force_resync(&self) -> error::Result<()> {\n        self.db\n            .execute_batch(\"delete from media; update meta set lastUsn = 0, dirMod = 0\")\n            .map_err(Into::into)\n    }\n\n    pub(crate) fn record_clean(&self, clean: &[String]) -> error::Result<()> {\n        for fname in clean {\n            if let Some(mut entry) = self.get_entry(fname)? {\n                if entry.sync_required {\n                    entry.sync_required = false;\n                    debug!(fname = &entry.fname, \"mark clean\");\n                    self.set_entry(&entry)?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    pub fn record_additions(&self, additions: Vec<AddedFile>) -> error::Result<()> {\n        for file in additions {\n            if let Some(renamed) = file.renamed_from {\n                // the file AnkiWeb sent us wasn't normalized, so we need to record\n                // the old file name as a deletion\n                debug!(\"marking non-normalized file as deleted: {}\", renamed);\n                let mut entry = MediaEntry {\n                    fname: renamed,\n                    sha1: None,\n                    mtime: 0,\n                    sync_required: true,\n                };\n                self.set_entry(&entry)?;\n                // and upload the new filename to ankiweb\n                debug!(\"marking renamed file as needing upload: {}\", file.fname);\n                entry = MediaEntry {\n                    fname: file.fname.to_string(),\n                    sha1: Some(file.sha1),\n                    mtime: file.mtime,\n                    sync_required: true,\n                };\n                self.set_entry(&entry)?;\n            } else {\n                // a normal addition\n                let entry = MediaEntry {\n                    fname: file.fname.to_string(),\n                    sha1: Some(file.sha1),\n                    mtime: file.mtime,\n                    sync_required: false,\n                };\n                debug!(\n                    fname = &entry.fname,\n                    sha1 = hex::encode(&entry.sha1.as_ref().unwrap()[0..4]),\n                    \"mark added\"\n                );\n                self.set_entry(&entry)?;\n            }\n        }\n\n        Ok(())\n    }\n\n    pub fn record_removals(&self, removals: &[String]) -> error::Result<()> {\n        for fname in removals {\n            debug!(fname, \"mark removed\");\n            self.remove_entry(fname)?;\n        }\n        Ok(())\n    }\n}\n\nfn row_to_entry(row: &Row) -> rusqlite::Result<MediaEntry> {\n    // map the string checksum into bytes\n    let sha1_str = row.get_ref(1)?.as_str_or_null()?;\n    let sha1_array = if let Some(s) = sha1_str {\n        let mut arr = [0; 20];\n        match hex::decode_to_slice(s, arr.as_mut()) {\n            Ok(_) => Some(arr),\n            _ => None,\n        }\n    } else {\n        None\n    };\n    // and return the entry\n    Ok(MediaEntry {\n        fname: row.get(0)?,\n        sha1: sha1_array,\n        mtime: row.get(2)?,\n        sync_required: row.get(3)?,\n    })\n}\n\nfn row_to_name_and_checksum(row: &Row) -> error::Result<(String, Sha1Hash)> {\n    let file_name = row.get(0)?;\n    let sha1_str: String = row.get(1)?;\n    let mut sha1 = [0; 20];\n    if let Err(err) = hex::decode_to_slice(sha1_str, &mut sha1) {\n        invalid_input!(err, \"bad media checksum: {file_name}\");\n    }\n    Ok((file_name, sha1))\n}\n\nfn trace(event: rusqlite::trace::TraceEvent) {\n    if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event {\n        println!(\"sql: {sql}\");\n    }\n}\n\npub(crate) fn open_or_create<P: AsRef<Path>>(path: P) -> error::Result<Connection> {\n    let mut db = Connection::open(path)?;\n\n    if std::env::var(\"TRACESQL\").is_ok() {\n        db.trace_v2(\n            rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,\n            Some(trace),\n        );\n    }\n\n    db.pragma_update(None, \"page_size\", 4096)?;\n    db.pragma_update(None, \"legacy_file_format\", false)?;\n    db.pragma_update_and_check(None, \"journal_mode\", \"wal\", |_| Ok(()))?;\n\n    initial_db_setup(&mut db)?;\n\n    Ok(db)\n}\n\nfn initial_db_setup(db: &mut Connection) -> error::Result<()> {\n    // tables already exist?\n    if db\n        .prepare_cached(\"select null from sqlite_master where type = 'table' and name = 'media'\")?\n        .exists([])?\n    {\n        return Ok(());\n    }\n\n    db.execute(\"begin\", [])?;\n    db.execute_batch(include_str!(\"schema.sql\"))?;\n    db.execute_batch(\"commit; vacuum; analyze;\")?;\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod test {\n    use anki_io::new_tempfile;\n    use tempfile::TempDir;\n\n    use crate::error::Result;\n    use crate::media::files::sha1_of_data;\n    use crate::media::MediaManager;\n    use crate::sync::media::database::client::MediaEntry;\n\n    #[test]\n    fn database() -> Result<()> {\n        let folder = TempDir::new()?;\n        let db_file = new_tempfile()?;\n        let db_file_path = db_file.path().to_str().unwrap();\n        let mut mgr = MediaManager::new(folder.path(), db_file_path)?;\n        mgr.db.transact(|ctx| {\n            // no entry exists yet\n            assert_eq!(ctx.get_entry(\"test.mp3\")?, None);\n\n            // add one\n            let mut entry = MediaEntry {\n                fname: \"test.mp3\".into(),\n                sha1: None,\n                mtime: 0,\n                sync_required: false,\n            };\n            ctx.set_entry(&entry)?;\n            assert_eq!(ctx.get_entry(\"test.mp3\")?.unwrap(), entry);\n\n            // update it\n            entry.sha1 = Some(sha1_of_data(b\"hello\"));\n            entry.mtime = 123;\n            entry.sync_required = true;\n            ctx.set_entry(&entry)?;\n            assert_eq!(ctx.get_entry(\"test.mp3\")?.unwrap(), entry);\n\n            assert_eq!(ctx.get_pending_uploads(25)?, vec![entry]);\n\n            let mut meta = ctx.get_meta()?;\n            assert_eq!(meta.folder_mtime, 0);\n            assert_eq!(meta.last_sync_usn.0, 0);\n\n            meta.folder_mtime = 123;\n            meta.last_sync_usn.0 = 321;\n\n            ctx.set_meta(&meta)?;\n\n            meta = ctx.get_meta()?;\n            assert_eq!(meta.folder_mtime, 123);\n            assert_eq!(meta.last_sync_usn.0, 321);\n\n            Ok(())\n        })?;\n\n        // reopen database and ensure data was committed\n        drop(mgr);\n        mgr = MediaManager::new(folder.path(), db_file_path)?;\n        let meta = mgr.db.get_meta()?;\n        assert_eq!(meta.folder_mtime, 123);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/client/schema.sql",
    "content": "CREATE TABLE media (\n  fname text NOT NULL PRIMARY KEY,\n  -- null indicates deleted file\n  csum text,\n  -- zero if deleted\n  mtime int NOT NULL,\n  dirty int NOT NULL\n) without rowid;\nCREATE INDEX idx_media_dirty ON media (dirty)\nWHERE dirty = 1;\nCREATE TABLE meta (dirMod int, lastUsn int);\nINSERT INTO meta\nVALUES (0, 0);"
  },
  {
    "path": "rslib/src/sync/media/database/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod client;\npub mod server;\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/changes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rusqlite::Row;\n\nuse crate::prelude::*;\nuse crate::sync::media::changes::MediaChange;\nuse crate::sync::media::database::server::ServerMediaDatabase;\n\nimpl MediaChange {\n    fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {\n        Ok(Self {\n            fname: row.get(0)?,\n            usn: row.get(1)?,\n            sha1: row.get(2)?,\n        })\n    }\n}\nimpl ServerMediaDatabase {\n    pub fn media_changes_chunk(&self, after_usn: Usn) -> Result<Vec<MediaChange>> {\n        Ok(self\n            .db\n            .prepare_cached(include_str!(\"changes.sql\"))?\n            .query_map([after_usn], MediaChange::from_row)?\n            .collect::<Result<_, _>>()?)\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/changes.sql",
    "content": "SELECT fname,\n  usn,\n  (\n    CASE\n      WHEN size > 0 THEN lower(hex(csum))\n      ELSE ''\n    END\n  )\nFROM media\nWHERE usn > ?\nLIMIT 1000"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/download.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rusqlite::params;\n\nuse crate::error;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::media::database::server::entry::MediaEntry;\nuse crate::sync::media::database::server::ServerMediaDatabase;\nuse crate::sync::media::MAX_MEDIA_FILES_IN_ZIP;\nuse crate::sync::media::MEDIA_SYNC_TARGET_ZIP_BYTES;\n\nimpl ServerMediaDatabase {\n    /// Return a list of entries in the same order as the provided files,\n    /// truncating the list if the configured total bytes is exceeded.\n    ///\n    /// If any file entries were missing or deleted, we don't have any way in\n    /// the current sync protocol to signal that they should be skipped, so\n    /// we abort with a conflict.\n    pub fn get_entries_for_download(&self, files: &[String]) -> HttpResult<Vec<MediaEntry>> {\n        if files.len() > MAX_MEDIA_FILES_IN_ZIP {\n            None.or_bad_request(\"too many files requested\")?;\n        }\n\n        let mut entries = vec![];\n        let mut accumulated_size = 0;\n        for filename in files {\n            let Some(entry) = self\n                .get_nonempty_entry(filename)\n                .or_internal_err(\"fetching entry\")?\n            else {\n                return None.or_conflict(format!(\"missing/empty file entry: {filename}\"));\n            };\n\n            accumulated_size += entry.size;\n            entries.push(entry);\n            if accumulated_size > MEDIA_SYNC_TARGET_ZIP_BYTES as u64 {\n                break;\n            }\n        }\n        Ok(entries)\n    }\n\n    /// Delete provided file from media DB, leaving no record of deletion. It\n    /// was probably missing due to an interrupted deletion, but removing\n    /// the entry errs on the side of caution, ensuring the deletion doesn't\n    /// propagate to other clients.\n    pub fn forget_missing_file(&mut self, entry: &MediaEntry) -> error::Result<()> {\n        assert!(entry.size > 0);\n        self.with_transaction(|db, meta| {\n            meta.total_bytes = meta.total_bytes.saturating_sub(entry.size);\n            meta.total_nonempty_files = meta.total_nonempty_files.saturating_sub(1);\n            db.db\n                .prepare_cached(\"delete from media where fname = ?\")?\n                .execute(params![&entry.nfc_filename,])?;\n            Ok(())\n        })?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/get_entry.sql",
    "content": "SELECT fname,\n  csum,\n  size,\n  usn,\n  mtime\nFROM media\nWHERE fname = ?;"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::mem;\n\nuse rusqlite::params;\nuse rusqlite::OptionalExtension;\nuse rusqlite::Row;\n\nuse crate::error;\nuse crate::prelude::TimestampSecs;\nuse crate::prelude::Usn;\nuse crate::sync::media::database::server::meta::StoreMetadata;\nuse crate::sync::media::database::server::ServerMediaDatabase;\n\npub mod changes;\nmod download;\npub mod upload;\n\nimpl ServerMediaDatabase {\n    /// Does not return a deletion entry.\n    pub fn get_nonempty_entry(&self, nfc_filename: &str) -> error::Result<Option<MediaEntry>> {\n        self.get_entry(nfc_filename)\n            .map(|e| e.filter(|e| !e.is_deleted()))\n    }\n\n    pub fn get_entry(&self, nfc_filename: &str) -> error::Result<Option<MediaEntry>> {\n        self.db\n            .prepare_cached(include_str!(\"get_entry.sql\"))?\n            .query_row([nfc_filename], MediaEntry::from_row)\n            .optional()\n            .map_err(Into::into)\n    }\n\n    /// Saves entry to the DB, overwriting any existing entry. Does no\n    /// validation on its own; caller is responsible for mutating meta\n    /// (which will update mtime as well).\n    pub fn set_entry(&mut self, entry: &mut MediaEntry) -> error::Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"set_entry.sql\"))?\n            .execute(params![\n                &entry.nfc_filename,\n                &entry.sha1,\n                &entry.size,\n                &entry.usn,\n                &entry.mtime.0,\n            ])?;\n        Ok(())\n    }\n\n    fn add_entry(\n        &mut self,\n        meta: &mut StoreMetadata,\n        filename: String,\n        total_bytes: usize,\n        sha1: Vec<u8>,\n    ) -> error::Result<MediaEntry> {\n        assert!(total_bytes > 0);\n        let mut new_entry = MediaEntry {\n            nfc_filename: filename,\n            sha1,\n            size: total_bytes as u64,\n            // set by following call\n            usn: Default::default(),\n            mtime: Default::default(),\n        };\n        meta.add_entry(&mut new_entry);\n        self.set_entry(&mut new_entry)?;\n        Ok(new_entry)\n    }\n\n    /// Returns the old sha1\n    fn replace_entry(\n        &mut self,\n        meta: &mut StoreMetadata,\n        existing_nonempty: &mut MediaEntry,\n        total_bytes: usize,\n        sha1: Vec<u8>,\n    ) -> error::Result<Vec<u8>> {\n        assert!(total_bytes > 0);\n        meta.replace_entry(existing_nonempty, total_bytes as u64);\n        let old_sha1 = mem::replace(&mut existing_nonempty.sha1, sha1);\n        self.set_entry(existing_nonempty)?;\n        Ok(old_sha1)\n    }\n\n    fn remove_entry(\n        &mut self,\n        meta: &mut StoreMetadata,\n        existing_nonempty: &mut MediaEntry,\n    ) -> error::Result<()> {\n        meta.remove_entry(existing_nonempty);\n        self.set_entry(existing_nonempty)?;\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct MediaEntry {\n    pub nfc_filename: String,\n    pub sha1: Vec<u8>,\n    /// Set to 0 to indicate deletion.\n    pub size: u64,\n    pub usn: Usn,\n    pub mtime: TimestampSecs,\n}\n\nimpl MediaEntry {\n    pub fn from_row(row: &Row) -> std::result::Result<Self, rusqlite::Error> {\n        Ok(Self {\n            nfc_filename: row.get(0)?,\n            sha1: row.get(1)?,\n            size: row.get(2)?,\n            usn: row.get(3)?,\n            mtime: TimestampSecs(row.get(4)?),\n        })\n    }\n\n    fn is_deleted(&self) -> bool {\n        self.size == 0\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/set_entry.sql",
    "content": "INSERT\n  OR REPLACE INTO media (fname, csum, size, usn, mtime)\nVALUES (?, ?, ?, ?, ?);"
  },
  {
    "path": "rslib/src/sync/media/database/server/entry/upload.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::error;\nuse crate::sync::media::database::server::meta::StoreMetadata;\nuse crate::sync::media::database::server::ServerMediaDatabase;\nuse crate::sync::media::zip::UploadedChange;\nuse crate::sync::media::zip::UploadedChangeKind;\n\npub enum UploadedChangeResult {\n    FileAlreadyDeleted {\n        filename: String,\n    },\n    FileIdentical {\n        filename: String,\n        sha1: Vec<u8>,\n    },\n    Added {\n        filename: String,\n        data: Vec<u8>,\n        sha1: Vec<u8>,\n    },\n    Removed {\n        filename: String,\n        sha1: Vec<u8>,\n    },\n    Replaced {\n        filename: String,\n        data: Vec<u8>,\n        old_sha1: Vec<u8>,\n        new_sha1: Vec<u8>,\n    },\n}\n\nimpl ServerMediaDatabase {\n    /// Add/modify/remove a single file.\n    pub fn register_uploaded_change(\n        &mut self,\n        meta: &mut StoreMetadata,\n        update: UploadedChange,\n    ) -> error::Result<UploadedChangeResult> {\n        let existing_file = self.get_nonempty_entry(&update.nfc_filename)?;\n        match (existing_file, update.kind) {\n            // deletion\n            (None, UploadedChangeKind::Delete) => Ok(UploadedChangeResult::FileAlreadyDeleted {\n                filename: update.nfc_filename,\n            }),\n            (Some(mut existing_nonempty), UploadedChangeKind::Delete) => {\n                self.remove_entry(meta, &mut existing_nonempty)?;\n                Ok(UploadedChangeResult::Removed {\n                    filename: existing_nonempty.nfc_filename,\n                    sha1: existing_nonempty.sha1,\n                })\n            }\n            // addition\n            (\n                None,\n                UploadedChangeKind::AddOrReplace {\n                    nonempty_data,\n                    sha1,\n                },\n            ) => {\n                let entry = self.add_entry(meta, update.nfc_filename, nonempty_data.len(), sha1)?;\n                Ok(UploadedChangeResult::Added {\n                    filename: entry.nfc_filename,\n                    data: nonempty_data,\n                    sha1: entry.sha1,\n                })\n            }\n            // replacement\n            (\n                Some(mut existing_nonempty),\n                UploadedChangeKind::AddOrReplace {\n                    nonempty_data,\n                    sha1,\n                },\n            ) => {\n                if existing_nonempty.sha1 == sha1 {\n                    Ok(UploadedChangeResult::FileIdentical {\n                        filename: existing_nonempty.nfc_filename,\n                        sha1,\n                    })\n                } else {\n                    let old_sha1 = self.replace_entry(\n                        meta,\n                        &mut existing_nonempty,\n                        nonempty_data.len(),\n                        sha1,\n                    )?;\n                    Ok(UploadedChangeResult::Replaced {\n                        filename: existing_nonempty.nfc_filename,\n                        data: nonempty_data,\n                        old_sha1,\n                        new_sha1: existing_nonempty.sha1,\n                    })\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/meta/get_meta.sql",
    "content": "SELECT last_usn,\n  total_bytes,\n  total_nonempty_files\nFROM meta;"
  },
  {
    "path": "rslib/src/sync/media/database/server/meta/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse rusqlite::params;\nuse rusqlite::Row;\n\nuse crate::error;\nuse crate::prelude::TimestampSecs;\nuse crate::prelude::Usn;\nuse crate::sync::media::database::server::entry::MediaEntry;\nuse crate::sync::media::database::server::ServerMediaDatabase;\n\n#[derive(Debug, PartialEq, Eq)]\npub struct StoreMetadata {\n    pub last_usn: Usn,\n    pub total_bytes: u64,\n    pub total_nonempty_files: u32,\n}\n\nimpl StoreMetadata {\n    pub(crate) fn add_entry(&mut self, entry: &mut MediaEntry) {\n        assert!(entry.size > 0);\n        self.total_bytes += entry.size;\n        self.total_nonempty_files += 1;\n        entry.usn = self.next_usn();\n        entry.mtime = TimestampSecs::now();\n    }\n\n    /// Expects entry to have its old size; the new size will be set.\n    pub(crate) fn replace_entry(&mut self, entry: &mut MediaEntry, new_size: u64) {\n        assert!(entry.size > 0);\n        assert!(new_size > 0);\n        self.total_bytes = self.total_bytes.saturating_sub(entry.size) + new_size;\n        entry.size = new_size;\n        entry.usn = self.next_usn();\n        entry.mtime = TimestampSecs::now();\n    }\n\n    pub(crate) fn remove_entry(&mut self, entry: &mut MediaEntry) {\n        assert!(entry.size > 0);\n        self.total_bytes = self.total_bytes.saturating_sub(entry.size);\n        self.total_nonempty_files = self.total_nonempty_files.saturating_sub(1);\n        entry.size = 0;\n        entry.usn = self.next_usn();\n        entry.mtime = TimestampSecs::now();\n    }\n}\n\nimpl StoreMetadata {\n    fn from_row(row: &Row) -> error::Result<Self, rusqlite::Error> {\n        Ok(Self {\n            last_usn: row.get(0)?,\n            total_bytes: row.get(1)?,\n            total_nonempty_files: row.get(2)?,\n        })\n    }\n\n    fn next_usn(&mut self) -> Usn {\n        self.last_usn.0 += 1;\n        self.last_usn\n    }\n}\n\nimpl ServerMediaDatabase {\n    /// Perform an exclusive transaction. Will implicitly commit if no error\n    /// returned, after flushing the updated metadata. Returns the latest\n    /// usn.\n    pub fn with_transaction<F>(&mut self, op: F) -> error::Result<Usn>\n    where\n        F: FnOnce(&mut Self, &mut StoreMetadata) -> error::Result<()>,\n    {\n        self.db.execute(\"begin exclusive\", [])?;\n        let mut meta = self.get_meta()?;\n        op(self, &mut meta)\n            .and_then(|_| {\n                self.set_meta(&meta)?;\n                self.db.execute(\"commit\", [])?;\n                Ok(meta.last_usn)\n            })\n            .inspect_err(|_e| {\n                let _ = self.db.execute(\"rollback\", []);\n            })\n    }\n\n    pub fn last_usn(&self) -> error::Result<Usn> {\n        Ok(self.get_meta()?.last_usn)\n    }\n\n    fn get_meta(&self) -> error::Result<StoreMetadata> {\n        self.db\n            .prepare_cached(include_str!(\"get_meta.sql\"))?\n            .query_row([], StoreMetadata::from_row)\n            .map_err(Into::into)\n    }\n\n    fn set_meta(&mut self, meta: &StoreMetadata) -> error::Result<()> {\n        self.db\n            .prepare_cached(include_str!(\"set_meta.sql\"))?\n            .execute(params![\n                meta.last_usn,\n                meta.total_bytes,\n                meta.total_nonempty_files\n            ])?;\n        Ok(())\n    }\n\n    pub fn nonempty_file_count(&self) -> error::Result<u32> {\n        Ok(self.get_meta()?.total_nonempty_files)\n    }\n\n    pub fn total_bytes(&self) -> error::Result<u64> {\n        Ok(self.get_meta()?.total_bytes)\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/meta/set_meta.sql",
    "content": "UPDATE meta\nSET last_usn = ?,\n  total_bytes = ?,\n  total_nonempty_files = ?;"
  },
  {
    "path": "rslib/src/sync/media/database/server/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod entry;\npub mod meta;\n\nuse std::path::Path;\n\nuse rusqlite::Connection;\n\nuse crate::prelude::*;\n\npub struct ServerMediaDatabase {\n    pub db: Connection,\n}\n\nimpl ServerMediaDatabase {\n    pub fn new(path: &Path) -> Result<Self> {\n        Ok(Self {\n            db: open_or_create_db(path)?,\n        })\n    }\n}\n\nfn open_or_create_db(path: &Path) -> Result<Connection> {\n    let db = Connection::open(path)?;\n    db.busy_timeout(std::time::Duration::from_secs(0))?;\n    db.pragma_update(None, \"locking_mode\", \"exclusive\")?;\n    db.pragma_update(None, \"journal_mode\", \"wal\")?;\n    let ver: u32 = db.query_row(\"select user_version from pragma_user_version\", [], |r| {\n        r.get(0)\n    })?;\n    if ver < 3 {\n        db.execute_batch(include_str!(\"schema_v3.sql\"))?;\n    }\n    if ver < 4 {\n        db.execute_batch(include_str!(\"schema_v4.sql\"))?;\n    }\n    Ok(db)\n}\n"
  },
  {
    "path": "rslib/src/sync/media/database/server/schema_v3.sql",
    "content": "BEGIN exclusive;\nCREATE TABLE IF NOT EXISTS media (\n  fname text NOT NULL PRIMARY KEY,\n  csum blob,\n  sz int NOT NULL,\n  usn int NOT NULL,\n  deleted int NOT NULL\n);\nCREATE INDEX IF NOT EXISTS ix_usn ON media (usn);\nCREATE TABLE IF NOT EXISTS meta (usn int NOT NULL, sz int NOT NULL);\nINSERT INTO meta (usn, sz)\nVALUES (0, 0);\npragma user_version = 3;\nCOMMIT;"
  },
  {
    "path": "rslib/src/sync/media/database/server/schema_v4.sql",
    "content": "-- csum is no longer nulled on deletion\n-- sz renamed to size\n-- deleted renamed to mtime\nBEGIN exclusive;\nALTER TABLE media\n  RENAME TO media_tmp;\nDROP INDEX ix_usn;\nCREATE TABLE media (\n  fname text NOT NULL PRIMARY KEY,\n  csum blob NOT NULL,\n  -- if zero, file has been deleted\n  size int NOT NULL,\n  usn int NOT NULL,\n  mtime int NOT NULL\n);\nINSERT INTO media (fname, csum, size, usn, mtime)\nSELECT fname,\n  csum,\n  sz,\n  usn,\n  deleted\nFROM media_tmp\nWHERE csum IS NOT NULL;\nDROP TABLE media_tmp;\nCREATE INDEX ix_usn ON media (usn);\nDROP TABLE meta;\n-- columns renamed; file count added\nCREATE TABLE meta (\n  last_usn int NOT NULL,\n  total_bytes int NOT NULL,\n  total_nonempty_files int NOT NULL\n);\nINSERT INTO meta (last_usn, total_bytes, total_nonempty_files)\nSELECT coalesce(max(usn), 0),\n  coalesce(sum(size), 0),\n  0\nFROM media;\nUPDATE meta\nSET total_nonempty_files = (\n    SELECT COUNT(*)\n    FROM media\n    WHERE size > 0\n  );\npragma user_version = 4;\nCOMMIT;\nvacuum;"
  },
  {
    "path": "rslib/src/sync/media/download.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::io;\nuse std::io::Read;\nuse std::path::Path;\n\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::error::SyncErrorKind;\nuse crate::media::files::add_file_from_ankiweb;\nuse crate::media::files::AddedFile;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct DownloadFilesRequest {\n    pub files: Vec<String>,\n}\n\npub(crate) fn extract_into_media_folder(\n    media_folder: &Path,\n    zip: Vec<u8>,\n) -> error::Result<Vec<AddedFile>> {\n    let reader = io::Cursor::new(zip);\n    let mut zip = zip::ZipArchive::new(reader)?;\n\n    let meta_file = zip.by_name(\"_meta\")?;\n    let fmap: HashMap<String, String> = serde_json::from_reader(meta_file)?;\n    let mut output = Vec::with_capacity(fmap.len());\n\n    for i in 0..zip.len() {\n        let mut file = zip.by_index(i)?;\n        let name = file.name();\n        if name == \"_meta\" {\n            continue;\n        }\n\n        let real_name = fmap\n            .get(name)\n            .ok_or_else(|| AnkiError::sync_error(\"malformed zip\", SyncErrorKind::Other))?;\n\n        let mut data = Vec::with_capacity(file.size() as usize);\n        file.read_to_end(&mut data)?;\n\n        let added = add_file_from_ankiweb(media_folder, real_name, &data)?;\n\n        output.push(added);\n    }\n\n    Ok(output)\n}\n"
  },
  {
    "path": "rslib/src/sync/media/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod begin;\npub mod changes;\npub mod database;\npub mod download;\npub mod progress;\npub mod protocol;\npub mod sanity;\npub mod syncer;\nmod tests;\npub mod upload;\npub mod zip;\n\n/// The maximum length we allow a filename to be. When combined\n/// with the rest of the path, the full path needs to be under ~240 chars\n/// on some platforms, and some filesystems like eCryptFS will increase\n/// the length of the filename.\npub static MAX_MEDIA_FILENAME_LENGTH: usize = 120;\n\n// We can't enforce the 120 limit until all clients have shifted over to the\n// Rust codebase.\npub const MAX_MEDIA_FILENAME_LENGTH_SERVER: usize = 255;\n\n/// Media syncing does not support files over 100MiB.\npub static MAX_INDIVIDUAL_MEDIA_FILE_SIZE: usize = 100 * 1024 * 1024;\n\npub static MAX_MEDIA_FILES_IN_ZIP: usize = 25;\n\n/// If reached, no further files are placed into the zip.\npub static MEDIA_SYNC_TARGET_ZIP_BYTES: usize = (2.5 * 1024.0 * 1024.0) as usize;\n"
  },
  {
    "path": "rslib/src/sync/media/progress.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#[derive(Debug, Default, Clone, Copy)]\npub struct MediaSyncProgress {\n    pub checked: usize,\n    pub downloaded_files: usize,\n    pub downloaded_deletions: usize,\n    pub uploaded_files: usize,\n    pub uploaded_deletions: usize,\n}\n\n#[derive(Debug, Default, Clone, Copy)]\n#[repr(transparent)]\npub struct MediaCheckProgress {\n    pub checked: usize,\n}\n"
  },
  {
    "path": "rslib/src/sync/media/protocol.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse async_trait::async_trait;\nuse reqwest::Url;\nuse serde::de::DeserializeOwned;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse strum::IntoStaticStr;\n\nuse crate::error;\nuse crate::error::AnkiError;\nuse crate::sync::collection::protocol::AsSyncEndpoint;\nuse crate::sync::error::HttpResult;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::begin::SyncBeginResponse;\nuse crate::sync::media::changes::MediaChangesRequest;\nuse crate::sync::media::changes::MediaChangesResponse;\nuse crate::sync::media::download::DownloadFilesRequest;\nuse crate::sync::media::sanity::MediaSanityCheckResponse;\nuse crate::sync::media::sanity::SanityCheckRequest;\nuse crate::sync::media::upload::MediaUploadResponse;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::response::SyncResponse;\n\n#[derive(IntoStaticStr, Deserialize, PartialEq, Eq, Debug)]\n#[serde(rename_all = \"camelCase\")]\n#[strum(serialize_all = \"camelCase\")]\npub enum MediaSyncMethod {\n    Begin,\n    MediaChanges,\n    UploadChanges,\n    DownloadFiles,\n    MediaSanity,\n}\n\nimpl AsSyncEndpoint for MediaSyncMethod {\n    fn as_sync_endpoint(&self, base: &Url) -> Url {\n        base.join(\"msync/\").unwrap().join(self.into()).unwrap()\n    }\n}\n\n#[async_trait]\npub trait MediaSyncProtocol: Send + Sync + 'static {\n    async fn begin(\n        &self,\n        req: SyncRequest<SyncBeginRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<SyncBeginResponse>>>;\n    async fn media_changes(\n        &self,\n        req: SyncRequest<MediaChangesRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaChangesResponse>>>;\n    async fn upload_changes(\n        &self,\n        req: SyncRequest<Vec<u8>>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaUploadResponse>>>;\n    async fn download_files(\n        &self,\n        req: SyncRequest<DownloadFilesRequest>,\n    ) -> HttpResult<SyncResponse<Vec<u8>>>;\n    async fn media_sanity_check(\n        &self,\n        req: SyncRequest<SanityCheckRequest>,\n    ) -> HttpResult<SyncResponse<JsonResult<MediaSanityCheckResponse>>>;\n}\n\n/// Media endpoints wrap their returns in a JSON result, and legacy\n/// clients expect it to always have an err field, even if it's empty.\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum JsonResult<T> {\n    Ok {\n        data: T,\n        #[serde(default)]\n        err: String,\n    },\n    Err {\n        err: String,\n    },\n}\n\nimpl<T> JsonResult<T> {\n    pub fn ok(inner: T) -> Self {\n        Self::Ok {\n            data: inner,\n            err: String::new(),\n        }\n    }\n}\n\nimpl<T> SyncResponse<JsonResult<T>>\nwhere\n    T: DeserializeOwned,\n{\n    pub fn json_result(&self) -> error::Result<T> {\n        match serde_json::from_slice(&self.data)? {\n            JsonResult::Ok { data, .. } => Ok(data),\n            JsonResult::Err { err } => Err(AnkiError::server_message(err)),\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/sanity.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\n\n#[derive(Serialize, Deserialize)]\npub struct SanityCheckRequest {\n    pub local: u32,\n}\n\n#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]\npub enum MediaSanityCheckResponse {\n    #[serde(rename = \"OK\")]\n    Ok,\n    #[serde(rename = \"mediaSanity\")]\n    SanityCheckFailed,\n}\n"
  },
  {
    "path": "rslib/src/sync/media/syncer.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse tracing::debug;\nuse version::sync_client_version;\n\nuse crate::error::AnkiError;\nuse crate::error::Result;\nuse crate::error::SyncErrorKind;\nuse crate::media::files::mtime_as_i64;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::http_client::HttpSyncClient;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::begin::SyncBeginResponse;\nuse crate::sync::media::changes;\nuse crate::sync::media::changes::MediaChangesRequest;\nuse crate::sync::media::database::client::changetracker::ChangeTracker;\nuse crate::sync::media::database::client::MediaDatabaseMetadata;\nuse crate::sync::media::database::client::MediaEntry;\nuse crate::sync::media::download;\nuse crate::sync::media::download::DownloadFilesRequest;\nuse crate::sync::media::progress::MediaSyncProgress;\nuse crate::sync::media::protocol::MediaSyncProtocol;\nuse crate::sync::media::sanity::MediaSanityCheckResponse;\nuse crate::sync::media::sanity::SanityCheckRequest;\nuse crate::sync::media::upload::gather_zip_data_for_upload;\nuse crate::sync::media::zip::zip_files_for_upload;\nuse crate::sync::media::MAX_MEDIA_FILES_IN_ZIP;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::version;\n\npub struct MediaSyncer {\n    mgr: MediaManager,\n    client: HttpSyncClient,\n    progress: ThrottlingProgressHandler<MediaSyncProgress>,\n}\n\nimpl MediaSyncer {\n    pub fn new(\n        mgr: MediaManager,\n        progress: ThrottlingProgressHandler<MediaSyncProgress>,\n        client: HttpSyncClient,\n    ) -> Result<MediaSyncer> {\n        Ok(MediaSyncer {\n            mgr,\n            client,\n            progress,\n        })\n    }\n\n    pub async fn sync(&mut self, server_usn: Option<Usn>) -> Result<()> {\n        self.sync_inner(server_usn).await.map_err(|e| {\n            debug!(\"sync error: {:?}\", e);\n            e\n        })\n    }\n\n    #[allow(clippy::useless_let_if_seq)]\n    async fn sync_inner(&mut self, server_usn: Option<Usn>) -> Result<()> {\n        self.register_changes()?;\n\n        let meta = self.mgr.db.get_meta()?;\n        let client_usn = meta.last_sync_usn;\n        let server_usn = if let Some(usn) = server_usn {\n            usn\n        } else {\n            self.begin_sync().await?\n        };\n\n        let mut actions_performed = false;\n\n        // need to fetch changes from server?\n        if client_usn != server_usn {\n            debug!(\"differs from local usn {}, fetching changes\", client_usn);\n            self.fetch_changes(meta).await?;\n            actions_performed = true;\n        }\n\n        // need to send changes to server?\n        let changes_pending = !self.mgr.db.get_pending_uploads(1)?.is_empty();\n        if changes_pending {\n            self.send_changes().await?;\n            actions_performed = true;\n        }\n\n        if actions_performed {\n            self.finalize_sync().await?;\n        }\n\n        debug!(\"media sync complete\");\n\n        Ok(())\n    }\n\n    async fn begin_sync(&mut self) -> Result<Usn> {\n        debug!(\"begin media sync\");\n        let SyncBeginResponse {\n            host_key: _,\n            usn: server_usn,\n        } = self\n            .client\n            .begin(\n                SyncBeginRequest {\n                    client_version: sync_client_version().into(),\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json_result()?;\n\n        debug!(\"server usn was {}\", server_usn);\n        Ok(server_usn)\n    }\n\n    /// Make sure media DB is up to date.\n    fn register_changes(&mut self) -> Result<()> {\n        let progress_cb = |checked| self.progress.update(true, |p| p.checked = checked).is_ok();\n        ChangeTracker::new(self.mgr.media_folder.as_path(), progress_cb)\n            .register_changes(&self.mgr.db)\n    }\n\n    async fn fetch_changes(&mut self, mut meta: MediaDatabaseMetadata) -> Result<()> {\n        let mut last_usn = meta.last_sync_usn;\n        loop {\n            debug!(start_usn = ?last_usn, \"fetching record batch\");\n\n            let batch = self\n                .client\n                .media_changes(MediaChangesRequest { last_usn }.try_into_sync_request()?)\n                .await?\n                .json_result()?;\n            if batch.is_empty() {\n                debug!(\"empty batch, done\");\n                break;\n            }\n            last_usn = batch.last().unwrap().usn;\n\n            self.progress.update(false, |p| p.checked += batch.len())?;\n\n            let (to_download, to_delete, to_remove_pending) =\n                changes::determine_required_changes(&self.mgr.db, batch)?;\n\n            // file removal\n            self.mgr.remove_files(to_delete.as_slice())?;\n            self.progress\n                .update(false, |p| p.downloaded_deletions += to_delete.len())?;\n\n            // file download\n            let mut downloaded = vec![];\n            let mut dl_fnames = to_download.as_slice();\n            while !dl_fnames.is_empty() {\n                let batch: Vec<_> = dl_fnames\n                    .iter()\n                    .take(MAX_MEDIA_FILES_IN_ZIP)\n                    .map(ToOwned::to_owned)\n                    .collect();\n                let zip_data = self\n                    .client\n                    .download_files(DownloadFilesRequest { files: batch }.try_into_sync_request()?)\n                    .await?\n                    .data;\n                let download_batch =\n                    download::extract_into_media_folder(self.mgr.media_folder.as_path(), zip_data)?\n                        .into_iter();\n                let len = download_batch.len();\n                dl_fnames = &dl_fnames[len..];\n                downloaded.extend(download_batch);\n\n                self.progress.update(false, |p| p.downloaded_files += len)?;\n            }\n\n            // then update the DB\n            let dirmod = mtime_as_i64(&self.mgr.media_folder)?;\n            self.mgr.db.transact(|ctx| {\n                ctx.record_clean(&to_remove_pending)?;\n                ctx.record_removals(&to_delete)?;\n                ctx.record_additions(downloaded)?;\n\n                // update usn\n                meta.last_sync_usn = last_usn;\n                meta.folder_mtime = dirmod;\n                ctx.set_meta(&meta)?;\n\n                Ok(())\n            })?;\n        }\n        Ok(())\n    }\n\n    async fn send_changes(&mut self) -> Result<()> {\n        loop {\n            let pending: Vec<MediaEntry> = self\n                .mgr\n                .db\n                .get_pending_uploads(MAX_MEDIA_FILES_IN_ZIP as u32)?;\n            if pending.is_empty() {\n                break;\n            }\n\n            let data_for_zip =\n                gather_zip_data_for_upload(&self.mgr.db, &self.mgr.media_folder, &pending)?;\n            let zip_bytes = match data_for_zip {\n                None => {\n                    // discard zip info and retry batch - not particularly efficient,\n                    // but this is a corner case\n                    self.progress\n                        .update(false, |p| p.checked += pending.len())?;\n                    continue;\n                }\n                Some(data) => zip_files_for_upload(data)?,\n            };\n\n            let reply = self\n                .client\n                .upload_changes(zip_bytes.try_into_sync_request()?)\n                .await?\n                .json_result()?;\n\n            let (processed_files, processed_deletions): (Vec<_>, Vec<_>) = pending\n                .into_iter()\n                .take(reply.processed)\n                .partition(|e| e.sha1.is_some());\n\n            self.progress.update(false, |p| {\n                p.uploaded_files += processed_files.len();\n                p.uploaded_deletions += processed_deletions.len();\n            })?;\n\n            let fnames: Vec<_> = processed_files\n                .into_iter()\n                .chain(processed_deletions.into_iter())\n                .map(|e| e.fname)\n                .collect();\n            let fname_cnt = fnames.len() as i32;\n            self.mgr.db.transact(|ctx| {\n                ctx.record_clean(fnames.as_slice())?;\n                let mut meta = ctx.get_meta()?;\n                if meta.last_sync_usn.0 + fname_cnt == reply.current_usn.0 {\n                    meta.last_sync_usn = reply.current_usn;\n                    ctx.set_meta(&meta)?;\n                } else {\n                    debug!(\n                        \"server usn {} is not {}, skipping usn update\",\n                        reply.current_usn,\n                        meta.last_sync_usn.0 + fname_cnt\n                    );\n                }\n                Ok(())\n            })?;\n        }\n\n        Ok(())\n    }\n\n    async fn finalize_sync(&mut self) -> Result<()> {\n        let local = self.mgr.db.count()?;\n        let msg = self\n            .client\n            .media_sanity_check(SanityCheckRequest { local }.try_into_sync_request()?)\n            .await?\n            .json_result()?;\n        if msg == MediaSanityCheckResponse::Ok {\n            Ok(())\n        } else {\n            self.mgr.db.transact(|ctx| ctx.force_resync())?;\n            Err(AnkiError::sync_error(\"\", SyncErrorKind::ResyncRequired))\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/media/tests.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n\nuse std::fs;\nuse std::net::IpAddr;\nuse std::thread::sleep;\nuse std::time::Duration;\n\nuse nom::AsBytes;\nuse reqwest::multipart;\nuse reqwest::Client;\n\nuse crate::error::Result;\nuse crate::media::MediaManager;\nuse crate::prelude::AnkiError;\nuse crate::progress::ThrottlingProgressHandler;\nuse crate::sync::collection::protocol::AsSyncEndpoint;\nuse crate::sync::collection::tests::with_active_server;\nuse crate::sync::collection::tests::SyncTestContext;\nuse crate::sync::media::begin::SyncBeginQuery;\nuse crate::sync::media::begin::SyncBeginRequest;\nuse crate::sync::media::progress::MediaSyncProgress;\nuse crate::sync::media::protocol::MediaSyncMethod;\nuse crate::sync::media::protocol::MediaSyncProtocol;\nuse crate::sync::media::sanity::MediaSanityCheckResponse;\nuse crate::sync::media::sanity::SanityCheckRequest;\nuse crate::sync::media::syncer::MediaSyncer;\nuse crate::sync::media::zip::zip_files_for_upload;\nuse crate::sync::request::IntoSyncRequest;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::version::SyncVersion;\nuse crate::version::sync_client_version;\n\n/// Older Rust versions sent hkey/version in GET query string.\n#[tokio::test]\nasync fn begin_supports_get() -> Result<()> {\n    with_active_server(|client_| async move {\n        let url = client_.endpoint().join(\"msync/begin\").unwrap();\n        let client = Client::new();\n        client\n            .get(url)\n            .query(&SyncBeginQuery {\n                host_key: client_.sync_key.clone(),\n                client_version: sync_client_version().into(),\n            })\n            .send()\n            .await?\n            .error_for_status()?;\n        Ok(())\n    })\n    .await\n}\n\n/// Older clients used a `v` variable in the begin multipart instead of placing\n/// the version in the JSON payload.\n#[tokio::test]\nasync fn begin_supports_version_in_form() -> Result<()> {\n    with_active_server(|client_| async move {\n        let url = MediaSyncMethod::Begin.as_sync_endpoint(client_.endpoint());\n        let client = Client::new();\n\n        let form = multipart::Form::new()\n            .text(\"c\", \"0\")\n            .text(\"v\", \"client\")\n            .text(\"k\", client_.sync_key.clone());\n        client\n            .post(url)\n            .multipart(form)\n            .send()\n            .await?\n            .error_for_status()?;\n        Ok(())\n    })\n    .await\n}\n\n/// Older clients sent key in `sk` multipart variable for non-begin requests.\n#[tokio::test]\nasync fn legacy_session_key_works() -> Result<()> {\n    with_active_server(|client_| async move {\n        let url = MediaSyncMethod::MediaChanges.as_sync_endpoint(client_.endpoint());\n        let client = Client::new();\n\n        let form = multipart::Form::new()\n            .text(\"c\", \"0\")\n            .text(\"v\", \"client\")\n            .text(\"sk\", client_.sync_key.clone())\n            .part(\n                \"data\",\n                multipart::Part::bytes(b\"{\\\"lastUsn\\\": 0}\".as_bytes()),\n            );\n        client\n            .post(url)\n            .multipart(form)\n            .send()\n            .await?\n            .error_for_status()?;\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn sanity_check() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client.clone());\n        let media1 = ctx.media1();\n        ctx.sync_media1().await?;\n        // may be non-zero when testing on external endpoint\n        let starting_file_count = fs::read_dir(&media1.media_folder).unwrap().count() as u32;\n        let resp = client\n            .media_sanity_check(\n                SanityCheckRequest {\n                    local: starting_file_count,\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json_result()?;\n        assert_eq!(resp, MediaSanityCheckResponse::Ok);\n        let resp = client\n            .media_sanity_check(\n                SanityCheckRequest {\n                    local: starting_file_count + 1,\n                }\n                .try_into_sync_request()?,\n            )\n            .await?\n            .json_result()?;\n        assert_eq!(resp, MediaSanityCheckResponse::SanityCheckFailed);\n        Ok(())\n    })\n    .await\n}\n\nfn ignore_progress() -> ThrottlingProgressHandler<MediaSyncProgress> {\n    ThrottlingProgressHandler::new(Default::default())\n}\n\nimpl SyncTestContext {\n    fn media1(&self) -> MediaManager {\n        self.col1().media().unwrap()\n    }\n\n    fn media2(&self) -> MediaManager {\n        self.col2().media().unwrap()\n    }\n\n    async fn sync_media1(&self) -> Result<()> {\n        let mut syncer =\n            MediaSyncer::new(self.media1(), ignore_progress(), self.client.clone()).unwrap();\n        syncer.sync(None).await\n    }\n\n    async fn sync_media2(&self) -> Result<()> {\n        let mut syncer =\n            MediaSyncer::new(self.media2(), ignore_progress(), self.client.clone()).unwrap();\n        syncer.sync(None).await\n    }\n\n    /// As local change detection depends on a millisecond timestamp,\n    /// we need to wait a little while between steps to ensure changes are\n    /// observed. Theoretically 1ms should suffice, but I was seeing flaky\n    /// tests on a ZFS system with the delay set to a few milliseconds.\n    fn sleep(&self) {\n        sleep(Duration::from_millis(10))\n    }\n}\n\n#[tokio::test]\nasync fn media_roundtrip() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client.clone());\n        let media1 = ctx.media1();\n        let media2 = ctx.media2();\n        ctx.sync_media1().await?;\n        ctx.sync_media2().await?;\n        ctx.sleep();\n        // may be non-zero when testing on external endpoint\n        let starting_file_count = fs::read_dir(&media1.media_folder).unwrap().count();\n        // add some files\n        fs::write(media1.media_folder.join(\"manual1\"), \"manual1\").unwrap();\n        media1.add_file(\"auto1\", b\"auto1\").unwrap();\n        fs::write(media1.media_folder.join(\"manual2\"), \"manual2\").unwrap();\n        // sync to server and then other client\n        ctx.sync_media1().await?;\n        ctx.sync_media2().await?;\n        // modify a file and remove the other\n        ctx.sleep();\n        fs::write(media2.media_folder.join(\"manual1\"), \"changed1\").unwrap();\n        fs::remove_file(media2.media_folder.join(\"manual2\")).unwrap();\n        ctx.sync_media2().await?;\n        ctx.sync_media1().await?;\n        assert_eq!(\n            fs::read_to_string(media1.media_folder.join(\"manual1\")).unwrap(),\n            \"changed1\"\n        );\n        // remove remaining files\n        ctx.sleep();\n        fs::remove_file(media1.media_folder.join(\"manual1\")).unwrap();\n        fs::remove_file(media2.media_folder.join(\"auto1\")).unwrap();\n        ctx.sync_media1().await?;\n        ctx.sync_media2().await?;\n        ctx.sync_media1().await?;\n        assert_eq!(\n            fs::read_dir(media1.media_folder).unwrap().count(),\n            starting_file_count\n        );\n        assert_eq!(\n            fs::read_dir(media2.media_folder).unwrap().count(),\n            starting_file_count\n        );\n        Ok(())\n    })\n    .await\n}\n\n#[tokio::test]\nasync fn parallel_requests() -> Result<()> {\n    with_active_server(|client| async move {\n        let ctx = SyncTestContext::new(client.clone());\n        let media1 = ctx.media1();\n        let media2 = ctx.media2();\n        ctx.sleep();\n        // multiple clients should be able to add the same file\n        media1.add_file(\"auto\", b\"auto\").unwrap();\n        media2.add_file(\"auto\", b\"auto\").unwrap();\n        ctx.sync_media1().await?;\n        // Normally the second client would notice the addition of the file when\n        // fetching changes from the server; here we manually upload the change to\n        // simulate two parallel syncs going on.\n        let get_usn = || async {\n            Ok::<_, AnkiError>(\n                ctx.client\n                    .begin(\n                        SyncBeginRequest {\n                            client_version: \"x\".into(),\n                        }\n                        .try_into_sync_request()?,\n                    )\n                    .await?\n                    .json_result()?\n                    .usn,\n            )\n        };\n        let start_usn = get_usn().await?;\n        let zip_data = zip_files_for_upload(vec![(\"auto\".into(), Some(b\"auto\".to_vec()))])?;\n        client\n            .upload_changes(SyncRequest::from_data(\n                zip_data,\n                ctx.client.sync_key.clone(),\n                String::new(),\n                IpAddr::from([0, 0, 0, 0]),\n                SyncVersion::latest(),\n            ))\n            .await?;\n        let end_usn = get_usn().await?;\n        assert_eq!(start_usn, end_usn);\n        // Parallel deletions should work too\n        media1.remove_files(&[\"auto\"])?;\n        media2.remove_files(&[\"auto\"])?;\n        ctx.sync_media1().await?;\n        let start_usn = get_usn().await?;\n        let zip_data = zip_files_for_upload(vec![(\"auto\".into(), None)])?;\n        client\n            .upload_changes(SyncRequest::from_data(\n                zip_data,\n                ctx.client.sync_key.clone(),\n                String::new(),\n                IpAddr::from([0, 0, 0, 0]),\n                SyncVersion::latest(),\n            ))\n            .await?;\n        let end_usn = get_usn().await?;\n        assert_eq!(start_usn, end_usn);\n        // In the case of differing content, server (first sync) content wins\n        media1.add_file(\"diff\", b\"1\").unwrap();\n        media2.add_file(\"diff\", b\"2\").unwrap();\n        ctx.sync_media1().await?;\n        ctx.sync_media2().await?;\n        assert_eq!(\n            fs::read_to_string(media1.media_folder.join(\"diff\")).unwrap(),\n            \"1\"\n        );\n        assert_eq!(\n            fs::read_to_string(media2.media_folder.join(\"diff\")).unwrap(),\n            \"1\"\n        );\n        Ok(())\n    })\n    .await\n}\n"
  },
  {
    "path": "rslib/src/sync/media/upload.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::path::Path;\n\nuse serde::Deserialize;\nuse serde_tuple::Serialize_tuple;\nuse tracing::debug;\n\nuse crate::media::files::data_for_file;\nuse crate::media::files::normalize_filename;\nuse crate::prelude::*;\nuse crate::sync::media::database::client::MediaDatabase;\nuse crate::sync::media::database::client::MediaEntry;\nuse crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE;\nuse crate::sync::media::MEDIA_SYNC_TARGET_ZIP_BYTES;\n\n#[derive(Serialize_tuple, Deserialize, Debug)]\npub struct MediaUploadResponse {\n    /// Always equal to number of uploaded files now. Old AnkiWeb versions used\n    /// to terminate processing early if too much time had elapsed, so older\n    /// clients will upload the same material again if this is less than the\n    /// count they uploaded.\n    pub processed: usize,\n    pub current_usn: Usn,\n}\n\n/// Filename -> Some(Data), or None in the deleted case.\ntype ZipDataForUpload = Vec<(String, Option<Vec<u8>>)>;\n\n/// Gather [(filename, data)] for provided entries, up to configured limit.\n/// Data is None if file is deleted.\n/// Returns None if one or more of the entries were inaccessible or in the wrong\n/// format.\npub fn gather_zip_data_for_upload(\n    ctx: &MediaDatabase,\n    media_folder: &Path,\n    files: &[MediaEntry],\n) -> Result<Option<ZipDataForUpload>> {\n    let mut invalid_entries = vec![];\n    let mut accumulated_size = 0;\n    let mut entries = vec![];\n\n    for file in files {\n        if accumulated_size > MEDIA_SYNC_TARGET_ZIP_BYTES {\n            break;\n        }\n\n        #[cfg(target_vendor = \"apple\")]\n        {\n            use unicode_normalization::is_nfc;\n            if !is_nfc(&file.fname) {\n                // older Anki versions stored non-normalized filenames in the DB; clean them up\n                debug!(fname = file.fname, \"clean up non-nfc entry\");\n                invalid_entries.push(&file.fname);\n                continue;\n            }\n        }\n\n        let file_data = if file.sha1.is_some() {\n            match data_for_file(media_folder, &file.fname) {\n                Ok(data) => data,\n                Err(e) => {\n                    debug!(\"error accessing {}: {}\", &file.fname, e);\n                    invalid_entries.push(&file.fname);\n                    continue;\n                }\n            }\n        } else {\n            // uploading deletion\n            None\n        };\n\n        if let Some(data) = file_data {\n            let normalized = normalize_filename(&file.fname);\n            if let Cow::Owned(o) = normalized {\n                debug!(\"media check required: {} should be {}\", &file.fname, o);\n                invalid_entries.push(&file.fname);\n                continue;\n            }\n\n            if data.is_empty() {\n                invalid_entries.push(&file.fname);\n                continue;\n            }\n            if data.len() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE {\n                invalid_entries.push(&file.fname);\n                continue;\n            }\n            accumulated_size += data.len();\n            entries.push((file.fname.clone(), Some(data)));\n            debug!(file.fname, kind = \"addition\", \"will upload\");\n        } else {\n            entries.push((file.fname.clone(), None));\n            debug!(file.fname, kind = \"removal\", \"will upload\");\n        }\n    }\n\n    if !invalid_entries.is_empty() {\n        // clean up invalid entries; we'll build a new zip\n        ctx.transact(|ctx| {\n            for fname in invalid_entries {\n                ctx.remove_entry(fname)?;\n            }\n            Ok(())\n        })?;\n        return Ok(None);\n    }\n\n    Ok(Some(entries))\n}\n"
  },
  {
    "path": "rslib/src/sync/media/zip.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\nuse std::io;\nuse std::io::Read;\nuse std::io::Write;\n\nuse serde::Deserialize;\nuse serde_tuple::Serialize_tuple;\nuse unicode_normalization::is_nfc;\nuse zip::write::FileOptions;\nuse zip::ZipWriter;\n\nuse crate::media::files::sha1_of_data;\nuse crate::prelude::*;\nuse crate::sync::media::MAX_INDIVIDUAL_MEDIA_FILE_SIZE;\nuse crate::sync::media::MAX_MEDIA_FILENAME_LENGTH_SERVER;\n\npub struct ZipFileMetadata {\n    pub filename: String,\n    pub total_bytes: u32,\n    pub sha1: String,\n}\n\n/// Write provided `[(filename, data)]` into a zip file, returning its data.\n/// The metadata is in a different format to the upload case, since deletions\n/// don't need to be represented.\npub fn zip_files_for_download(files: Vec<(String, Vec<u8>)>) -> Result<Vec<u8>> {\n    let options: FileOptions<'_, ()> =\n        FileOptions::default().compression_method(zip::CompressionMethod::Stored);\n    let mut zip = ZipWriter::new(io::Cursor::new(vec![]));\n    let mut entries = HashMap::new();\n\n    for (idx, (filename, data)) in files.into_iter().enumerate() {\n        assert!(!data.is_empty());\n        let idx_str = idx.to_string();\n        entries.insert(idx_str.clone(), filename);\n        zip.start_file(idx_str, options)?;\n        zip.write_all(&data)?;\n    }\n\n    let meta = serde_json::to_vec(&entries)?;\n    zip.start_file(\"_meta\", options)?;\n    zip.write_all(&meta)?;\n\n    Ok(zip.finish()?.into_inner())\n}\n\npub fn zip_files_for_upload(entries_: Vec<(String, Option<Vec<u8>>)>) -> Result<Vec<u8>> {\n    let options: FileOptions<'_, ()> =\n        FileOptions::default().compression_method(zip::CompressionMethod::Stored);\n    let mut zip = ZipWriter::new(io::Cursor::new(vec![]));\n    let mut entries = vec![];\n\n    for (idx, (filename, data)) in entries_.into_iter().enumerate() {\n        match data {\n            None => {\n                entries.push(UploadEntry {\n                    actual_filename: filename,\n                    filename_in_zip: None,\n                });\n            }\n            Some(data) => {\n                let idx_str = idx.to_string();\n                zip.start_file(&idx_str, options)?;\n                zip.write_all(&data)?;\n                entries.push(UploadEntry {\n                    actual_filename: filename,\n                    filename_in_zip: Some(idx_str),\n                });\n            }\n        }\n    }\n\n    let meta = serde_json::to_vec(&entries)?;\n    zip.start_file(\"_meta\", options)?;\n    zip.write_all(&meta)?;\n\n    Ok(zip.finish()?.into_inner())\n}\n\npub struct UploadedChange {\n    pub nfc_filename: String,\n    pub kind: UploadedChangeKind,\n}\n\npub enum UploadedChangeKind {\n    AddOrReplace {\n        nonempty_data: Vec<u8>,\n        sha1: Vec<u8>,\n    },\n    Delete,\n}\n\npub fn unzip_and_validate_files(zip_data: &[u8]) -> Result<Vec<UploadedChange>> {\n    let mut zip = zip::ZipArchive::new(io::Cursor::new(zip_data))?;\n\n    // meta map first, limited to a reasonable size\n    let meta_file = zip.by_name(\"_meta\")?;\n    let entries: Vec<UploadEntry> = serde_json::from_reader(meta_file.take(50 * 1024))?;\n    if entries.len() > 25 {\n        invalid_input!(\"too many files in zip\");\n    }\n\n    // extract files/deletions from zip\n    entries\n        .into_iter()\n        .map(|entry| {\n            if entry.actual_filename.len() > MAX_MEDIA_FILENAME_LENGTH_SERVER {\n                invalid_input!(\"filename too long: {}\", entry.actual_filename.len());\n            }\n            if !is_nfc(&entry.actual_filename) {\n                invalid_input!(\"filename was not not in nfc: {}\", entry.actual_filename);\n            }\n            if entry.actual_filename.contains(std::path::is_separator) {\n                invalid_input!(\"filename contained separator: {}\", entry.actual_filename);\n            }\n            let data = if let Some(filename_in_zip) = entry.filename_in_zip.as_ref() {\n                if filename_in_zip.is_empty() {\n                    // older clients/AnkiDroid use an empty string instead of null\n                    UploadedChangeKind::Delete\n                } else {\n                    let file = zip.by_name(filename_in_zip)?;\n                    if file.size() > MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64 {\n                        invalid_input!(\"file too large\");\n                    }\n                    let mut data = vec![];\n                    // the .take() is because we don't trust the header to be correct\n                    let bytes_read = file\n                        .take(MAX_INDIVIDUAL_MEDIA_FILE_SIZE as u64)\n                        .read_to_end(&mut data)?;\n                    if bytes_read == 0 {\n                        invalid_input!(\"file entry was zero bytes\");\n                    }\n                    let sha1 = sha1_of_data(&data).to_vec();\n                    UploadedChangeKind::AddOrReplace {\n                        nonempty_data: data,\n                        sha1,\n                    }\n                }\n            } else {\n                UploadedChangeKind::Delete\n            };\n            Ok(UploadedChange {\n                nfc_filename: entry.actual_filename,\n                kind: data,\n            })\n        })\n        .collect()\n}\n\n#[derive(Serialize_tuple, Deserialize)]\nstruct UploadEntry {\n    actual_filename: String,\n    filename_in_zip: Option<String>,\n}\n"
  },
  {
    "path": "rslib/src/sync/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod collection;\npub mod error;\npub mod http_client;\npub mod http_server;\npub mod login;\npub mod media;\npub mod request;\npub mod response;\npub mod version;\n"
  },
  {
    "path": "rslib/src/sync/request/header_and_stream.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::fmt::Display;\nuse std::io::Cursor;\nuse std::io::ErrorKind;\nuse std::marker::PhantomData;\nuse std::net::IpAddr;\n\nuse axum::http::StatusCode;\nuse axum_extra::headers::Header;\nuse axum_extra::headers::HeaderName;\nuse axum_extra::headers::HeaderValue;\nuse bytes::Bytes;\nuse futures::Stream;\nuse futures::TryStreamExt;\nuse serde::de::DeserializeOwned;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse tokio::io::AsyncReadExt;\nuse tokio_util::io::ReaderStream;\n\nuse crate::sync::error::HttpError;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\nuse crate::sync::version::SyncVersion;\n\nimpl<T> SyncRequest<T> {\n    pub(super) async fn from_header_and_stream<S, E>(\n        sync_header: SyncHeader,\n        body_stream: S,\n        ip: IpAddr,\n    ) -> HttpResult<SyncRequest<T>>\n    where\n        S: Stream<Item = Result<Bytes, E>> + Unpin,\n        E: Display,\n        T: DeserializeOwned,\n    {\n        sync_header.sync_version.ensure_supported()?;\n        let data = decode_zstd_body_for_server(body_stream).await?;\n        Ok(Self {\n            sync_key: sync_header.sync_key,\n            session_key: sync_header.session_key,\n            media_client_version: None,\n            data,\n            ip,\n            json_output_type: PhantomData,\n            sync_version: sync_header.sync_version,\n            client_version: sync_header.client_ver,\n        })\n    }\n}\n\n/// Enforces max payload size\npub async fn decode_zstd_body_for_server<S, E>(data: S) -> HttpResult<Vec<u8>>\nwhere\n    S: Stream<Item = Result<Bytes, E>> + Unpin,\n    E: Display,\n{\n    let reader = tokio_util::io::StreamReader::new(\n        data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!(\"{e}\"))),\n    );\n    let reader = async_compression::tokio::bufread::ZstdDecoder::new(reader);\n    let mut buf: Vec<u8> = vec![];\n    reader\n        .take(*MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED)\n        .read_to_end(&mut buf)\n        .await\n        .or_bad_request(\"decoding zstd body\")?;\n    Ok(buf)\n}\n\n/// Does not enforce payload size\npub fn decode_zstd_body_stream_for_client<S, E>(data: S) -> impl Stream<Item = HttpResult<Bytes>>\nwhere\n    S: Stream<Item = Result<Bytes, E>> + Unpin,\n    E: Display,\n{\n    let reader = tokio_util::io::StreamReader::new(\n        data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!(\"{e}\"))),\n    );\n    let reader = async_compression::tokio::bufread::ZstdDecoder::new(reader);\n    ReaderStream::new(reader).map_err(|err| HttpError {\n        code: StatusCode::BAD_REQUEST,\n        context: \"decode zstd body\".into(),\n        source: Some(Box::new(err) as _),\n    })\n}\n\npub fn encode_zstd_body(data: Vec<u8>) -> impl Stream<Item = HttpResult<Bytes>> + Unpin {\n    let enc = async_compression::tokio::bufread::ZstdEncoder::new(Cursor::new(data));\n    ReaderStream::new(enc).map_err(|err| HttpError {\n        code: StatusCode::INTERNAL_SERVER_ERROR,\n        context: \"encode zstd body\".into(),\n        source: Some(Box::new(err) as _),\n    })\n}\n\npub fn encode_zstd_body_stream<S, E>(data: S) -> impl Stream<Item = HttpResult<Bytes>>\nwhere\n    S: Stream<Item = Result<Bytes, E>> + Unpin,\n    E: Display,\n{\n    let reader = tokio_util::io::StreamReader::new(\n        data.map_err(|e| std::io::Error::new(ErrorKind::ConnectionAborted, format!(\"{e}\"))),\n    );\n    let reader = async_compression::tokio::bufread::ZstdEncoder::new(reader);\n    ReaderStream::new(reader).map_err(|err| HttpError {\n        code: StatusCode::BAD_REQUEST,\n        context: \"encode zstd body\".into(),\n        source: Some(Box::new(err) as _),\n    })\n}\n\n#[derive(Serialize, Deserialize)]\npub struct SyncHeader {\n    #[serde(rename = \"v\")]\n    pub sync_version: SyncVersion,\n    #[serde(rename = \"k\")]\n    pub sync_key: String,\n    #[serde(rename = \"c\")]\n    pub client_ver: String,\n    #[serde(rename = \"s\")]\n    pub session_key: String,\n}\n\npub static SYNC_HEADER_NAME: HeaderName = HeaderName::from_static(\"anki-sync\");\n\nimpl Header for SyncHeader {\n    fn name() -> &'static HeaderName {\n        &SYNC_HEADER_NAME\n    }\n\n    fn decode<'i, I>(values: &mut I) -> Result<Self, axum_extra::headers::Error>\n    where\n        Self: Sized,\n        I: Iterator<Item = &'i HeaderValue>,\n    {\n        values\n            .next()\n            .and_then(|value| value.to_str().ok())\n            .and_then(|s| serde_json::from_str(s).ok())\n            .ok_or_else(axum_extra::headers::Error::invalid)\n    }\n\n    fn encode<E: Extend<HeaderValue>>(&self, _values: &mut E) {\n        todo!()\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/request/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\npub mod header_and_stream;\nmod multipart;\n\nuse std::any::Any;\nuse std::env;\nuse std::marker::PhantomData;\nuse std::net::IpAddr;\nuse std::sync::LazyLock;\n\nuse axum::body::Body;\nuse axum::extract::FromRequest;\nuse axum::extract::Multipart;\nuse axum::http::Request;\nuse axum::http::StatusCode;\nuse axum::RequestPartsExt;\nuse axum_client_ip::ClientIp;\nuse axum_extra::TypedHeader;\nuse header_and_stream::SyncHeader;\nuse serde::de::DeserializeOwned;\nuse serde::Serialize;\nuse serde_json::Error;\nuse tracing::Span;\n\nuse crate::sync::error::HttpError;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::version::SyncVersion;\nuse crate::version::sync_client_version_short;\n\n/// Stores the bytes of a sync request, the associated type they\n/// represent, and authentication info provided in headers/multipart\n/// forms. For a SyncRequest<Foo>, you can call .json() to get a Foo\n/// struct from the bytes.\n#[derive(Clone)]\npub struct SyncRequest<T> {\n    pub data: Vec<u8>,\n    json_output_type: PhantomData<T>,\n    pub sync_version: SyncVersion,\n    /// empty with older clients\n    pub client_version: String,\n    pub ip: IpAddr,\n    /// Non-empty on every non-login request.\n    pub sync_key: String,\n    /// May not be set on some requests by legacy clients. Used by stateful sync\n    /// methods to check for concurrent access.\n    pub session_key: String,\n    /// Set by legacy clients when posting to msync/begin\n    pub media_client_version: Option<String>,\n}\n\nimpl<T> SyncRequest<T>\nwhere\n    T: DeserializeOwned,\n{\n    pub fn from_data(\n        data: Vec<u8>,\n        host_key: String,\n        session_key: String,\n        ip: IpAddr,\n        sync_version: SyncVersion,\n    ) -> SyncRequest<T> {\n        SyncRequest {\n            data,\n            json_output_type: Default::default(),\n            ip,\n            sync_key: host_key,\n            session_key,\n            media_client_version: None,\n            sync_version,\n            client_version: String::new(),\n        }\n    }\n\n    /// Given a generic Self<Vec<u8>>, infer the actual type based on context.\n    pub fn into_output_type<O>(self) -> SyncRequest<O> {\n        SyncRequest {\n            data: self.data,\n            json_output_type: PhantomData,\n            ip: self.ip,\n            sync_key: self.sync_key,\n            session_key: self.session_key,\n            media_client_version: self.media_client_version,\n            sync_version: self.sync_version,\n            client_version: self.client_version,\n        }\n    }\n\n    pub fn json(&self) -> HttpResult<T> {\n        serde_json::from_slice(&self.data).or_bad_request(\"invalid json\")\n    }\n\n    pub fn skey(&self) -> HttpResult<&str> {\n        if self.session_key.is_empty() {\n            None.or_bad_request(\"missing skey\")?;\n        }\n        Ok(&self.session_key)\n    }\n}\n\nimpl<S, T> FromRequest<S> for SyncRequest<T>\nwhere\n    S: Send + Sync,\n    T: DeserializeOwned,\n{\n    type Rejection = HttpError;\n\n    async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {\n        let (mut parts, body) = req.into_parts();\n\n        let ip = parts\n            .extract::<ClientIp>()\n            .await\n            .map_err(|_| {\n                HttpError::new_without_source(StatusCode::INTERNAL_SERVER_ERROR, \"missing ip\")\n            })?\n            .0;\n        Span::current().record(\"ip\", ip.to_string());\n\n        let sync_header: Option<TypedHeader<SyncHeader>> =\n            parts.extract().await.or_bad_request(\"bad sync header\")?;\n        let req = Request::from_parts(parts, body);\n\n        if let Some(TypedHeader(sync_header)) = sync_header {\n            let stream = Body::from_request(req, state)\n                .await\n                .expect(\"infallible\")\n                .into_data_stream();\n            SyncRequest::from_header_and_stream(sync_header, stream, ip).await\n        } else {\n            let multi = Multipart::from_request(req, state)\n                .await\n                .or_bad_request(\"multipart\")?;\n            SyncRequest::from_multipart(multi, ip).await\n        }\n    }\n}\n\npub trait IntoSyncRequest {\n    fn try_into_sync_request(self) -> Result<SyncRequest<Self>, serde_json::Error>\n    where\n        Self: Sized + 'static;\n}\n\nimpl<T> IntoSyncRequest for T\nwhere\n    T: Serialize,\n{\n    fn try_into_sync_request(self) -> Result<SyncRequest<Self>, Error>\n    where\n        Self: Sized + 'static,\n    {\n        // A not-very-elegant workaround for the fact that a separate impl for vec<u8>\n        // would conflict with this generic one.\n        let is_data = (&self as &dyn Any).is::<Vec<u8>>();\n        let data = if is_data {\n            let boxed_self = (Box::new(self) as Box<dyn Any>)\n                .downcast::<Vec<u8>>()\n                .unwrap();\n            *boxed_self\n        } else {\n            serde_json::to_vec(&self)?\n        };\n        Ok(SyncRequest {\n            data,\n            json_output_type: PhantomData,\n            ip: IpAddr::from([0, 0, 0, 0]),\n            media_client_version: None,\n            sync_version: SyncVersion::latest(),\n            client_version: sync_client_version_short().to_string(),\n            // injected by client.request()\n            sync_key: String::new(),\n            session_key: String::new(),\n        })\n    }\n}\n\npub static MAXIMUM_SYNC_PAYLOAD_BYTES: LazyLock<usize> = LazyLock::new(|| {\n    env::var(\"MAX_SYNC_PAYLOAD_MEGS\")\n        .map(|v| v.parse().expect(\"invalid upload limit\"))\n        .unwrap_or(100)\n        * 1024\n        * 1024\n});\n/// Client ignores this when a non-AnkiWeb endpoint is configured. Controls the\n/// maximum size of a payload after decompression, which effectively limits the\n/// how large a collection file can be uploaded.\npub static MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED: LazyLock<u64> =\n    LazyLock::new(|| (*MAXIMUM_SYNC_PAYLOAD_BYTES * 3) as u64);\n"
  },
  {
    "path": "rslib/src/sync/request/multipart.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::io::Read;\nuse std::marker::PhantomData;\nuse std::net::IpAddr;\n\nuse axum::extract::Multipart;\nuse bytes::Buf;\nuse bytes::Bytes;\nuse flate2::read::GzDecoder;\nuse tokio::task::spawn_blocking;\n\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::request::SyncRequest;\nuse crate::sync::request::MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED;\nuse crate::sync::version::SyncVersion;\nuse crate::sync::version::SYNC_VERSION_10_V2_TIMEZONE;\n\nimpl<T> SyncRequest<T> {\n    pub(super) async fn from_multipart(\n        mut multi: Multipart,\n        ip: IpAddr,\n    ) -> HttpResult<SyncRequest<T>> {\n        let mut host_key = String::new();\n        let mut session_key = String::new();\n        let mut media_client_version = None;\n        let mut compressed = false;\n        let mut data = None;\n        while let Some(field) = multi\n            .next_field()\n            .await\n            .or_bad_request(\"invalid multipart\")?\n        {\n            match field.name() {\n                Some(\"c\") => {\n                    // normal syncs should always be compressed, but media syncs may compress the\n                    // zip instead\n                    let c = field.text().await.or_bad_request(\"malformed c\")?;\n                    compressed = c != \"0\";\n                }\n                Some(\"k\") | Some(\"sk\") => {\n                    host_key = field.text().await.or_bad_request(\"malformed (s)k\")?;\n                }\n                Some(\"s\") => session_key = field.text().await.or_bad_request(\"malformed s\")?,\n                Some(\"v\") => {\n                    media_client_version = Some(field.text().await.or_bad_request(\"malformed v\")?)\n                }\n                Some(\"data\") => {\n                    data = Some(\n                        field\n                            .bytes()\n                            .await\n                            .or_bad_request(\"missing data for multi\")?,\n                    )\n                }\n                _ => {}\n            }\n        }\n        let data = {\n            let data = data.unwrap_or_default();\n            if data.is_empty() {\n                // AnkiDroid omits 'data' when downloading\n                b\"{}\".to_vec()\n            } else if compressed {\n                decode_gzipped_data(data).await?\n            } else {\n                data.to_vec()\n            }\n        };\n        Ok(Self {\n            ip,\n            sync_key: host_key,\n            session_key,\n            media_client_version,\n            data,\n            json_output_type: PhantomData,\n            // may be lower - the old protocol didn't provide the version on every request\n            sync_version: SyncVersion(SYNC_VERSION_10_V2_TIMEZONE),\n            client_version: String::new(),\n        })\n    }\n}\n\npub async fn decode_gzipped_data(data: Bytes) -> HttpResult<Vec<u8>> {\n    // actix uses this threshold, so presumably they've measured\n    if data.len() < 2049 {\n        decode_gzipped_data_inner(data)\n    } else {\n        spawn_blocking(move || decode_gzipped_data_inner(data))\n            .await\n            .or_internal_err(\"decode gzip join\")?\n    }\n}\n\nfn decode_gzipped_data_inner(data: Bytes) -> HttpResult<Vec<u8>> {\n    let mut gz = GzDecoder::new(data.reader()).take(*MAXIMUM_SYNC_PAYLOAD_BYTES_UNCOMPRESSED);\n    let mut data = Vec::new();\n    gz.read_to_end(&mut data).or_bad_request(\"invalid gzip\")?;\n    Ok(data)\n}\n"
  },
  {
    "path": "rslib/src/sync/response.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::marker::PhantomData;\n\nuse axum::body::Body;\nuse axum::response::IntoResponse;\nuse axum::response::Response;\nuse axum_extra::headers::HeaderName;\nuse serde::de::DeserializeOwned;\nuse serde::Serialize;\n\nuse crate::prelude::*;\nuse crate::sync::collection::upload::UploadResponse;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\nuse crate::sync::request::header_and_stream::encode_zstd_body;\nuse crate::sync::version::SyncVersion;\n\npub static ORIGINAL_SIZE: HeaderName = HeaderName::from_static(\"anki-original-size\");\n\n/// Stores the data returned from a sync request, and the type\n/// it represents. Given a SyncResponse<Foo>, you can get a Foo\n/// struct via .json(), except for uploads/downloads.\n#[derive(Debug)]\npub struct SyncResponse<T> {\n    pub data: Vec<u8>,\n    json_output_type: PhantomData<T>,\n}\n\nimpl<T> SyncResponse<T> {\n    pub fn from_vec(data: Vec<u8>) -> SyncResponse<T> {\n        SyncResponse {\n            data,\n            json_output_type: Default::default(),\n        }\n    }\n\n    pub fn make_response(self, sync_version: SyncVersion) -> Response {\n        if sync_version.is_zstd() {\n            let header = (&ORIGINAL_SIZE, self.data.len().to_string());\n            let body = Body::from_stream(encode_zstd_body(self.data));\n            ([header], body).into_response()\n        } else {\n            self.data.into_response()\n        }\n    }\n}\n\nimpl SyncResponse<UploadResponse> {\n    // Unfortunately the sync protocol sends this as a bare string\n    // instead of JSON.\n    pub fn upload_response(&self) -> UploadResponse {\n        let resp = String::from_utf8_lossy(&self.data);\n        match resp.as_ref() {\n            \"OK\" => UploadResponse::Ok,\n            other => UploadResponse::Err(other.into()),\n        }\n    }\n\n    pub fn from_upload_response(resp: UploadResponse) -> Self {\n        let text = match resp {\n            UploadResponse::Ok => \"OK\".into(),\n            UploadResponse::Err(other) => other,\n        };\n        SyncResponse::from_vec(text.into_bytes())\n    }\n}\n\nimpl<T> SyncResponse<T>\nwhere\n    T: Serialize,\n{\n    pub fn try_from_obj(obj: T) -> HttpResult<SyncResponse<T>> {\n        let data = serde_json::to_vec(&obj).or_internal_err(\"couldn't serialize object\")?;\n        Ok(SyncResponse::from_vec(data))\n    }\n}\n\nimpl<T> SyncResponse<T>\nwhere\n    T: DeserializeOwned,\n{\n    pub fn json(&self) -> Result<T> {\n        serde_json::from_slice(&self.data).map_err(Into::into)\n    }\n}\n"
  },
  {
    "path": "rslib/src/sync/version.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse serde::Deserialize;\nuse serde::Serialize;\n\nuse crate::storage::SchemaVersion;\nuse crate::sync::error::HttpResult;\nuse crate::sync::error::OrHttpErr;\n\npub const SYNC_VERSION_MIN: u8 = SYNC_VERSION_08_SESSIONKEY;\npub const SYNC_VERSION_MAX: u8 = SYNC_VERSION_11_DIRECT_POST;\n\n/// Added in 2013. Introduced a session key to identify parallel attempts at\n/// syncing. At the end of 2022, only used by 0.045% of syncers. Half are\n/// AnkiUniversal users, as it never added support for the V2 scheduler.\npub const SYNC_VERSION_08_SESSIONKEY: u8 = 8;\n\n/// Added Jan 2018. No functional changes to protocol, but marks that the client\n/// supports the V2 scheduler.\n///\n/// In July 2018 a separate chunked graves method was added, but was optional.\n/// At the end of 2022, AnkiDroid is still using the old approach of passing all\n/// graves to the start method in the legacy schema path.\npub const SYNC_VERSION_09_V2_SCHEDULER: u8 = 9;\n\n/// Added Mar 2020. No functional changes to protocol, but marks that the client\n/// supports the V2 timezone changes.\npub const SYNC_VERSION_10_V2_TIMEZONE: u8 = 10;\n\n/// Added Jan 2023. Switches from packaging messages in a multipart request with\n/// gzip to using headers and zstd, and stops using a separate session key for\n/// media syncs. Schema 18 uploads/downloads are now supported, and hostNum has\n/// been deprecated in favour of a redirect.\npub const SYNC_VERSION_11_DIRECT_POST: u8 = 11;\n\n#[derive(Debug, Serialize, Deserialize, Clone, Copy)]\n#[repr(transparent)]\npub struct SyncVersion(pub u8);\n\nimpl SyncVersion {\n    pub fn is_too_old(&self) -> bool {\n        self.0 < SYNC_VERSION_MIN\n    }\n\n    pub fn is_too_new(&self) -> bool {\n        self.0 > SYNC_VERSION_MAX\n    }\n\n    pub fn ensure_supported(&self) -> HttpResult<()> {\n        if self.is_too_old() || self.is_too_new() {\n            None.or_bad_request(format!(\"unsupported sync version: {}\", self.0))?;\n        }\n        Ok(())\n    }\n\n    pub fn latest() -> Self {\n        SyncVersion(SYNC_VERSION_MAX)\n    }\n\n    pub fn multipart() -> Self {\n        Self(SYNC_VERSION_10_V2_TIMEZONE)\n    }\n\n    pub fn is_multipart(&self) -> bool {\n        self.0 < SYNC_VERSION_11_DIRECT_POST\n    }\n\n    pub fn is_zstd(&self) -> bool {\n        self.0 >= SYNC_VERSION_11_DIRECT_POST\n    }\n\n    pub fn collection_schema(&self) -> SchemaVersion {\n        if self.is_multipart() {\n            SchemaVersion::V11\n        } else {\n            SchemaVersion::V18\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/bulkadd.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n//! Adding tags to selected notes in the browse screen.\n\nuse std::collections::HashSet;\n\nuse unicase::UniCase;\n\nuse super::join_tags;\nuse super::split_tags;\nuse crate::notes::NoteTags;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn add_tags_to_notes(&mut self, nids: &[NoteId], tags: &str) -> Result<OpOutput<usize>> {\n        self.transact(Op::UpdateTag, |col| col.add_tags_to_notes_inner(nids, tags))\n    }\n}\n\nimpl Collection {\n    pub(crate) fn add_tags_to_notes_inner(&mut self, nids: &[NoteId], tags: &str) -> Result<usize> {\n        let usn = self.usn()?;\n\n        // will update tag list for any new tags, and match case\n        let tags_to_add = self.canonified_tags_as_vec(tags, usn)?;\n\n        // modify notes\n        let mut match_count = 0;\n        let notes = self.storage.get_note_tags_by_id_list(nids)?;\n        for original in notes {\n            if let Some(updated_tags) = add_missing_tags(&original.tags, &tags_to_add) {\n                match_count += 1;\n                let mut note = NoteTags {\n                    tags: updated_tags,\n                    ..original\n                };\n                note.set_modified(usn);\n                self.update_note_tags_undoable(&note, original)?;\n            }\n        }\n\n        Ok(match_count)\n    }\n}\n\n/// Returns the sorted new tag string if any tags were added.\nfn add_missing_tags(note_tags: &str, desired: &[UniCase<String>]) -> Option<String> {\n    let mut note_tags: HashSet<_> = split_tags(note_tags)\n        .map(ToOwned::to_owned)\n        .map(UniCase::new)\n        .collect();\n\n    let mut modified = false;\n    for tag in desired {\n        if !note_tags.contains(tag) {\n            note_tags.insert(tag.clone());\n            modified = true;\n        }\n    }\n    if !modified {\n        return None;\n    }\n\n    // sort\n    let mut tags: Vec<_> = note_tags.into_iter().collect::<Vec<_>>();\n    tags.sort_unstable();\n\n    // turn back into a string\n    let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect();\n    Some(join_tags(&tags))\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn add_missing() {\n        let desired: Vec<_> = [\"xyz\", \"abc\", \"DEF\"]\n            .iter()\n            .map(|s| UniCase::new(s.to_string()))\n            .collect();\n\n        let add_to = |text| add_missing_tags(text, &desired).unwrap();\n\n        assert_eq!(&add_to(\"\"), \" abc DEF xyz \");\n        assert_eq!(&add_to(\"XYZ deF aaa\"), \" aaa abc deF XYZ \");\n        assert!(add_missing_tags(\"def xyz abc\", &desired).is_none());\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/complete.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse regex::Regex;\n\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn complete_tag(&self, input: &str, limit: usize) -> Result<Vec<String>> {\n        let filters: Vec<_> = input\n            .split(\"::\")\n            .map(component_to_regex)\n            .collect::<Result<_, _>>()?;\n        let mut tags = vec![];\n        let mut priority = vec![];\n        self.storage.get_tags_by_predicate(|tag| {\n            if priority.len() + tags.len() <= limit {\n                match filters_match(&filters, tag) {\n                    Some(true) => priority.push(tag.to_string()),\n                    Some(_) => tags.push(tag.to_string()),\n                    _ => {}\n                }\n            }\n            // we only need the tag name\n            false\n        })?;\n        priority.append(&mut tags);\n        Ok(priority)\n    }\n}\n\nfn component_to_regex(component: &str) -> Result<Regex> {\n    Regex::new(&format!(\"(?i){}\", regex::escape(component))).map_err(Into::into)\n}\n\n/// Returns None if tag wasn't a match, otherwise whether it was a consecutive\n/// prefix match\nfn filters_match(filters: &[Regex], tag: &str) -> Option<bool> {\n    let mut remaining_tag_components = tag.split(\"::\");\n    let mut is_prefix = true;\n    'outer: for filter in filters {\n        loop {\n            if let Some(component) = remaining_tag_components.next() {\n                if let Some(m) = filter.find(component) {\n                    is_prefix &= m.start() == 0;\n                    continue 'outer;\n                } else {\n                    is_prefix = false;\n                }\n            } else {\n                return None;\n            }\n        }\n    }\n    Some(is_prefix)\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn matching() -> Result<()> {\n        let filters = &[component_to_regex(\"b\")?];\n        assert!(filters_match(filters, \"ABC\").is_some());\n        assert!(filters_match(filters, \"ABC::def\").is_some());\n        assert!(filters_match(filters, \"def::abc\").is_some());\n        assert!(filters_match(filters, \"def\").is_none());\n\n        let filters = &[component_to_regex(\"b\")?, component_to_regex(\"E\")?];\n        assert!(filters_match(filters, \"ABC\").is_none());\n        assert!(filters_match(filters, \"ABC::def\").is_some());\n        assert!(filters_match(filters, \"def::abc\").is_none());\n        assert!(filters_match(filters, \"def\").is_none());\n\n        let filters = &[\n            component_to_regex(\"a\")?,\n            component_to_regex(\"c\")?,\n            component_to_regex(\"e\")?,\n        ];\n        assert!(filters_match(filters, \"ace\").is_none());\n        assert!(filters_match(filters, \"a::c\").is_none());\n        assert!(filters_match(filters, \"c::e\").is_none());\n        assert!(filters_match(filters, \"a::c::e\").is_some());\n        assert!(filters_match(filters, \"a::b::c::d::e\").is_some());\n        assert!(filters_match(filters, \"1::a::b::c::d::e::f\").is_some());\n\n        assert_eq!(filters_match(filters, \"a1::c2::e3\"), Some(true));\n        assert_eq!(filters_match(filters, \"a1::c2::?::e4\"), Some(false));\n        assert_eq!(filters_match(filters, \"a1::c2::3e\"), Some(false));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/findreplace.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\n\nuse regex::NoExpand;\nuse regex::Regex;\nuse regex::Replacer;\n\nuse super::is_tag_separator;\nuse super::join_tags;\nuse super::split_tags;\nuse crate::notes::NoteTags;\nuse crate::prelude::*;\n\nimpl Collection {\n    /// Replace occurrences of a search with a new value in tags.\n    pub fn find_and_replace_tag(\n        &mut self,\n        nids: &[NoteId],\n        search: &str,\n        replacement: &str,\n        regex: bool,\n        match_case: bool,\n    ) -> Result<OpOutput<usize>> {\n        require!(\n            !replacement.contains(is_tag_separator),\n            \"replacement name cannot contain a space\",\n        );\n\n        let mut search = if regex {\n            Cow::from(search)\n        } else {\n            Cow::from(regex::escape(search))\n        };\n\n        if !match_case {\n            search = format!(\"(?i){search}\").into();\n        }\n\n        self.transact(Op::UpdateTag, |col| {\n            if regex {\n                col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, replacement)\n            } else {\n                col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, NoExpand(replacement))\n            }\n        })\n    }\n}\n\nimpl Collection {\n    fn replace_tags_for_notes_inner<R: Replacer>(\n        &mut self,\n        nids: &[NoteId],\n        regex: Regex,\n        mut repl: R,\n    ) -> Result<usize> {\n        let usn = self.usn()?;\n        let mut match_count = 0;\n        let notes = self.storage.get_note_tags_by_id_list(nids)?;\n\n        for original in notes {\n            if let Some(updated_tags) = replace_tags(&original.tags, &regex, repl.by_ref()) {\n                let (tags, _) = self.canonify_tags(updated_tags, usn)?;\n\n                match_count += 1;\n                let mut note = NoteTags {\n                    tags: join_tags(&tags),\n                    ..original\n                };\n                note.set_modified(usn);\n                self.update_note_tags_undoable(&note, original)?;\n            }\n        }\n\n        Ok(match_count)\n    }\n}\n\n/// If any tags are changed, return the new tags list.\n/// The returned tags will need to be canonified.\nfn replace_tags<R>(tags: &str, regex: &Regex, mut repl: R) -> Option<Vec<String>>\nwhere\n    R: Replacer,\n{\n    let maybe_replaced: Vec<_> = split_tags(tags)\n        .map(|tag| regex.replace_all(tag, repl.by_ref()))\n        .collect();\n\n    if maybe_replaced\n        .iter()\n        .any(|cow| matches!(cow, Cow::Owned(_)))\n    {\n        Some(maybe_replaced.into_iter().map(|s| s.to_string()).collect())\n    } else {\n        // nothing matched\n        None\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::decks::DeckId;\n\n    #[test]\n    fn find_replace() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.tags.push(\"test\".into());\n        col.add_note(&mut note, DeckId(1))?;\n\n        col.find_and_replace_tag(&[note.id], \"foo|test\", \"bar\", true, false)?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.tags[0], \"bar\");\n\n        col.find_and_replace_tag(&[note.id], \"BAR\", \"baz\", false, true)?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.tags[0], \"bar\");\n\n        col.find_and_replace_tag(&[note.id], \"b.r\", \"baz\", false, false)?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.tags[0], \"bar\");\n\n        col.find_and_replace_tag(&[note.id], \"b.r\", \"baz\", true, false)?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(note.tags[0], \"baz\");\n\n        let out = col.add_tags_to_notes(&[note.id], \"cee aye\")?;\n        assert_eq!(out.output, 1);\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(&note.tags, &[\"aye\", \"baz\", \"cee\"]);\n\n        // if all tags already on note, it doesn't get updated\n        let out = col.add_tags_to_notes(&[note.id], \"cee aye\")?;\n        assert_eq!(out.output, 0);\n\n        // empty replacement deletes tag\n        col.find_and_replace_tag(&[note.id], \"b.*|.*ye\", \"\", true, false)?;\n        let note = col.storage.get_note(note.id)?.unwrap();\n        assert_eq!(&note.tags, &[\"cee\"]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/matcher.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashSet;\n\nuse regex::Captures;\nuse regex::Regex;\n\nuse super::join_tags;\nuse super::split_tags;\nuse crate::prelude::*;\npub(crate) struct TagMatcher {\n    regex: Regex,\n    new_tags: HashSet<String>,\n}\n\n/// Helper to match any of the provided space-separated tags in a space-\n/// separated list of tags, and replace the prefix.\n///\n/// Tracks seen tags during replacement, so the tag list can be updated as well.\nimpl TagMatcher {\n    pub fn new(space_separated_tags: &str) -> Result<Self> {\n        // convert \"fo*o bar\" into \"fo\\*o|bar\"\n        let tags: Vec<_> = split_tags(space_separated_tags)\n            .map(regex::escape)\n            .collect();\n        let tags = tags.join(\"|\");\n\n        let regex = Regex::new(&format!(\n            r#\"(?ix)\n            # start of string, or a space\n            (?:^|\\ )\n            # 1: the tag prefix\n            (\n                {tags}\n            )\n            (?:\n                # 2: an optional child separator\n                (::)\n                # or a space/end of string the end of the string\n                |\\ |$\n            )\n        \"#\n        ))?;\n\n        Ok(Self {\n            regex,\n            new_tags: HashSet::new(),\n        })\n    }\n\n    pub fn is_match(&self, space_separated_tags: &str) -> bool {\n        self.regex.is_match(space_separated_tags)\n    }\n\n    pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String {\n        let tags: Vec<_> = split_tags(space_separated_tags)\n            .map(|tag| {\n                let out = self.regex.replace(tag, |caps: &Captures| {\n                    // if we captured the child separator, add it to the replacement\n                    if caps.get(2).is_some() {\n                        Cow::Owned(format!(\"{replacement}::\"))\n                    } else {\n                        Cow::Borrowed(replacement)\n                    }\n                });\n                if let Cow::Owned(out) = out {\n                    if !self.new_tags.contains(&out) {\n                        self.new_tags.insert(out.clone());\n                    }\n                    out\n                } else {\n                    out.to_string()\n                }\n            })\n            .collect();\n\n        join_tags(tags.as_slice())\n    }\n\n    /// The `replacement` function should return the text to use as a\n    /// replacement.\n    pub fn replace_with_fn<F>(&mut self, space_separated_tags: &str, replacer: F) -> String\n    where\n        F: Fn(&str) -> String,\n    {\n        let tags: Vec<_> = split_tags(space_separated_tags)\n            .map(|tag| {\n                let out = self.regex.replace(tag, |caps: &Captures| {\n                    let replacement = replacer(caps.get(1).unwrap().as_str());\n                    // if we captured the child separator, add it to the replacement\n                    if caps.get(2).is_some() {\n                        format!(\"{replacement}::\")\n                    } else {\n                        replacement\n                    }\n                });\n                if let Cow::Owned(out) = out {\n                    if !self.new_tags.contains(&out) {\n                        self.new_tags.insert(out.clone());\n                    }\n                    out\n                } else {\n                    out.to_string()\n                }\n            })\n            .collect();\n\n        join_tags(tags.as_slice())\n    }\n\n    /// Remove any matching tags. Does not update seen_tags.\n    pub fn remove(&mut self, space_separated_tags: &str) -> String {\n        let tags: Vec<_> = split_tags(space_separated_tags)\n            .filter(|&tag| !self.is_match(tag))\n            .map(ToString::to_string)\n            .collect();\n\n        join_tags(tags.as_slice())\n    }\n\n    /// Returns all replaced values that were used, so they can be registered\n    /// into the tag list.\n    pub fn into_new_tags(self) -> HashSet<String> {\n        self.new_tags\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn regex() -> Result<()> {\n        let re = TagMatcher::new(\"one two\")?;\n        assert!(!re.is_match(\" foo \"));\n        assert!(re.is_match(\" foo one \"));\n        assert!(re.is_match(\" two foo \"));\n\n        let mut re = TagMatcher::new(\"foo\")?;\n        assert!(re.is_match(\"foo\"));\n        assert!(re.is_match(\" foo \"));\n        assert!(re.is_match(\" bar foo baz \"));\n        assert!(re.is_match(\" bar FOO baz \"));\n        assert!(!re.is_match(\" bar foof baz \"));\n        assert!(!re.is_match(\" barfoo \"));\n\n        let mut as_xxx = |text| re.replace(text, \"xxx\");\n\n        assert_eq!(&as_xxx(\" baz FOO \"), \" baz xxx \");\n        assert_eq!(&as_xxx(\" x foo::bar x \"), \" x xxx::bar x \");\n        assert_eq!(\n            &as_xxx(\" x foo::bar bar::foo x \"),\n            \" x xxx::bar bar::foo x \"\n        );\n        assert_eq!(\n            &as_xxx(\" x foo::bar foo::bar::baz x \"),\n            \" x xxx::bar xxx::bar::baz x \"\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod bulkadd;\nmod complete;\nmod findreplace;\nmod matcher;\nmod notes;\nmod register;\nmod remove;\nmod rename;\nmod reparent;\nmod service;\nmod tree;\npub(crate) mod undo;\n\nuse unicase::UniCase;\n\nuse crate::prelude::*;\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct Tag {\n    pub name: String,\n    pub usn: Usn,\n    pub expanded: bool,\n}\n\nimpl Tag {\n    pub fn new(name: String, usn: Usn) -> Self {\n        Tag {\n            name,\n            usn,\n            expanded: false,\n        }\n    }\n\n    pub(crate) fn set_modified(&mut self, usn: Usn) {\n        self.usn = usn;\n    }\n}\n\npub(crate) fn split_tags(tags: &str) -> impl Iterator<Item = &str> {\n    tags.split(is_tag_separator).filter(|tag| !tag.is_empty())\n}\n\npub(crate) fn join_tags(tags: &[String]) -> String {\n    if tags.is_empty() {\n        \"\".into()\n    } else {\n        format!(\" {} \", tags.join(\" \"))\n    }\n}\n\nfn is_tag_separator(c: char) -> bool {\n    c == ' ' || c == '\\u{3000}'\n}\n\npub(crate) fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option<UniCase<&str>> {\n    tag_name.rsplit_once(\"::\").map(|t| t.0).map(UniCase::new)\n}\n\nfn immediate_parent_name_str(tag_name: &str) -> Option<&str> {\n    tag_name.rsplit_once(\"::\").map(|t| t.0)\n}\n"
  },
  {
    "path": "rslib/src/tags/notes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\n\nuse unicase::UniCase;\n\nuse super::split_tags;\nuse crate::prelude::*;\nuse crate::search::SearchNode;\n\nimpl Collection {\n    pub(crate) fn all_tags_in_deck(&mut self, deck_id: DeckId) -> Result<HashSet<UniCase<String>>> {\n        let guard = self.search_notes_into_table(SearchNode::DeckIdWithChildren(deck_id))?;\n        let mut all_tags: HashSet<UniCase<String>> = HashSet::new();\n        guard\n            .col\n            .storage\n            .for_each_note_tag_in_searched_notes(|tags| {\n                for tag in split_tags(tags) {\n                    // A benchmark on a large deck indicates that nothing is gained by using a Cow\n                    // and skipping an allocation in the duplicate case, and\n                    // this approach is simpler.\n                    all_tags.insert(UniCase::new(tag.to_string()));\n                }\n            })?;\n        Ok(all_tags)\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/register.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashSet;\n\nuse unicase::UniCase;\n\nuse super::immediate_parent_name_str;\nuse super::is_tag_separator;\nuse super::split_tags;\nuse super::Tag;\nuse crate::prelude::*;\nuse crate::text::normalize_to_nfc;\nuse crate::types::Usn;\n\nimpl Collection {\n    /// Given a list of tags, fix case, ordering and duplicates.\n    /// Returns true if any new tags were added.\n    /// Each tag is split on spaces, so if you have a &str, you\n    /// can pass that in as a one-element vec.\n    pub(crate) fn canonify_tags(\n        &mut self,\n        tags: Vec<String>,\n        usn: Usn,\n    ) -> Result<(Vec<String>, bool)> {\n        self.canonify_tags_inner(tags, usn, true)\n    }\n\n    pub(crate) fn canonify_tags_without_registering(\n        &mut self,\n        tags: Vec<String>,\n        usn: Usn,\n    ) -> Result<Vec<String>> {\n        self.canonify_tags_inner(tags, usn, false)\n            .map(|(tags, _)| tags)\n    }\n\n    /// Like [canonify_tags()], but doesn't save new tags. As a consequence, new\n    /// parents are not canonified.\n    fn canonify_tags_inner(\n        &mut self,\n        tags: Vec<String>,\n        usn: Usn,\n        register: bool,\n    ) -> Result<(Vec<String>, bool)> {\n        let mut seen = HashSet::new();\n        let mut added = false;\n\n        let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();\n        for tag in tags {\n            let mut tag = Tag::new(tag.to_string(), usn);\n            if register {\n                added |= self.register_tag(&mut tag)?;\n            } else {\n                self.prepare_tag_for_registering(&mut tag)?;\n            }\n            seen.insert(UniCase::new(tag.name));\n        }\n\n        // exit early if no non-empty tags\n        if seen.is_empty() {\n            return Ok((vec![], added));\n        }\n\n        // return the sorted, canonified tags\n        let mut tags = seen.into_iter().collect::<Vec<_>>();\n        tags.sort_unstable();\n        let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect();\n\n        Ok((tags, added))\n    }\n\n    /// Returns true if any cards were added to the tag list.\n    pub(crate) fn canonified_tags_as_vec(\n        &mut self,\n        tags: &str,\n        usn: Usn,\n    ) -> Result<Vec<UniCase<String>>> {\n        let mut out_tags = vec![];\n\n        for tag in split_tags(tags) {\n            let mut tag = Tag::new(tag.to_string(), usn);\n            self.register_tag(&mut tag)?;\n            out_tags.push(UniCase::new(tag.name));\n        }\n\n        Ok(out_tags)\n    }\n\n    /// Adjust tag casing to match any existing parents, and register it if it's\n    /// not already in the tags list. True if the tag was added and not\n    /// already in tag list. In the case the tag is already registered, tag\n    /// will be mutated to match the existing name.\n    pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result<bool> {\n        let is_new = self.prepare_tag_for_registering(tag)?;\n        if is_new {\n            self.register_tag_undoable(tag)?;\n        }\n        Ok(is_new)\n    }\n\n    /// Create a tag object, normalize text, and match parents/existing case if\n    /// available. True if tag is new.\n    pub(super) fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result<bool> {\n        let normalized_name = normalize_tag_name(&tag.name)?;\n        if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? {\n            tag.name = existing_tag.name;\n            Ok(false)\n        } else {\n            if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? {\n                tag.name = new_name;\n            } else if let Cow::Owned(new_name) = normalized_name {\n                tag.name = new_name;\n            }\n            Ok(true)\n        }\n    }\n\n    pub(super) fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result<bool> {\n        let mut tag = Tag::new(tag, usn);\n        self.register_tag(&mut tag)\n    }\n}\n\nimpl Collection {\n    /// If parent tag(s) exist and differ in case, return a rewritten tag.\n    pub(super) fn adjusted_case_for_parents(&self, tag: &str) -> Result<Option<String>> {\n        if let Some(parent_tag) = self.first_existing_parent_tag(tag)? {\n            let child_split: Vec<_> = tag.split(\"::\").collect();\n            let parent_count = parent_tag.matches(\"::\").count() + 1;\n            Ok(Some(format!(\n                \"{}::{}\",\n                parent_tag,\n                &child_split[parent_count..].join(\"::\")\n            )))\n        } else {\n            Ok(None)\n        }\n    }\n\n    fn first_existing_parent_tag(&self, mut tag: &str) -> Result<Option<String>> {\n        while let Some(parent_name) = immediate_parent_name_str(tag) {\n            if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? {\n                return Ok(Some(parent_tag));\n            }\n            tag = parent_name;\n        }\n\n        Ok(None)\n    }\n}\n\nfn invalid_char_for_tag(c: char) -> bool {\n    c.is_ascii_control() || is_tag_separator(c)\n}\n\nfn normalized_tag_name_component(comp: &str) -> Cow<'_, str> {\n    let mut out = normalize_to_nfc(comp);\n    if out.contains(invalid_char_for_tag) {\n        out = out.replace(invalid_char_for_tag, \"\").into();\n    }\n    let trimmed = out.trim();\n    if trimmed.is_empty() {\n        \"blank\".to_string().into()\n    } else if trimmed.len() != out.len() {\n        trimmed.to_string().into()\n    } else {\n        out\n    }\n}\n\npub(super) fn normalize_tag_name(name: &str) -> Result<Cow<'_, str>> {\n    let normalized_name: Cow<str> = if name\n        .split(\"::\")\n        .any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_)))\n    {\n        let comps: Vec<_> = name\n            .split(\"::\")\n            .map(normalized_tag_name_component)\n            .collect::<Vec<_>>();\n        comps.join(\"::\").into()\n    } else {\n        // no changes required\n        name.into()\n    };\n    if normalized_name.is_empty() {\n        // this should not be possible\n        invalid_input!(\"blank tag\");\n    } else {\n        Ok(normalized_name)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::decks::DeckId;\n\n    #[test]\n    fn tags() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n\n        let tags: String = col.storage.db_scalar(\"select tags from notes\")?;\n        assert_eq!(tags, \"\");\n\n        // first instance wins in case of duplicates\n        note.tags = vec![\"foo\".into(), \"FOO\".into()];\n        col.update_note(&mut note)?;\n        assert_eq!(&note.tags, &[\"foo\"]);\n        let tags: String = col.storage.db_scalar(\"select tags from notes\")?;\n        assert_eq!(tags, \" foo \");\n\n        // existing case is used if in DB\n        note.tags = vec![\"FOO\".into()];\n        col.update_note(&mut note)?;\n        assert_eq!(&note.tags, &[\"foo\"]);\n        assert_eq!(tags, \" foo \");\n\n        // tags are normalized to nfc\n        note.tags = vec![\"\\u{fa47}\".into()];\n        col.update_note(&mut note)?;\n        assert_eq!(&note.tags, &[\"\\u{6f22}\"]);\n\n        // if code incorrectly adds a space to a tag, it gets split\n        note.tags = vec![\"one two\".into()];\n        col.update_note(&mut note)?;\n        assert_eq!(&note.tags, &[\"one\", \"two\"]);\n\n        // blanks should be handled\n        note.tags = vec![\n            \"\".into(),\n            \"foo\".into(),\n            \" \".into(),\n            \"::\".into(),\n            \"foo::\".into(),\n        ];\n        col.update_note(&mut note)?;\n        assert_eq!(&note.tags, &[\"blank::blank\", \"foo\", \"foo::blank\"]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/remove.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse unicase::UniCase;\n\nuse super::matcher::TagMatcher;\nuse crate::prelude::*;\n\nimpl Collection {\n    /// Take tags as a whitespace-separated string and remove them from all\n    /// notes and the tag list.\n    pub fn remove_tags(&mut self, tags: &str) -> Result<OpOutput<usize>> {\n        self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags))\n    }\n\n    /// Remove whitespace-separated tags from provided notes.\n    pub fn remove_tags_from_notes(\n        &mut self,\n        nids: &[NoteId],\n        tags: &str,\n    ) -> Result<OpOutput<usize>> {\n        self.transact(Op::RemoveTag, |col| {\n            col.remove_tags_from_notes_inner(nids, tags)\n        })\n    }\n\n    /// Remove tags not referenced by notes, returning removed count.\n    pub fn clear_unused_tags(&mut self) -> Result<OpOutput<usize>> {\n        self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner())\n    }\n}\n\nimpl Collection {\n    fn remove_tags_inner(&mut self, tags: &str) -> Result<usize> {\n        let usn = self.usn()?;\n\n        // gather tags that need removing\n        let mut re = TagMatcher::new(tags)?;\n        let matched_notes = self\n            .storage\n            .get_note_tags_by_predicate(|tags| re.is_match(tags))?;\n        let match_count = matched_notes.len();\n\n        // remove from the tag list\n        for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? {\n            self.remove_single_tag_undoable(tag)?;\n        }\n\n        // replace tags\n        for mut note in matched_notes {\n            let original = note.clone();\n            note.tags = re.remove(&note.tags);\n            note.set_modified(usn);\n            self.update_note_tags_undoable(&note, original)?;\n        }\n\n        Ok(match_count)\n    }\n\n    fn remove_tags_from_notes_inner(&mut self, nids: &[NoteId], tags: &str) -> Result<usize> {\n        let usn = self.usn()?;\n\n        let mut re = TagMatcher::new(tags)?;\n        let mut match_count = 0;\n        let notes = self.storage.get_note_tags_by_id_list(nids)?;\n\n        for mut note in notes {\n            if !re.is_match(&note.tags) {\n                continue;\n            }\n\n            match_count += 1;\n            let original = note.clone();\n            note.tags = re.remove(&note.tags);\n            note.set_modified(usn);\n            self.update_note_tags_undoable(&note, original)?;\n        }\n\n        Ok(match_count)\n    }\n\n    fn clear_unused_tags_inner(&mut self) -> Result<usize> {\n        let mut count = 0;\n        let in_notes = self.storage.all_tags_in_notes()?;\n        let need_remove = self\n            .storage\n            .all_tags()?\n            .into_iter()\n            .filter(|tag| !in_notes.contains(&UniCase::new(tag.name.clone())));\n        for tag in need_remove {\n            self.remove_single_tag_undoable(tag)?;\n            count += 1;\n        }\n\n        Ok(count)\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::tags::Tag;\n\n    #[test]\n    fn clearing() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.tags.push(\"one\".into());\n        note.tags.push(\"two\".into());\n        col.add_note(&mut note, DeckId(1))?;\n\n        col.set_tag_collapsed(\"one\", false)?;\n        col.clear_unused_tags()?;\n        assert!(col.storage.get_tag(\"one\")?.unwrap().expanded);\n        assert!(!col.storage.get_tag(\"two\")?.unwrap().expanded);\n\n        // tag children are also cleared when clearing their parent\n        col.storage.clear_all_tags()?;\n        for name in &[\"a\", \"a::b\", \"A::b::c\"] {\n            col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?;\n        }\n        col.remove_tags(\"a\")?;\n        assert_eq!(col.storage.all_tags()?, vec![]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/rename.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::is_tag_separator;\nuse super::matcher::TagMatcher;\nuse crate::prelude::*;\nuse crate::tags::register::normalize_tag_name;\n\nimpl Collection {\n    /// Rename a given tag and its children on all notes that reference it,\n    /// returning changed note count.\n    pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result<OpOutput<usize>> {\n        self.transact(Op::RenameTag, |col| {\n            col.rename_tag_inner(old_prefix, new_prefix)\n        })\n    }\n}\n\nimpl Collection {\n    fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result<usize> {\n        require!(\n            !new_prefix.contains(is_tag_separator),\n            \"replacement name can not contain a space\",\n        );\n        require!(\n            !new_prefix.trim().is_empty(),\n            \"replacement name must not be empty\",\n        );\n\n        let usn = self.usn()?;\n\n        // ensure normalized+matching parent case, but not case of existing tag.\n        // The matching of parent case is mainly to be consistent with the way\n        // decks are handled.\n        let new_prefix = normalize_tag_name(new_prefix)?;\n        let new_prefix = self\n            .adjusted_case_for_parents(&new_prefix)?\n            .map(Into::into)\n            .unwrap_or(new_prefix);\n\n        // gather tags that need replacing\n        let mut re = TagMatcher::new(old_prefix)?;\n        let matched_notes = self\n            .storage\n            .get_note_tags_by_predicate(|tags| re.is_match(tags))?;\n        let match_count = matched_notes.len();\n        if match_count == 0 {\n            // no matches; exit early so we don't clobber the empty tag entries\n            return Ok(0);\n        }\n\n        // remove old prefix from the tag list\n        for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? {\n            self.remove_single_tag_undoable(tag)?;\n        }\n\n        // replace tags\n        for mut note in matched_notes {\n            let original = note.clone();\n            note.tags = re.replace(&note.tags, &new_prefix);\n            note.set_modified(usn);\n            self.update_note_tags_undoable(&note, original)?;\n        }\n\n        // update tag list\n        for tag in re.into_new_tags() {\n            self.register_tag_string(tag, usn)?;\n        }\n\n        Ok(match_count)\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/reparent.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashMap;\n\nuse unicase::UniCase;\n\nuse super::join_tags;\nuse super::matcher::TagMatcher;\nuse crate::prelude::*;\n\nimpl Collection {\n    /// Reparent the provided tags under a new parent.\n    ///\n    /// Parents of the provided tags are left alone - only the final component\n    /// and its children are moved. If a source tag is the parent of the target\n    /// tag, it will remain unchanged. If `new_parent` is not provided, tags\n    /// will be reparented to the root element. When reparenting tags, any\n    /// children they have are reparented as well.\n    ///\n    /// For example:\n    /// - foo,       bar       -> bar::foo\n    /// - foo::bar,  baz       -> baz::bar\n    /// - foo,       foo::bar  -> no action\n    /// - foo::bar,  none      -> bar\n    pub fn reparent_tags(\n        &mut self,\n        tags_to_reparent: &[String],\n        new_parent: Option<String>,\n    ) -> Result<OpOutput<usize>> {\n        self.transact(Op::ReparentTag, |col| {\n            col.reparent_tags_inner(tags_to_reparent, new_parent)\n        })\n    }\n\n    pub fn reparent_tags_inner(\n        &mut self,\n        tags_to_reparent: &[String],\n        new_parent: Option<String>,\n    ) -> Result<usize> {\n        let usn = self.usn()?;\n        let mut matcher = TagMatcher::new(&join_tags(tags_to_reparent))?;\n        let old_to_new_names = old_to_new_names(tags_to_reparent, new_parent);\n        if old_to_new_names.is_empty() {\n            return Ok(0);\n        }\n        let matched_notes = self\n            .storage\n            .get_note_tags_by_predicate(|tags| matcher.is_match(tags))?;\n        let match_count = matched_notes.len();\n        if match_count == 0 {\n            // no matches; exit early so we don't clobber the empty tag entries\n            return Ok(0);\n        }\n\n        // remove old prefixes from the tag list\n        for tag in self\n            .storage\n            .get_tags_by_predicate(|tag| matcher.is_match(tag))?\n        {\n            self.remove_single_tag_undoable(tag)?;\n        }\n\n        // replace tags\n        for mut note in matched_notes {\n            let original = note.clone();\n            note.tags = matcher.replace_with_fn(&note.tags, |cap| {\n                old_to_new_names\n                    .get(&UniCase::new(cap.to_string()))\n                    .unwrap()\n                    .clone()\n            });\n            note.set_modified(usn);\n            self.update_note_tags_undoable(&note, original)?;\n        }\n\n        // update tag list\n        for tag in matcher.into_new_tags() {\n            self.register_tag_string(tag, usn)?;\n        }\n\n        Ok(match_count)\n    }\n}\n\nfn old_to_new_names(\n    tags_to_reparent: &[String],\n    new_parent: Option<String>,\n) -> HashMap<UniCase<String>, String> {\n    tags_to_reparent\n        .iter()\n        // generate resulting names and filter out invalid ones\n        .flat_map(|source_tag| {\n            reparented_name(source_tag, new_parent.as_deref())\n                .map(|output_name| (UniCase::new(source_tag.to_owned()), output_name))\n        })\n        .collect()\n}\n\n/// Arguments are expected in 'human' form with a :: separator.\n/// Returns None if new parent is a child of the tag to be reparented.\nfn reparented_name(existing_name: &str, new_parent: Option<&str>) -> Option<String> {\n    let existing_base = existing_name.rsplit(\"::\").next().unwrap();\n    let existing_root = existing_name.split(\"::\").next().unwrap();\n    if let Some(new_parent) = new_parent {\n        let new_parent_root = new_parent.split(\"::\").next().unwrap();\n        if new_parent.starts_with(existing_name) && new_parent_root == existing_root {\n            // foo onto foo::bar, or foo onto itself -> no-op\n            None\n        } else {\n            // foo::bar onto baz -> baz::bar\n            let new_name = format!(\"{new_parent}::{existing_base}\");\n            if new_name != existing_name {\n                Some(new_name)\n            } else {\n                None\n            }\n        }\n    } else {\n        // foo::bar onto top level -> bar\n        let new_name = existing_base.into();\n        if new_name != existing_name {\n            Some(new_name)\n        } else {\n            None\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    fn alltags(col: &Collection) -> Vec<String> {\n        col.storage\n            .all_tags()\n            .unwrap()\n            .into_iter()\n            .map(|t| t.name)\n            .collect()\n    }\n\n    #[test]\n    fn dragdrop() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        for tag in &[\n            \"a\",\n            \"ab\",\n            \"another\",\n            \"parent1::child1::grandchild1\",\n            \"parent1::child1\",\n            \"parent1\",\n            \"parent2\",\n            \"yet::another\",\n        ] {\n            let mut note = nt.new_note();\n            note.tags.push(tag.to_string());\n            col.add_note(&mut note, DeckId(1))?;\n        }\n\n        // two decks with the same base name; they both get mapped\n        // to parent1::another\n        col.reparent_tags(\n            &[\"another\".to_string(), \"yet::another\".to_string()],\n            Some(\"parent1\".to_string()),\n        )?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"a\",\n                \"ab\",\n                \"parent1\",\n                \"parent1::another\",\n                \"parent1::child1\",\n                \"parent1::child1::grandchild1\",\n                \"parent2\",\n            ]\n        );\n\n        // child and children moved to parent2\n        col.reparent_tags(\n            &[\"parent1::child1\".to_string()],\n            Some(\"parent2\".to_string()),\n        )?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"a\",\n                \"ab\",\n                \"parent1\",\n                \"parent1::another\",\n                \"parent2\",\n                \"parent2::child1\",\n                \"parent2::child1::grandchild1\",\n            ]\n        );\n\n        // empty target reparents to root\n        col.reparent_tags(&[\"parent1::another\".to_string()], None)?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"a\",\n                \"ab\",\n                \"another\",\n                \"parent1\",\n                \"parent2\",\n                \"parent2::child1\",\n                \"parent2::child1::grandchild1\",\n            ]\n        );\n\n        // parent1 onto parent1::child1 -> no-op\n        col.reparent_tags(\n            &[\"parent1\".to_string()],\n            Some(\"parent1::child1\".to_string()),\n        )?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"a\",\n                \"ab\",\n                \"another\",\n                \"parent1\",\n                \"parent2\",\n                \"parent2::child1\",\n                \"parent2::child1::grandchild1\",\n            ]\n        );\n\n        // tags that are prefixes of the new parent are handled correctly\n        col.reparent_tags(&[\"a\".to_string()], Some(\"ab\".to_string()))?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"ab\",\n                \"ab::a\",\n                \"another\",\n                \"parent1\",\n                \"parent2\",\n                \"parent2::child1\",\n                \"parent2::child1::grandchild1\",\n            ]\n        );\n\n        // grandchildren can be reparented under the same root\n        col.reparent_tags(\n            &[\"parent2::child1::grandchild1\".to_string()],\n            Some(\"parent2\".to_string()),\n        )?;\n\n        assert_eq!(\n            alltags(&col),\n            &[\n                \"ab\",\n                \"ab::a\",\n                \"another\",\n                \"parent1\",\n                \"parent2\",\n                \"parent2::child1\",\n                \"parent2::grandchild1\",\n            ]\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/service.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse anki_proto::generic;\n\nuse crate::collection::Collection;\nuse crate::error;\nuse crate::notes::service::to_note_ids;\n\nimpl crate::services::TagsService for Collection {\n    fn clear_unused_tags(&mut self) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.clear_unused_tags().map(Into::into)\n    }\n\n    fn all_tags(&mut self) -> error::Result<generic::StringList> {\n        Ok(generic::StringList {\n            vals: self\n                .storage\n                .all_tags()?\n                .into_iter()\n                .map(|t| t.name)\n                .collect(),\n        })\n    }\n\n    fn remove_tags(\n        &mut self,\n        tags: generic::String,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.remove_tags(tags.val.as_str()).map(Into::into)\n    }\n\n    fn set_tag_collapsed(\n        &mut self,\n        input: anki_proto::tags::SetTagCollapsedRequest,\n    ) -> error::Result<anki_proto::collection::OpChanges> {\n        self.set_tag_collapsed(&input.name, input.collapsed)\n            .map(Into::into)\n    }\n\n    fn tag_tree(&mut self) -> error::Result<anki_proto::tags::TagTreeNode> {\n        self.tag_tree()\n    }\n\n    fn reparent_tags(\n        &mut self,\n        input: anki_proto::tags::ReparentTagsRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        let source_tags = input.tags;\n        let target_tag = if input.new_parent.is_empty() {\n            None\n        } else {\n            Some(input.new_parent)\n        };\n        self.reparent_tags(&source_tags, target_tag).map(Into::into)\n    }\n\n    fn rename_tags(\n        &mut self,\n        input: anki_proto::tags::RenameTagsRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.rename_tag(&input.current_prefix, &input.new_prefix)\n            .map(Into::into)\n    }\n\n    fn add_note_tags(\n        &mut self,\n        input: anki_proto::tags::NoteIdsAndTagsRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.add_tags_to_notes(&to_note_ids(input.note_ids), &input.tags)\n            .map(Into::into)\n    }\n\n    fn remove_note_tags(\n        &mut self,\n        input: anki_proto::tags::NoteIdsAndTagsRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        self.remove_tags_from_notes(&to_note_ids(input.note_ids), &input.tags)\n            .map(Into::into)\n    }\n\n    fn find_and_replace_tag(\n        &mut self,\n        input: anki_proto::tags::FindAndReplaceTagRequest,\n    ) -> error::Result<anki_proto::collection::OpChangesWithCount> {\n        let note_ids = if input.note_ids.is_empty() {\n            self.search_notes_unordered(\"\")?\n        } else {\n            to_note_ids(input.note_ids)\n        };\n        self.find_and_replace_tag(\n            &note_ids,\n            &input.search,\n            &input.replacement,\n            input.regex,\n            input.match_case,\n        )\n        .map(Into::into)\n    }\n\n    fn complete_tag(\n        &mut self,\n        input: anki_proto::tags::CompleteTagRequest,\n    ) -> error::Result<anki_proto::tags::CompleteTagResponse> {\n        let tags = Collection::complete_tag(self, &input.input, input.match_limit as usize)?;\n        Ok(anki_proto::tags::CompleteTagResponse { tags })\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/tree.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::collections::HashSet;\nuse std::iter::Peekable;\n\nuse anki_proto::tags::TagTreeNode;\nuse unicase::UniCase;\n\nuse super::immediate_parent_name_unicase;\nuse super::Tag;\nuse crate::prelude::*;\n\nimpl Collection {\n    pub fn tag_tree(&mut self) -> Result<TagTreeNode> {\n        let tags = self.storage.all_tags()?;\n        let tree = tags_to_tree(tags);\n\n        Ok(tree)\n    }\n\n    pub fn set_tag_collapsed(&mut self, tag: &str, collapsed: bool) -> Result<OpOutput<()>> {\n        self.transact(Op::SkipUndo, |col| {\n            col.set_tag_collapsed_inner(tag, collapsed, col.usn()?)\n        })\n    }\n}\n\nimpl Collection {\n    fn set_tag_collapsed_inner(&mut self, name: &str, collapsed: bool, usn: Usn) -> Result<()> {\n        self.register_tag_string(name.into(), usn)?;\n        if let Some(mut tag) = self.storage.get_tag(name)? {\n            let original = tag.clone();\n            tag.expanded = !collapsed;\n            self.update_tag_inner(&mut tag, original, usn)?;\n        }\n        Ok(())\n    }\n\n    fn update_tag_inner(&mut self, tag: &mut Tag, original: Tag, usn: Usn) -> Result<()> {\n        tag.set_modified(usn);\n        self.update_tag_undoable(tag, original)\n    }\n}\n\n/// Append any missing parents. Caller must sort afterwards.\nfn add_missing_parents(tags: &mut Vec<Tag>) {\n    let mut all_names: HashSet<UniCase<&str>> = HashSet::new();\n    let mut missing = vec![];\n    for tag in &*tags {\n        add_tag_and_missing_parents(&mut all_names, &mut missing, UniCase::new(&tag.name))\n    }\n    let mut missing: Vec<_> = missing\n        .into_iter()\n        .map(|n| Tag::new(n.to_string(), Usn(0)))\n        .collect();\n    tags.append(&mut missing);\n}\n\nfn tags_to_tree(mut tags: Vec<Tag>) -> TagTreeNode {\n    add_missing_parents(&mut tags);\n    for tag in &mut tags {\n        tag.name = tag.name.replace(\"::\", \"\\x1f\");\n    }\n    tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name)));\n    let mut top = TagTreeNode::default();\n    let mut it = tags.into_iter().peekable();\n    add_child_nodes(&mut it, &mut top);\n\n    top\n}\n\nfn add_child_nodes(tags: &mut Peekable<impl Iterator<Item = Tag>>, parent: &mut TagTreeNode) {\n    while let Some(tag) = tags.peek() {\n        let split_name: Vec<_> = tag.name.split('\\x1f').collect();\n        match split_name.len() as u32 {\n            l if l <= parent.level => {\n                // next item is at a higher level\n                return;\n            }\n            l if l == parent.level + 1 => {\n                // next item is an immediate descendent of parent\n                parent.children.push(TagTreeNode {\n                    name: (*split_name.last().unwrap()).into(),\n                    children: vec![],\n                    level: parent.level + 1,\n                    collapsed: !tag.expanded,\n                });\n                tags.next();\n            }\n            _ => {\n                // next item is at a lower level\n                if let Some(last_child) = parent.children.last_mut() {\n                    add_child_nodes(tags, last_child)\n                } else {\n                    // immediate parent is missing\n                    tags.next();\n                }\n            }\n        }\n    }\n}\n\n/// For the given tag, check if immediate parent exists. If so, add\n/// tag and return.\n/// If the immediate parent is missing, check and add any missing parents.\n/// This should ensure that if an immediate parent is found, all ancestors\n/// are guaranteed to already exist.\nfn add_tag_and_missing_parents<'a, 'b>(\n    all: &'a mut HashSet<UniCase<&'b str>>,\n    missing: &'a mut Vec<UniCase<&'b str>>,\n    tag_name: UniCase<&'b str>,\n) {\n    if let Some(parent) = immediate_parent_name_unicase(tag_name) {\n        if !all.contains(&parent) {\n            missing.push(parent);\n            add_tag_and_missing_parents(all, missing, parent);\n        }\n    }\n    // finally, add provided tag\n    all.insert(tag_name);\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    fn node(name: &str, level: u32, children: Vec<TagTreeNode>) -> TagTreeNode {\n        TagTreeNode {\n            name: name.into(),\n            level,\n            children,\n            collapsed: level != 0,\n        }\n    }\n\n    fn leaf(name: &str, level: u32) -> TagTreeNode {\n        node(name, level, vec![])\n    }\n\n    #[test]\n    fn tree() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        note.tags.push(\"foo::bar::a\".into());\n        note.tags.push(\"foo::bar::b\".into());\n        col.add_note(&mut note, DeckId(1))?;\n\n        // missing parents are added\n        assert_eq!(\n            col.tag_tree()?,\n            node(\n                \"\",\n                0,\n                vec![node(\n                    \"foo\",\n                    1,\n                    vec![node(\"bar\", 2, vec![leaf(\"a\", 3), leaf(\"b\", 3)])]\n                )]\n            )\n        );\n\n        // differing case should result in only one parent case being added -\n        // the first one\n        col.storage.clear_all_tags()?;\n        note.tags[0] = \"foo::BAR::a\".into();\n        note.tags[1] = \"FOO::bar::b\".into();\n        col.update_note(&mut note)?;\n        assert_eq!(\n            col.tag_tree()?,\n            node(\n                \"\",\n                0,\n                vec![node(\n                    \"foo\",\n                    1,\n                    vec![node(\"BAR\", 2, vec![leaf(\"a\", 3), leaf(\"b\", 3)])]\n                )]\n            )\n        );\n\n        // things should work even if the immediate parent is not missing\n        col.storage.clear_all_tags()?;\n        note.tags[0] = \"foo::bar::baz\".into();\n        note.tags[1] = \"foo::bar::baz::quux\".into();\n        col.update_note(&mut note)?;\n        assert_eq!(\n            col.tag_tree()?,\n            node(\n                \"\",\n                0,\n                vec![node(\n                    \"foo\",\n                    1,\n                    vec![node(\"bar\", 2, vec![node(\"baz\", 3, vec![leaf(\"quux\", 4)])])]\n                )]\n            )\n        );\n\n        // numbers have a smaller ascii number than ':', so a naive sort on\n        // '::' would result in one::two being nested under one1.\n        col.storage.clear_all_tags()?;\n        note.tags[0] = \"one\".into();\n        note.tags[1] = \"one1\".into();\n        note.tags.push(\"one::two\".into());\n        col.update_note(&mut note)?;\n        assert_eq!(\n            col.tag_tree()?,\n            node(\n                \"\",\n                0,\n                vec![node(\"one\", 1, vec![leaf(\"two\", 2)]), leaf(\"one1\", 1)]\n            )\n        );\n\n        // children should match the case of their parents\n        col.storage.clear_all_tags()?;\n        note.tags[0] = \"FOO\".into();\n        note.tags[1] = \"foo::BAR\".into();\n        note.tags[2] = \"foo::bar::baz\".into();\n        col.update_note(&mut note)?;\n        assert_eq!(note.tags, vec![\"FOO\", \"FOO::BAR\", \"FOO::BAR::baz\"]);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/tags/undo.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse super::Tag;\nuse crate::prelude::*;\n\n#[derive(Debug)]\npub(crate) enum UndoableTagChange {\n    Added(Box<Tag>),\n    Removed(Box<Tag>),\n    Updated(Box<Tag>),\n}\n\nimpl Collection {\n    pub(crate) fn undo_tag_change(&mut self, change: UndoableTagChange) -> Result<()> {\n        match change {\n            UndoableTagChange::Added(tag) => self.remove_single_tag_undoable(*tag),\n            UndoableTagChange::Removed(tag) => self.register_tag_undoable(&tag),\n            UndoableTagChange::Updated(tag) => {\n                let current = self\n                    .storage\n                    .get_tag(&tag.name)?\n                    .or_invalid(\"tag disappeared\")?;\n                self.update_tag_undoable(&tag, current)\n            }\n        }\n    }\n\n    /// Updates an existing tag, saving an undo entry. Caller must update usn.\n    pub(super) fn update_tag_undoable(&mut self, tag: &Tag, original: Tag) -> Result<()> {\n        self.save_undo(UndoableTagChange::Updated(Box::new(original)));\n        self.storage.update_tag(tag)\n    }\n\n    /// Adds an already-validated tag to the tag list, saving an undo entry.\n    /// Caller is responsible for setting usn.\n    pub(super) fn register_tag_undoable(&mut self, tag: &Tag) -> Result<()> {\n        self.save_undo(UndoableTagChange::Added(Box::new(tag.clone())));\n        self.storage.register_tag(tag)\n    }\n\n    /// Remove a single tag from the tag list, saving an undo entry. Does not\n    /// alter notes. FIXME: caller will need to update usn when we make tags\n    /// incrementally syncable.\n    pub(super) fn remove_single_tag_undoable(&mut self, tag: Tag) -> Result<()> {\n        self.storage.remove_single_tag(&tag.name)?;\n        self.save_undo(UndoableTagChange::Removed(Box::new(tag)));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/template.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::fmt::Write;\nuse std::iter;\nuse std::sync::LazyLock;\n\nuse anki_i18n::I18n;\nuse nom::bytes::complete::tag;\nuse nom::bytes::complete::take_until;\nuse nom::combinator::map;\nuse nom::sequence::delimited;\nuse nom::Parser;\nuse regex::Regex;\n\nuse crate::cloze::cloze_number_in_fields;\nuse crate::error::AnkiError;\nuse crate::error::Result;\nuse crate::error::TemplateError;\nuse crate::invalid_input;\nuse crate::template_filters::apply_filters;\n\npub type FieldMap<'a> = HashMap<&'a str, u16>;\ntype TemplateResult<T> = std::result::Result<T, TemplateError>;\n\nstatic TEMPLATE_ERROR_LINK: &str =\n    \"https://docs.ankiweb.net/templates/errors.html#template-syntax-error\";\nstatic TEMPLATE_BLANK_LINK: &str =\n    \"https://docs.ankiweb.net/templates/errors.html#front-of-card-is-blank\";\nstatic TEMPLATE_BLANK_CLOZE_LINK: &str =\n    \"https://docs.ankiweb.net/templates/errors.html#no-cloze-filter-on-cloze-note-type\";\n\n// Template comment delimiters\nstatic COMMENT_START: &str = \"<!--\";\nstatic COMMENT_END: &str = \"-->\";\n\nstatic ALT_HANDLEBAR_DIRECTIVE: &str = \"{{=<% %>=}}\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum TemplateMode {\n    Standard,\n    LegacyAltSyntax,\n}\n\nimpl TemplateMode {\n    fn start_tag(&self) -> &'static str {\n        match self {\n            TemplateMode::Standard => \"{{\",\n            TemplateMode::LegacyAltSyntax => \"<%\",\n        }\n    }\n\n    fn end_tag(&self) -> &'static str {\n        match self {\n            TemplateMode::Standard => \"}}\",\n            TemplateMode::LegacyAltSyntax => \"%>\",\n        }\n    }\n\n    fn handlebar_token<'b>(&self, s: &'b str) -> nom::IResult<&'b str, Token<'b>> {\n        map(\n            delimited(\n                tag(self.start_tag()),\n                take_until(self.end_tag()),\n                tag(self.end_tag()),\n            ),\n            |out| classify_handle(out),\n        )\n        .parse(s)\n    }\n\n    /// Return the next handlebar, comment or text token.\n    fn next_token<'b>(&self, input: &'b str) -> Option<(&'b str, Token<'b>)> {\n        if input.is_empty() {\n            return None;\n        }\n\n        // Loop, starting from the first character\n        for (i, _) in input.char_indices() {\n            let remaining = &input[i..];\n\n            // Valid handlebar clause?\n            if let Ok((after_handlebar, token)) = self.handlebar_token(remaining) {\n                // Found at the start of string, so that's the next token\n                return Some(if i == 0 {\n                    (after_handlebar, token)\n                } else {\n                    // There was some text prior to this, so return it instead\n                    (remaining, Token::Text(&input[..i]))\n                });\n            }\n\n            // Check comments too\n            if let Ok((after_comment, token)) = comment_token(remaining) {\n                return Some(if i == 0 {\n                    (after_comment, token)\n                } else {\n                    (remaining, Token::Text(&input[..i]))\n                });\n            }\n        }\n\n        // If no matches, return the entire input as text, with nothing remaining\n        Some((\"\", Token::Text(input)))\n    }\n}\n\n// Lexing\n//----------------------------------------\n\n#[derive(Debug)]\npub enum Token<'a> {\n    Text(&'a str),\n    Comment(&'a str),\n    Replacement(&'a str),\n    OpenConditional(&'a str),\n    OpenNegated(&'a str),\n    CloseConditional(&'a str),\n}\n\nfn comment_token(s: &str) -> nom::IResult<&str, Token<'_>> {\n    map(\n        delimited(\n            tag(COMMENT_START),\n            take_until(COMMENT_END),\n            tag(COMMENT_END),\n        ),\n        Token::Comment,\n    )\n    .parse(s)\n}\n\nfn tokens(mut template: &str) -> impl Iterator<Item = TemplateResult<Token<'_>>> {\n    let mode = if template.trim_start().starts_with(ALT_HANDLEBAR_DIRECTIVE) {\n        template = template\n            .trim_start()\n            .trim_start_matches(ALT_HANDLEBAR_DIRECTIVE);\n\n        TemplateMode::LegacyAltSyntax\n    } else {\n        TemplateMode::Standard\n    };\n    iter::from_fn(move || {\n        let token;\n        (template, token) = mode.next_token(template)?;\n        Some(Ok(token))\n    })\n}\n\n/// classify handle based on leading character\nfn classify_handle(s: &str) -> Token<'_> {\n    let start = s.trim_start_matches('{').trim();\n    if start.len() < 2 {\n        return Token::Replacement(start);\n    }\n    if let Some(stripped) = start.strip_prefix('#') {\n        Token::OpenConditional(stripped.trim_start())\n    } else if let Some(stripped) = start.strip_prefix('/') {\n        Token::CloseConditional(stripped.trim_start())\n    } else if let Some(stripped) = start.strip_prefix('^') {\n        Token::OpenNegated(stripped.trim_start())\n    } else {\n        Token::Replacement(start)\n    }\n}\n\n// Parsing\n//----------------------------------------\n\n#[derive(Debug, PartialEq, Eq)]\nenum ParsedNode {\n    Text(String),\n    Comment(String),\n    Replacement {\n        key: String,\n        filters: Vec<String>,\n    },\n    Conditional {\n        key: String,\n        children: Vec<ParsedNode>,\n    },\n    NegatedConditional {\n        key: String,\n        children: Vec<ParsedNode>,\n    },\n}\n\n#[derive(Debug)]\npub struct ParsedTemplate(Vec<ParsedNode>);\n\nimpl ParsedTemplate {\n    /// Create a template from the provided text.\n    pub fn from_text(template: &str) -> TemplateResult<ParsedTemplate> {\n        let mut iter = tokens(template);\n        Ok(Self(parse_inner(&mut iter, None)?))\n    }\n}\n\nfn parse_inner<'a, I: Iterator<Item = TemplateResult<Token<'a>>>>(\n    iter: &mut I,\n    open_tag: Option<&'a str>,\n) -> TemplateResult<Vec<ParsedNode>> {\n    let mut nodes = vec![];\n\n    while let Some(token) = iter.next() {\n        use Token::*;\n        nodes.push(match token? {\n            Text(t) => ParsedNode::Text(t.into()),\n            Comment(t) => ParsedNode::Comment(t.into()),\n            Replacement(t) => {\n                let mut it = t.rsplit(':');\n                ParsedNode::Replacement {\n                    key: it.next().unwrap().into(),\n                    filters: it.map(Into::into).collect(),\n                }\n            }\n            OpenConditional(t) => ParsedNode::Conditional {\n                key: t.into(),\n                children: parse_inner(iter, Some(t))?,\n            },\n            OpenNegated(t) => ParsedNode::NegatedConditional {\n                key: t.into(),\n                children: parse_inner(iter, Some(t))?,\n            },\n            CloseConditional(t) => {\n                let currently_open = if let Some(open) = open_tag {\n                    if open == t {\n                        // matching closing tag, move back to parent\n                        return Ok(nodes);\n                    } else {\n                        Some(open.to_string())\n                    }\n                } else {\n                    None\n                };\n                return Err(TemplateError::ConditionalNotOpen {\n                    closed: t.to_string(),\n                    currently_open,\n                });\n            }\n        });\n    }\n\n    if let Some(open) = open_tag {\n        Err(TemplateError::ConditionalNotClosed(open.to_string()))\n    } else {\n        Ok(nodes)\n    }\n}\n\nfn template_error_to_anki_error(\n    err: TemplateError,\n    q_side: bool,\n    browser: bool,\n    tr: &I18n,\n) -> AnkiError {\n    let header = match (q_side, browser) {\n        (true, false) => tr.card_template_rendering_front_side_problem(),\n        (false, false) => tr.card_template_rendering_back_side_problem(),\n        (true, true) => tr.card_template_rendering_browser_front_side_problem(),\n        (false, true) => tr.card_template_rendering_browser_back_side_problem(),\n    };\n    let details = htmlescape::encode_minimal(&localized_template_error(tr, err));\n    let more_info = tr.card_template_rendering_more_info();\n    let source =\n        format!(\"{header}<br>{details}<br><a href='{TEMPLATE_ERROR_LINK}'>{more_info}</a>\");\n\n    AnkiError::TemplateError { info: source }\n}\n\nfn localized_template_error(tr: &I18n, err: TemplateError) -> String {\n    match err {\n        TemplateError::NoClosingBrackets(tag) => tr\n            .card_template_rendering_no_closing_brackets(\"}}\", tag)\n            .into(),\n        TemplateError::ConditionalNotClosed(tag) => tr\n            .card_template_rendering_conditional_not_closed(format!(\"{{{{/{tag}}}}}\"))\n            .into(),\n        TemplateError::ConditionalNotOpen {\n            closed,\n            currently_open,\n        } => if let Some(open) = currently_open {\n            tr.card_template_rendering_wrong_conditional_closed(\n                format!(\"{{{{/{closed}}}}}\"),\n                format!(\"{{{{/{open}}}}}\"),\n            )\n        } else {\n            tr.card_template_rendering_conditional_not_open(\n                format!(\"{{{{/{closed}}}}}\"),\n                format!(\"{{{{#{closed}}}}}\"),\n                format!(\"{{{{^{closed}}}}}\"),\n            )\n        }\n        .into(),\n        TemplateError::FieldNotFound { field, filters } => tr\n            .card_template_rendering_no_such_field(format!(\"{{{{{filters}{field}}}}}\"), field)\n            .into(),\n        TemplateError::NoSuchConditional(condition) => tr\n            .card_template_rendering_no_such_field(format!(\"{{{{{condition}}}}}\"), &condition[1..])\n            .into(),\n    }\n}\n\n// Checking if template is empty\n//----------------------------------------\n\nimpl ParsedTemplate {\n    /// true if provided fields are sufficient to render the template\n    pub fn renders_with_fields(&self, nonempty_fields: &HashSet<&str>) -> bool {\n        !template_is_empty(nonempty_fields, &self.0, true)\n    }\n\n    pub fn renders_with_fields_for_reqs(&self, nonempty_fields: &HashSet<&str>) -> bool {\n        !template_is_empty(nonempty_fields, &self.0, false)\n    }\n}\n\n/// If check_negated is false, negated conditionals resolve to their children,\n/// even if the referenced key is non-empty. This allows the legacy required\n/// field cache to generate results closer to older Anki versions.\nfn template_is_empty(\n    nonempty_fields: &HashSet<&str>,\n    nodes: &[ParsedNode],\n    check_negated: bool,\n) -> bool {\n    use ParsedNode::*;\n    for node in nodes {\n        match node {\n            // ignore normal text\n            Text(_) | Comment(_) => (),\n            Replacement { key, .. } => {\n                if nonempty_fields.contains(key.as_str()) {\n                    // a single replacement is enough\n                    return false;\n                }\n            }\n            Conditional { key, children } => {\n                if !nonempty_fields.contains(key.as_str()) {\n                    continue;\n                }\n                if !template_is_empty(nonempty_fields, children, check_negated) {\n                    return false;\n                }\n            }\n            NegatedConditional { key, children } => {\n                if check_negated && nonempty_fields.contains(key.as_str()) {\n                    continue;\n                }\n\n                if !template_is_empty(nonempty_fields, children, check_negated) {\n                    return false;\n                }\n            }\n        }\n    }\n\n    true\n}\n\n// Rendering\n//----------------------------------------\n\n#[derive(Debug, PartialEq, Eq)]\npub enum RenderedNode {\n    Text {\n        text: String,\n    },\n    Replacement {\n        field_name: String,\n        current_text: String,\n        /// Filters are in the order they should be applied.\n        filters: Vec<String>,\n    },\n}\n\npub(crate) struct RenderContext<'a> {\n    pub fields: &'a HashMap<&'a str, Cow<'a, str>>,\n    pub nonempty_fields: &'a HashSet<&'a str>,\n    pub card_ord: u16,\n    /// Should be set before rendering the answer, even if `partial_for_python`\n    /// is true.\n    pub frontside: Option<&'a str>,\n    /// If true, question/answer will not be fully rendered if an unknown filter\n    /// is encountered, and the frontend code will need to complete the\n    /// rendering.\n    pub partial_for_python: bool,\n}\n\nimpl ParsedTemplate {\n    /// Render the template with the provided fields.\n    ///\n    /// Replacements that use only standard filters will become part of\n    /// a text node. If a non-standard filter is encountered, a partially\n    /// rendered Replacement is returned for the calling code to complete.\n    fn render(&self, context: &RenderContext, _tr: &I18n) -> TemplateResult<Vec<RenderedNode>> {\n        let mut rendered = vec![];\n\n        render_into(&mut rendered, self.0.as_ref(), context)?;\n\n        Ok(rendered)\n    }\n}\n\nfn render_into(\n    rendered_nodes: &mut Vec<RenderedNode>,\n    nodes: &[ParsedNode],\n    context: &RenderContext,\n) -> TemplateResult<()> {\n    use ParsedNode::*;\n    for node in nodes {\n        match node {\n            Text(text) => {\n                append_str_to_nodes(rendered_nodes, text);\n            }\n            Comment(comment) => {\n                append_str_to_nodes(rendered_nodes, COMMENT_START);\n                append_str_to_nodes(rendered_nodes, comment);\n                append_str_to_nodes(rendered_nodes, COMMENT_END);\n            }\n            Replacement { key, .. } if key == \"FrontSide\" => {\n                let frontside = context.frontside.as_ref().copied().unwrap_or_default();\n                if context.partial_for_python {\n                    // defer FrontSide rendering to Python, as extra\n                    // filters may be required\n                    rendered_nodes.push(RenderedNode::Replacement {\n                        field_name: (*key).to_string(),\n                        filters: vec![],\n                        current_text: \"\".into(),\n                    });\n                } else {\n                    append_str_to_nodes(rendered_nodes, frontside);\n                }\n            }\n            Replacement { key, filters } => {\n                if key.is_empty() && !filters.is_empty() {\n                    if context.partial_for_python {\n                        // if a filter is provided, we accept an empty field name to\n                        // mean 'pass an empty string to the filter, and it will add\n                        // its own text'\n                        rendered_nodes.push(RenderedNode::Replacement {\n                            field_name: \"\".to_string(),\n                            current_text: \"\".to_string(),\n                            filters: filters.clone(),\n                        });\n                    } else {\n                        // nothing to do\n                    }\n                } else {\n                    // apply built in filters if field exists\n                    let (text, remaining_filters) = match context.fields.get(key.as_str()) {\n                        Some(text) => apply_filters(\n                            text,\n                            filters\n                                .iter()\n                                .map(|s| s.as_str())\n                                .collect::<Vec<_>>()\n                                .as_slice(),\n                            key,\n                            context,\n                        ),\n                        None => {\n                            // unknown field encountered\n                            let filters_str = filters\n                                .iter()\n                                .rev()\n                                .cloned()\n                                .chain(iter::once(\"\".into()))\n                                .collect::<Vec<_>>()\n                                .join(\":\");\n                            return Err(TemplateError::FieldNotFound {\n                                field: (*key).to_string(),\n                                filters: filters_str,\n                            });\n                        }\n                    };\n\n                    // fully processed?\n                    if remaining_filters.is_empty() {\n                        append_str_to_nodes(rendered_nodes, text.as_ref())\n                    } else {\n                        rendered_nodes.push(RenderedNode::Replacement {\n                            field_name: (*key).to_string(),\n                            filters: remaining_filters,\n                            current_text: text.into(),\n                        });\n                    }\n                }\n            }\n            Conditional { key, children } => {\n                if context.evaluate_conditional(key.as_str(), false)? {\n                    render_into(rendered_nodes, children.as_ref(), context)?;\n                } else {\n                    // keep checking for errors, but discard rendered nodes\n                    render_into(&mut vec![], children.as_ref(), context)?;\n                }\n            }\n            NegatedConditional { key, children } => {\n                if context.evaluate_conditional(key.as_str(), true)? {\n                    render_into(rendered_nodes, children.as_ref(), context)?;\n                } else {\n                    render_into(&mut vec![], children.as_ref(), context)?;\n                }\n            }\n        };\n    }\n\n    Ok(())\n}\n\nimpl RenderContext<'_> {\n    fn evaluate_conditional(&self, key: &str, negated: bool) -> TemplateResult<bool> {\n        if self.nonempty_fields.contains(key) {\n            Ok(true ^ negated)\n        } else if self.fields.contains_key(key) || is_cloze_conditional(key) {\n            Ok(false ^ negated)\n        } else {\n            let prefix = if negated { \"^\" } else { \"#\" };\n            Err(TemplateError::NoSuchConditional(format!(\"{prefix}{key}\")))\n        }\n    }\n}\n\n/// Append to last node if last node is a string, else add new node.\nfn append_str_to_nodes(nodes: &mut Vec<RenderedNode>, text: &str) {\n    if let Some(RenderedNode::Text {\n        text: ref mut existing_text,\n    }) = nodes.last_mut()\n    {\n        // append to existing last node\n        existing_text.push_str(text)\n    } else {\n        // otherwise, add a new string node\n        nodes.push(RenderedNode::Text {\n            text: text.to_string(),\n        })\n    }\n}\n\n/// True if provided text contains only whitespace and/or empty BR/DIV tags.\npub(crate) fn field_is_empty(text: &str) -> bool {\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r\"(?xsi)\n            ^(?:\n            [[:space:]]\n            |\n            </?(?:br|div)\\ ?/?>\n            )*$\n        \",\n        )\n        .unwrap()\n    });\n    RE.is_match(text)\n}\n\nfn nonempty_fields<'a, R>(fields: &'a HashMap<&str, R>) -> HashSet<&'a str>\nwhere\n    R: AsRef<str>,\n{\n    fields\n        .iter()\n        .filter_map(|(name, val)| {\n            if !field_is_empty(val.as_ref()) {\n                Some(*name)\n            } else {\n                None\n            }\n        })\n        .collect()\n}\n\n// Rendering both sides\n//----------------------------------------\n\n#[derive(Clone)]\npub struct RenderCardRequest<'a> {\n    pub qfmt: &'a str,\n    pub afmt: &'a str,\n    pub field_map: &'a HashMap<&'a str, Cow<'a, str>>,\n    pub card_ord: u16,\n    pub is_cloze: bool,\n    pub browser: bool,\n    pub tr: &'a I18n,\n    pub partial_render: bool,\n}\n\npub struct RenderCardResponse {\n    pub qnodes: Vec<RenderedNode>,\n    pub anodes: Vec<RenderedNode>,\n    pub is_empty: bool,\n}\n\n/// Returns `(qnodes, anodes, is_empty)`\npub fn render_card(\n    RenderCardRequest {\n        qfmt,\n        afmt,\n        field_map,\n        card_ord,\n        is_cloze,\n        browser,\n        tr,\n        partial_render: partial_for_python,\n    }: RenderCardRequest<'_>,\n) -> Result<RenderCardResponse> {\n    // prepare context\n    let mut context = RenderContext {\n        fields: field_map,\n        nonempty_fields: &nonempty_fields(field_map),\n        frontside: None,\n        card_ord,\n        partial_for_python,\n    };\n\n    // question side\n    let (mut qnodes, qtmpl) = ParsedTemplate::from_text(qfmt)\n        .and_then(|tmpl| Ok((tmpl.render(&context, tr)?, tmpl)))\n        .map_err(|e| template_error_to_anki_error(e, true, browser, tr))?;\n\n    // check if the front side was empty\n    let empty_message = if is_cloze && cloze_is_empty(field_map, card_ord) {\n        Some(format!(\n            \"<div>{}<br><a href='{}'>{}</a></div>\",\n            tr.card_template_rendering_missing_cloze(card_ord + 1),\n            TEMPLATE_BLANK_CLOZE_LINK,\n            tr.card_template_rendering_more_info()\n        ))\n    } else if !is_cloze && !browser && !qtmpl.renders_with_fields(context.nonempty_fields) {\n        Some(format!(\n            \"<div>{}<br><a href='{}'>{}</a></div>\",\n            tr.card_template_rendering_empty_front(),\n            TEMPLATE_BLANK_LINK,\n            tr.card_template_rendering_more_info()\n        ))\n    } else {\n        None\n    };\n    if let Some(text) = empty_message {\n        qnodes.push(RenderedNode::Text { text: text.clone() });\n        return Ok(RenderCardResponse {\n            qnodes,\n            anodes: vec![RenderedNode::Text { text }],\n            is_empty: true,\n        });\n    }\n\n    // answer side\n    context.frontside = if context.partial_for_python {\n        Some(\"\")\n    } else {\n        let Some(RenderedNode::Text { text }) = &qnodes.first() else {\n            invalid_input!(\"should not happen: first node not text\");\n        };\n        Some(text)\n    };\n    let anodes = ParsedTemplate::from_text(afmt)\n        .and_then(|tmpl| tmpl.render(&context, tr))\n        .map_err(|e| template_error_to_anki_error(e, false, browser, tr))?;\n\n    Ok(RenderCardResponse {\n        qnodes,\n        anodes,\n        is_empty: false,\n    })\n}\n\nfn cloze_is_empty(field_map: &HashMap<&str, Cow<str>>, card_ord: u16) -> bool {\n    !cloze_number_in_fields(field_map.values()).contains(&(card_ord + 1))\n}\n\n// Field requirements\n//----------------------------------------\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum FieldRequirements {\n    Any(HashSet<u16>),\n    All(HashSet<u16>),\n    None,\n}\n\nimpl ParsedTemplate {\n    /// Return fields required by template.\n    ///\n    /// This is not able to represent negated expressions or combinations of\n    /// Any and All, but is compatible with older Anki clients.\n    ///\n    /// In the future, it may be feasible to calculate the requirements\n    /// when adding cards, instead of caching them up front, which would mean\n    /// the above restrictions could be lifted. We would probably\n    /// want to add a cache of non-zero fields -> available cards to avoid\n    /// slowing down bulk operations like importing too much.\n    pub fn requirements(&self, field_map: &FieldMap) -> FieldRequirements {\n        let mut nonempty: HashSet<_> = Default::default();\n        let mut ords = HashSet::new();\n        for (name, ord) in field_map {\n            nonempty.clear();\n            nonempty.insert(*name);\n            if self.renders_with_fields_for_reqs(&nonempty) {\n                ords.insert(*ord);\n            }\n        }\n        if !ords.is_empty() {\n            return FieldRequirements::Any(ords);\n        }\n\n        nonempty.extend(field_map.keys());\n        ords.extend(field_map.values().copied());\n        for (name, ord) in field_map {\n            // can we remove this field and still render?\n            nonempty.remove(name);\n            if self.renders_with_fields_for_reqs(&nonempty) {\n                ords.remove(ord);\n            }\n            nonempty.insert(*name);\n        }\n        if !ords.is_empty() && self.renders_with_fields_for_reqs(&nonempty) {\n            FieldRequirements::All(ords)\n        } else {\n            FieldRequirements::None\n        }\n    }\n}\n\n// Renaming & deleting fields\n//----------------------------------------\n\nimpl ParsedTemplate {\n    /// Given a map of old to new field names, update references to the new\n    /// names. Returns true if any changes made.\n    pub(crate) fn rename_and_remove_fields(&mut self, fields: &HashMap<String, Option<String>>) {\n        let old_nodes = std::mem::take(&mut self.0);\n        self.0 = rename_and_remove_fields(old_nodes, fields);\n    }\n\n    pub(crate) fn contains_cloze_replacement(&self) -> bool {\n        self.0.iter().any(|node| {\n            matches!(\n                node,\n                ParsedNode::Replacement {key:_, filters} if filters.iter().any(|f| f==\"cloze\")\n            )\n        })\n    }\n\n    pub(crate) fn contains_field_replacement(&self) -> bool {\n        let mut set = HashSet::new();\n        find_field_references(&self.0, &mut set, false, false);\n        !set.is_empty()\n    }\n\n    pub(crate) fn add_missing_field_replacement(&mut self, field_name: &str, is_cloze: bool) {\n        let key = String::from(field_name);\n        let filters = match is_cloze {\n            true => vec![String::from(\"cloze\")],\n            false => Vec::new(),\n        };\n        self.0.push(ParsedNode::Replacement { key, filters });\n    }\n}\n\nfn rename_and_remove_fields(\n    nodes: Vec<ParsedNode>,\n    fields: &HashMap<String, Option<String>>,\n) -> Vec<ParsedNode> {\n    let mut out = vec![];\n    for node in nodes {\n        match node {\n            ParsedNode::Text(text) => out.push(ParsedNode::Text(text)),\n            ParsedNode::Comment(text) => out.push(ParsedNode::Comment(text)),\n            ParsedNode::Replacement { key, filters } => {\n                match fields.get(&key) {\n                    // delete the field\n                    Some(None) => (),\n                    // rename it\n                    Some(Some(new_name)) => out.push(ParsedNode::Replacement {\n                        key: new_name.into(),\n                        filters,\n                    }),\n                    // or leave it alone\n                    None => out.push(ParsedNode::Replacement { key, filters }),\n                }\n            }\n            ParsedNode::Conditional { key, children } => {\n                let children = rename_and_remove_fields(children, fields);\n                match fields.get(&key) {\n                    // remove the field, preserving children\n                    Some(None) => out.extend(children),\n                    // rename it\n                    Some(Some(new_name)) => out.push(ParsedNode::Conditional {\n                        key: new_name.into(),\n                        children,\n                    }),\n                    // or leave it alone\n                    None => out.push(ParsedNode::Conditional { key, children }),\n                }\n            }\n            ParsedNode::NegatedConditional { key, children } => {\n                let children = rename_and_remove_fields(children, fields);\n                match fields.get(&key) {\n                    // remove the field, preserving children\n                    Some(None) => out.extend(children),\n                    // rename it\n                    Some(Some(new_name)) => out.push(ParsedNode::NegatedConditional {\n                        key: new_name.into(),\n                        children,\n                    }),\n                    // or leave it alone\n                    None => out.push(ParsedNode::NegatedConditional { key, children }),\n                }\n            }\n        }\n    }\n    out\n}\n\n// Writing back to a string\n//----------------------------------------\n\nimpl ParsedTemplate {\n    pub(crate) fn template_to_string(&self) -> String {\n        let mut buf = String::new();\n        nodes_to_string(&mut buf, &self.0);\n        buf\n    }\n}\n\nfn nodes_to_string(buf: &mut String, nodes: &[ParsedNode]) {\n    for node in nodes {\n        match node {\n            ParsedNode::Text(text) => buf.push_str(text),\n            ParsedNode::Comment(text) => {\n                buf.push_str(COMMENT_START);\n                buf.push_str(text);\n                buf.push_str(COMMENT_END);\n            }\n            ParsedNode::Replacement { key, filters } => {\n                write!(\n                    buf,\n                    \"{{{{{}}}}}\",\n                    filters\n                        .iter()\n                        .rev()\n                        .chain(iter::once(key))\n                        .map(|s| s.to_string())\n                        .collect::<Vec<_>>()\n                        .join(\":\")\n                )\n                .unwrap();\n            }\n            ParsedNode::Conditional { key, children } => {\n                write!(buf, \"{{{{#{key}}}}}\").unwrap();\n                nodes_to_string(buf, children);\n                write!(buf, \"{{{{/{key}}}}}\").unwrap();\n            }\n            ParsedNode::NegatedConditional { key, children } => {\n                write!(buf, \"{{{{^{key}}}}}\").unwrap();\n                nodes_to_string(buf, children);\n                write!(buf, \"{{{{/{key}}}}}\").unwrap();\n            }\n        }\n    }\n}\n\n// Detecting cloze fields\n//----------------------------------------\n\nimpl ParsedTemplate {\n    /// Field names may not be valid.\n    pub(crate) fn all_referenced_field_names(&self) -> HashSet<&str> {\n        let mut set = HashSet::new();\n        find_field_references(&self.0, &mut set, false, true);\n        set\n    }\n\n    /// Field names may not be valid.\n    pub(crate) fn all_referenced_cloze_field_names(&self) -> HashSet<&str> {\n        let mut set = HashSet::new();\n        find_field_references(&self.0, &mut set, true, false);\n        set\n    }\n}\n\nfn find_field_references<'a>(\n    nodes: &'a [ParsedNode],\n    fields: &mut HashSet<&'a str>,\n    cloze_only: bool,\n    with_conditionals: bool,\n) {\n    for node in nodes {\n        match node {\n            ParsedNode::Text(_) => {}\n            ParsedNode::Comment(_) => {}\n            ParsedNode::Replacement { key, filters } => {\n                if !cloze_only || filters.iter().any(|f| f == \"cloze\") {\n                    fields.insert(key);\n                }\n            }\n            ParsedNode::Conditional { key, children }\n            | ParsedNode::NegatedConditional { key, children } => {\n                if with_conditionals && !is_cloze_conditional(key) {\n                    fields.insert(key);\n                }\n                find_field_references(children, fields, cloze_only, with_conditionals);\n            }\n        }\n    }\n}\n\nfn is_cloze_conditional(key: &str) -> bool {\n    key.strip_prefix('c')\n        .is_some_and(|s| s.parse::<u32>().is_ok())\n}\n\n// Tests\n//---------------------------------------\n\n#[cfg(test)]\nmod test {\n    use std::collections::HashMap;\n\n    use anki_i18n::I18n;\n\n    use super::FieldMap;\n    use super::ParsedNode::*;\n    use super::ParsedTemplate as PT;\n    use crate::error::TemplateError;\n    use crate::template::field_is_empty;\n    use crate::template::nonempty_fields;\n    use crate::template::FieldRequirements;\n    use crate::template::RenderCardRequest;\n    use crate::template::RenderContext;\n    use crate::template::COMMENT_END;\n    use crate::template::COMMENT_START;\n\n    #[test]\n    fn field_empty() {\n        assert!(field_is_empty(\"\"));\n        assert!(field_is_empty(\" \"));\n        assert!(!field_is_empty(\"x\"));\n        assert!(field_is_empty(\"<BR>\"));\n        assert!(field_is_empty(\"<div />\"));\n        assert!(field_is_empty(\" <div> <br> </div>\\n\"));\n        assert!(!field_is_empty(\" <div>x</div>\\n\"));\n    }\n\n    #[test]\n    fn parsing() {\n        let orig = \"\";\n        let tmpl = PT::from_text(orig).unwrap();\n        assert_eq!(tmpl.0, vec![]);\n        assert_eq!(orig, &tmpl.template_to_string());\n\n        let orig = \"foo {{bar}} {{#baz}} quux {{/baz}}\";\n        let tmpl = PT::from_text(orig).unwrap();\n        assert_eq!(\n            tmpl.0,\n            vec![\n                Text(\"foo \".into()),\n                Replacement {\n                    key: \"bar\".into(),\n                    filters: vec![]\n                },\n                Text(\" \".into()),\n                Conditional {\n                    key: \"baz\".into(),\n                    children: vec![Text(\" quux \".into())]\n                }\n            ]\n        );\n        assert_eq!(orig, &tmpl.template_to_string());\n\n        // Hardcode comment delimiters into tests to keep them concise\n        assert_eq!(COMMENT_START, \"<!--\");\n        assert_eq!(COMMENT_END, \"-->\");\n\n        let orig = \"foo <!--{{bar }} --> {{#baz}} --> <!-- <!-- {{#def}} --> \\u{123}-->\\u{456}<!-- 2 --><!----> <!-- quux {{/baz}} <!-- {{nc:abc}}\";\n        let tmpl = PT::from_text(orig).unwrap();\n        assert_eq!(\n            tmpl.0,\n            vec![\n                Text(\"foo \".into()),\n                Comment(\"{{bar }} \".into()),\n                Text(\" \".into()),\n                Conditional {\n                    key: \"baz\".into(),\n                    children: vec![\n                        Text(\" --> \".into()),\n                        Comment(\" <!-- {{#def}} \".into()),\n                        Text(\" \\u{123}-->\\u{456}\".into()),\n                        Comment(\" 2 \".into()),\n                        Comment(\"\".into()),\n                        Text(\" <!-- quux \".into()),\n                    ]\n                },\n                Text(\" <!-- \".into()),\n                Replacement {\n                    key: \"abc\".into(),\n                    filters: vec![\"nc\".into()]\n                }\n            ]\n        );\n        assert_eq!(orig, &tmpl.template_to_string());\n\n        let tmpl = PT::from_text(\"{{^baz}}{{/baz}}\").unwrap();\n        assert_eq!(\n            tmpl.0,\n            vec![NegatedConditional {\n                key: \"baz\".into(),\n                children: vec![]\n            }]\n        );\n\n        PT::from_text(\"{{#mis}}{{/matched}}\").unwrap_err();\n        PT::from_text(\"{{/matched}}\").unwrap_err();\n        PT::from_text(\"{{#mis}}\").unwrap_err();\n        PT::from_text(\"{{#mis}}<!--{{/matched}}-->\").unwrap_err();\n        PT::from_text(\"<!--{{#mis}}{{/matched}}-->\").unwrap();\n        PT::from_text(\"<!--{{foo}}\").unwrap();\n        PT::from_text(\"{{foo}}-->\").unwrap();\n\n        // whitespace\n        assert_eq!(\n            PT::from_text(\"{{ tag }}\").unwrap().0,\n            vec![Replacement {\n                key: \"tag\".into(),\n                filters: vec![]\n            }]\n        );\n\n        // stray closing characters (like in javascript) are ignored\n        assert_eq!(\n            PT::from_text(\"text }} more\").unwrap().0,\n            vec![Text(\"text }} more\".into())]\n        );\n\n        // make sure filters and so on are round-tripped correctly\n        let orig = \"foo {{one:two}} {{one:two:three}} {{^baz}} {{/baz}} {{foo:}}\";\n        let tmpl = PT::from_text(orig).unwrap();\n        assert_eq!(orig, &tmpl.template_to_string());\n\n        let orig =\n            \"foo {{one:two}} <!--<!--abc {{^def}}-->--> {{one:two:three}} {{^baz}} <!-- {{/baz}} 🙂 --> {{/baz}} {{foo:}}\";\n        let tmpl = PT::from_text(orig).unwrap();\n        assert_eq!(orig, &tmpl.template_to_string());\n    }\n\n    #[test]\n    fn nonempty() {\n        let fields = vec![\"1\", \"3\"].into_iter().collect();\n        let mut tmpl = PT::from_text(\"{{2}}{{1}}\").unwrap();\n        assert!(tmpl.renders_with_fields(&fields));\n        tmpl = PT::from_text(\"{{2}}\").unwrap();\n        assert!(!tmpl.renders_with_fields(&fields));\n        tmpl = PT::from_text(\"{{2}}{{4}}\").unwrap();\n        assert!(!tmpl.renders_with_fields(&fields));\n        tmpl = PT::from_text(\"{{#3}}{{^2}}{{1}}{{/2}}{{/3}}\").unwrap();\n        assert!(tmpl.renders_with_fields(&fields));\n\n        tmpl = PT::from_text(\"{{^1}}{{3}}{{/1}}\").unwrap();\n        assert!(!tmpl.renders_with_fields(&fields));\n        assert!(tmpl.renders_with_fields_for_reqs(&fields));\n    }\n\n    #[test]\n    fn requirements() {\n        let field_map: FieldMap = [\"a\", \"b\", \"c\"]\n            .iter()\n            .enumerate()\n            .map(|(a, b)| (*b, a as u16))\n            .collect();\n\n        let mut tmpl = PT::from_text(\"{{a}}{{b}}\").unwrap();\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::Any(vec![0, 1].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\"{{#a}}{{b}}{{/a}}\").unwrap();\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::All(vec![0, 1].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\"{{z}}\").unwrap();\n        assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None);\n\n        tmpl = PT::from_text(\"{{^a}}{{b}}{{/a}}\").unwrap();\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::Any(vec![1].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\"{{^a}}{{#b}}{{c}}{{/b}}{{/a}}\").unwrap();\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::All(vec![1, 2].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\"{{#a}}{{#b}}{{a}}{{/b}}{{/a}}\").unwrap();\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::All(vec![0, 1].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\n            r#\"\n{{^a}}\n    {{b}}\n{{/a}}\n\n{{#a}}\n    {{a}}\n    {{b}}\n{{/a}}\n\"#,\n        )\n        .unwrap();\n\n        // Hardcode comment delimiters into tests to keep them concise\n        assert_eq!(COMMENT_START, \"<!--\");\n        assert_eq!(COMMENT_END, \"-->\");\n\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::Any(vec![0, 1].into_iter().collect())\n        );\n\n        tmpl = PT::from_text(\n            r#\"\n<!--{{^a}}-->\n    {{b}}\n<!--{{/a}}-->\n{{#c}}\n    <!--{{a}}-->\n    {{b}}\n    <!--{{c}}-->\n{{/c}}\n\"#,\n        )\n        .unwrap();\n\n        assert_eq!(\n            tmpl.requirements(&field_map),\n            FieldRequirements::Any(vec![1].into_iter().collect())\n        );\n    }\n\n    #[test]\n    fn alt_syntax() {\n        let input = \"\n{{=<% %>=}}\n<%Front%>\n<% #Back %>\n<%/Back%>\";\n        assert_eq!(\n            PT::from_text(input).unwrap().0,\n            vec![\n                Text(\"\\n\".into()),\n                Replacement {\n                    key: \"Front\".into(),\n                    filters: vec![]\n                },\n                Text(\"\\n\".into()),\n                Conditional {\n                    key: \"Back\".into(),\n                    children: vec![Text(\"\\n\".into())]\n                }\n            ]\n        );\n        let input = \"\n{{=<% %>=}}\n{{#foo}}\n<%Front%>\n{{/foo}}\n\";\n        assert_eq!(\n            PT::from_text(input).unwrap().0,\n            vec![\n                Text(\"\\n{{#foo}}\\n\".into()),\n                Replacement {\n                    key: \"Front\".into(),\n                    filters: vec![]\n                },\n                Text(\"\\n{{/foo}}\\n\".into())\n            ]\n        );\n    }\n\n    #[test]\n    fn render_single() {\n        let map: HashMap<_, _> = vec![(\"F\", \"f\"), (\"B\", \"b\"), (\"E\", \" \"), (\"c1\", \"1\")]\n            .into_iter()\n            .map(|r| (r.0, r.1.into()))\n            .collect();\n\n        let ctx = RenderContext {\n            fields: &map,\n            nonempty_fields: &nonempty_fields(&map),\n            frontside: None,\n            card_ord: 1,\n            partial_for_python: true,\n        };\n\n        use crate::template::RenderedNode as FN;\n        let mut tmpl = PT::from_text(\"{{B}}A{{F}}\").unwrap();\n        let tr = I18n::template_only();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Text {\n                text: \"bAf\".to_owned()\n            },]\n        );\n\n        // empty\n        tmpl = PT::from_text(\"{{#E}}A{{/E}}\").unwrap();\n        assert_eq!(tmpl.render(&ctx, &tr).unwrap(), vec![]);\n\n        // missing\n        tmpl = PT::from_text(\"{{#E}}}{{^M}}A{{/M}}{{/E}}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap_err(),\n            TemplateError::NoSuchConditional(\"^M\".to_string())\n        );\n\n        // nested\n        tmpl = PT::from_text(\"{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Text {\n                text: \"12f\".to_owned()\n            },]\n        );\n\n        // Hardcode comment delimiters into tests to keep them concise\n        assert_eq!(COMMENT_START, \"<!--\");\n        assert_eq!(COMMENT_END, \"-->\");\n\n        // commented\n        tmpl = PT::from_text(\n            \"{{^E}}1<!--{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}-->\\u{123}<!-- this is a comment -->{{/E}}\\u{456}\",\n        )\n        .unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Text {\n                text:\n                    \"1<!--{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}-->\\u{123}<!-- this is a comment -->\\u{456}\"\n                        .to_owned()\n            },]\n        );\n\n        // card conditionals\n        tmpl = PT::from_text(\"{{^c2}}1{{#c1}}2{{/c1}}{{/c2}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Text {\n                text: \"12\".to_owned()\n            },]\n        );\n\n        // unknown filters\n        tmpl = PT::from_text(\"{{one:two:B}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Replacement {\n                field_name: \"B\".to_owned(),\n                filters: vec![\"two\".to_string(), \"one\".to_string()],\n                current_text: \"b\".to_owned()\n            },]\n        );\n\n        // partially unknown filters\n        // excess colons are ignored\n        tmpl = PT::from_text(\"{{one::text:B}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Replacement {\n                field_name: \"B\".to_owned(),\n                filters: vec![\"one\".to_string()],\n                current_text: \"b\".to_owned()\n            },]\n        );\n\n        // known filter\n        tmpl = PT::from_text(\"{{text:B}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Text {\n                text: \"b\".to_owned()\n            }]\n        );\n\n        // unknown field\n        tmpl = PT::from_text(\"{{X}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap_err(),\n            TemplateError::FieldNotFound {\n                field: \"X\".to_owned(),\n                filters: \"\".to_owned()\n            }\n        );\n\n        // unknown field with filters\n        tmpl = PT::from_text(\"{{foo:text:X}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap_err(),\n            TemplateError::FieldNotFound {\n                field: \"X\".to_owned(),\n                filters: \"foo:text:\".to_owned()\n            }\n        );\n\n        // a blank field is allowed if it has filters\n        tmpl = PT::from_text(\"{{filter:}}\").unwrap();\n        assert_eq!(\n            tmpl.render(&ctx, &tr).unwrap(),\n            vec![FN::Replacement {\n                field_name: \"\".to_string(),\n                current_text: \"\".to_string(),\n                filters: vec![\"filter\".to_string()]\n            }]\n        );\n    }\n\n    #[test]\n    fn render_card() {\n        let map: HashMap<_, _> = vec![(\"E\", \"\"), (\"N\", \"N\")]\n            .into_iter()\n            .map(|r| (r.0, r.1.into()))\n            .collect();\n\n        let tr = I18n::template_only();\n        use crate::template::RenderedNode as FN;\n\n        let mut req = RenderCardRequest {\n            qfmt: \"test{{E}}\",\n            afmt: \"\",\n            field_map: &map,\n            card_ord: 1,\n            is_cloze: false,\n            browser: false,\n            tr: &tr,\n            partial_render: true,\n        };\n        let response = super::render_card(req.clone()).unwrap();\n        assert_eq!(\n            response.qnodes[0],\n            FN::Text {\n                text: \"test\".into()\n            }\n        );\n        assert!(response.is_empty);\n        if let FN::Text { ref text } = response.qnodes[1] {\n            assert!(text.contains(\"card is blank\"));\n        } else {\n            unreachable!();\n        }\n\n        // a popular card template expects {{FrontSide}} to resolve to an empty\n        // string on the front side :-(\n        req.qfmt = \"{{FrontSide}}{{N}}\";\n        let response = super::render_card(req.clone()).unwrap();\n        assert_eq!(\n            &response.qnodes,\n            &[\n                FN::Replacement {\n                    field_name: \"FrontSide\".into(),\n                    current_text: \"\".into(),\n                    filters: vec![]\n                },\n                FN::Text { text: \"N\".into() }\n            ]\n        );\n        assert!(!response.is_empty);\n        req.partial_render = false;\n        let response = super::render_card(req.clone()).unwrap();\n        assert_eq!(&response.qnodes, &[FN::Text { text: \"N\".into() }]);\n        assert!(!response.is_empty);\n    }\n}\n"
  },
  {
    "path": "rslib/src/template_filters.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\n\nuse blake3::Hasher;\nuse regex::Captures;\nuse regex::Regex;\n\nuse crate::cloze::cloze_filter;\nuse crate::cloze::cloze_only_filter;\nuse crate::template::RenderContext;\nuse crate::text::strip_html;\n\n// Filtering\n//----------------------------------------\n\n/// Applies built in filters, returning the resulting text and remaining\n/// filters.\n///\n/// If [context.partial_for_python] is true, the first non-standard filter that\n/// is encountered will terminate processing, so non-standard filters must come\n/// at the end. If false, missing filters are ignored.\npub(crate) fn apply_filters<'a>(\n    text: &'a str,\n    filters: &[&str],\n    field_name: &str,\n    context: &RenderContext,\n) -> (Cow<'a, str>, Vec<String>) {\n    let mut text: Cow<str> = text.into();\n\n    // type:cloze & type:nc are handled specially\n    // other type: are passed as the default one\n    let filters = match filters {\n        [\"cloze\", \"type\"] => &[\"type-cloze\"],\n        [\"nc\", \"type\"] => &[\"type-nc\"],\n        [.., \"type\"] => &[\"type\"],\n        _ => filters,\n    };\n\n    for (idx, &filter_name) in filters.iter().enumerate() {\n        match apply_filter(filter_name, text.as_ref(), field_name, context) {\n            (true, None) => {\n                // filter did not change text\n            }\n            (true, Some(output)) => {\n                // text updated\n                text = output.into();\n            }\n            (false, _) => {\n                // unrecognized filter\n                if context.partial_for_python {\n                    //  return current text and remaining filters\n                    return (\n                        text,\n                        filters.iter().skip(idx).map(ToString::to_string).collect(),\n                    );\n                }\n            }\n        }\n    }\n\n    // all filters processed\n    (text, vec![])\n}\n\n/// Apply one filter.\n///\n/// Returns true if filter was valid.\n/// Returns string if input text changed.\nfn apply_filter(\n    filter_name: &str,\n    text: &str,\n    field_name: &str,\n    context: &RenderContext,\n) -> (bool, Option<String>) {\n    let output_text = match filter_name {\n        \"text\" => strip_html(text),\n        \"furigana\" => furigana_filter(text),\n        \"kanji\" => kanji_filter(text),\n        \"kana\" => kana_filter(text),\n        \"type\" => type_filter(field_name),\n        \"type-cloze\" => type_cloze_filter(field_name),\n        \"type-nc\" => type_nc_filter(field_name),\n        \"hint\" => hint_filter(text, field_name),\n        \"cloze\" => cloze_filter(text, context),\n        \"cloze-only\" => cloze_only_filter(text, context),\n        // an empty filter name (caused by using two colons) is ignored\n        \"\" => text.into(),\n        _ => {\n            if let Some(options) = filter_name.strip_prefix(\"tts \") {\n                tts_filter(options, text).into()\n            } else {\n                // unrecognized filter\n                return (false, None);\n            }\n        }\n    };\n\n    (\n        true,\n        match output_text {\n            Cow::Owned(o) => Some(o),\n            _ => None,\n        },\n    )\n}\n\n// Ruby filters\n//----------------------------------------\n\nstatic FURIGANA: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\" ?([^ >]+?)\\[(.+?)\\]\").unwrap());\n\n/// Did furigana regex match a sound tag?\nfn captured_sound(caps: &Captures) -> bool {\n    caps.get(2).unwrap().as_str().starts_with(\"sound:\")\n}\n\nfn kana_filter(text: &str) -> Cow<'_, str> {\n    FURIGANA\n        .replace_all(&text.replace(\"&nbsp;\", \" \"), |caps: &Captures| {\n            if captured_sound(caps) {\n                caps.get(0).unwrap().as_str().to_owned()\n            } else {\n                caps.get(2).unwrap().as_str().to_owned()\n            }\n        })\n        .into_owned()\n        .into()\n}\n\nfn kanji_filter(text: &str) -> Cow<'_, str> {\n    FURIGANA\n        .replace_all(&text.replace(\"&nbsp;\", \" \"), |caps: &Captures| {\n            if captured_sound(caps) {\n                caps.get(0).unwrap().as_str().to_owned()\n            } else {\n                caps.get(1).unwrap().as_str().to_owned()\n            }\n        })\n        .into_owned()\n        .into()\n}\n\nfn furigana_filter(text: &str) -> Cow<'_, str> {\n    FURIGANA\n        .replace_all(&text.replace(\"&nbsp;\", \" \"), |caps: &Captures| {\n            if captured_sound(caps) {\n                caps.get(0).unwrap().as_str().to_owned()\n            } else {\n                format!(\n                    \"<ruby><rb>{}</rb><rt>{}</rt></ruby>\",\n                    caps.get(1).unwrap().as_str(),\n                    caps.get(2).unwrap().as_str()\n                )\n            }\n        })\n        .into_owned()\n        .into()\n}\n\n// Other filters\n//----------------------------------------\n\n/// convert to [[type:...]] for the gui code to process\nfn type_filter<'a>(field_name: &str) -> Cow<'a, str> {\n    format!(\"[[type:{field_name}]]\").into()\n}\n\nfn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> {\n    format!(\"[[type:cloze:{field_name}]]\").into()\n}\n\nfn type_nc_filter<'a>(field_name: &str) -> Cow<'a, str> {\n    format!(\"[[type:nc:{field_name}]]\").into()\n}\n\nfn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> {\n    if text.trim().is_empty() {\n        return text.into();\n    }\n\n    // generate a unique DOM id\n    let mut hasher = Hasher::new();\n    hasher.update(text.as_bytes());\n    hasher.update(field_name.as_bytes());\n    let id = hex::encode(&hasher.finalize().as_bytes()[0..8]);\n\n    format!(\n        r##\"\n<a class=hint href=\"#\"\nonclick=\"this.style.display='none';\ndocument.getElementById('hint{id}').style.display='block';\nreturn false;\" draggable=false>\n{field_name}</a>\n<div id=\"hint{id}\" class=hint style=\"display: none\">{text}</div>\n\"##\n    )\n    .into()\n}\n\nfn tts_filter(options: &str, text: &str) -> String {\n    format!(\"[anki:tts lang={options}]{text}[/anki:tts]\")\n}\n\n// Tests\n//----------------------------------------\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn furigana() {\n        let text = \"test first[second] third[fourth]\";\n        assert_eq!(kana_filter(text).as_ref(), \"testsecondfourth\");\n        assert_eq!(kanji_filter(text).as_ref(), \"testfirstthird\");\n        assert_eq!(\n            furigana_filter(\"first[second]\").as_ref(),\n            \"<ruby><rb>first</rb><rt>second</rt></ruby>\"\n        );\n    }\n\n    #[allow(clippy::needless_raw_string_hashes)]\n    #[test]\n    fn hint() {\n        assert_eq!(\n            hint_filter(\"foo\", \"field\"),\n            r##\"\n<a class=hint href=\"#\"\nonclick=\"this.style.display='none';\ndocument.getElementById('hint83fe48607f0f3a66').style.display='block';\nreturn false;\" draggable=false>\nfield</a>\n<div id=\"hint83fe48607f0f3a66\" class=hint style=\"display: none\">foo</div>\n\"##\n        );\n    }\n\n    #[test]\n    fn typing() {\n        assert_eq!(type_filter(\"Front\"), \"[[type:Front]]\");\n        assert_eq!(type_cloze_filter(\"Front\"), \"[[type:cloze:Front]]\");\n        assert_eq!(type_nc_filter(\"Front\"), \"[[type:nc:Front]]\");\n        let ctx = RenderContext {\n            fields: &Default::default(),\n            nonempty_fields: &Default::default(),\n            frontside: Some(\"\"),\n            card_ord: 0,\n            partial_for_python: true,\n        };\n        assert_eq!(\n            apply_filters(\"ignored\", &[\"cloze\", \"type\"], \"Text\", &ctx),\n            (\"[[type:cloze:Text]]\".into(), vec![])\n        );\n        assert_eq!(\n            apply_filters(\"ignored\", &[\"nc\", \"type\"], \"Text\", &ctx),\n            (\"[[type:nc:Text]]\".into(), vec![])\n        );\n        assert_eq!(\n            apply_filters(\"ignored\", &[\"some\", \"unknown\", \"type\"], \"Text\", &ctx),\n            (\"[[type:Text]]\".into(), vec![])\n        );\n    }\n\n    #[test]\n    fn cloze() {\n        let text = \"{{c1::one}} {{c2::two::hint}}\";\n        let mut ctx = RenderContext {\n            fields: &Default::default(),\n            nonempty_fields: &Default::default(),\n            frontside: None,\n            card_ord: 0,\n            partial_for_python: true,\n        };\n        assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), \"[...] two\");\n        assert_eq!(\n            cloze_filter(text, &ctx),\n            r#\"<span class=\"cloze\" data-cloze=\"one\" data-ordinal=\"1\">[...]</span> <span class=\"cloze-inactive\" data-ordinal=\"2\">two</span>\"#\n        );\n\n        ctx.card_ord = 1;\n        assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), \"one [hint]\");\n\n        ctx.frontside = Some(\"\");\n        assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), \"one two\");\n\n        // if the provided ordinal did not match any cloze deletions,\n        // Anki treats the string as blank, which add-ons like\n        // cloze overlapper take advantage of.\n        ctx.card_ord = 2;\n        assert_eq!(cloze_filter(text, &ctx).as_ref(), \"\");\n    }\n\n    #[test]\n    fn tts() {\n        assert_eq!(\n            tts_filter(\"en_US voices=Bob,Jane\", \"foo\"),\n            \"[anki:tts lang=en_US voices=Bob,Jane]foo[/anki:tts]\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/tests.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#![cfg(test)]\n#![allow(dead_code)]\n\nuse itertools::Itertools;\nuse tempfile::tempdir;\nuse tempfile::TempDir;\n\nuse crate::collection::CollectionBuilder;\nuse crate::deckconfig::DeckConfigInner;\nuse crate::media::MediaManager;\nuse crate::prelude::*;\n\npub(crate) fn open_fs_test_collection(name: &str) -> (Collection, TempDir) {\n    let tempdir = tempdir().unwrap();\n    let dir = tempdir.path();\n    let col = CollectionBuilder::new(dir.join(format!(\"{name}.anki2\")))\n        .with_desktop_media_paths()\n        .build()\n        .unwrap();\n    (col, tempdir)\n}\n\npub(crate) fn open_test_collection_with_learning_card() -> Collection {\n    let mut col = Collection::new();\n    NoteAdder::basic(&mut col).add(&mut col);\n    col.answer_again();\n    col.clear_study_queues();\n    col\n}\n\npub(crate) fn open_test_collection_with_relearning_card() -> Collection {\n    let mut col = Collection::new();\n    NoteAdder::basic(&mut col).add(&mut col);\n    col.answer_easy();\n    col.storage\n        .db\n        .execute_batch(\"UPDATE cards SET due = 0\")\n        .unwrap();\n    col.clear_study_queues();\n    col.answer_again();\n    col.clear_study_queues();\n    col\n}\n\nimpl Collection {\n    pub(crate) fn new() -> Collection {\n        CollectionBuilder::default().build().unwrap()\n    }\n\n    pub(crate) fn add_media(&self, media: &[(&str, &[u8])]) {\n        let mgr = MediaManager::new(&self.media_folder, &self.media_db).unwrap();\n        for (name, data) in media {\n            mgr.add_file(name, data).unwrap();\n        }\n    }\n\n    pub(crate) fn get_all_notes(&mut self) -> Vec<Note> {\n        self.storage.get_all_notes()\n    }\n\n    pub(crate) fn get_first_card(&self) -> Card {\n        self.storage.get_all_cards().pop().unwrap()\n    }\n\n    pub(crate) fn set_default_learn_steps(&mut self, steps: Vec<f32>) {\n        self.update_default_deck_config(|config| config.learn_steps = steps);\n    }\n\n    pub(crate) fn set_default_relearn_steps(&mut self, steps: Vec<f32>) {\n        self.update_default_deck_config(|config| config.relearn_steps = steps);\n    }\n\n    /// Updates with the modified config, then resorts and adjusts remaining\n    /// steps in the default deck.\n    pub(crate) fn update_default_deck_config(\n        &mut self,\n        modifier: impl FnOnce(&mut DeckConfigInner),\n    ) {\n        let config = self\n            .get_deck_config(DeckConfigId(1), false)\n            .unwrap()\n            .unwrap();\n        let mut new_config = config.clone();\n\n        modifier(&mut new_config.inner);\n\n        self.update_deck_config_inner(&mut new_config, config.clone(), None)\n            .unwrap();\n        self.sort_deck(DeckId(1), config.inner.new_card_insert_order(), Usn(0))\n            .unwrap();\n        self.adjust_remaining_steps_in_deck(DeckId(1), Some(&config), Some(&new_config), Usn(0))\n            .unwrap();\n    }\n\n    pub(crate) fn basic_notetype(&self) -> Notetype {\n        let ntid = self.storage.get_notetype_id(\"Basic\").unwrap().unwrap();\n        self.storage.get_notetype(ntid).unwrap().unwrap()\n    }\n\n    pub(crate) fn basic_rev_notetype(&self) -> Notetype {\n        let ntid = self\n            .storage\n            .get_notetype_id(\"Basic (and reversed card)\")\n            .unwrap()\n            .unwrap();\n        self.storage.get_notetype(ntid).unwrap().unwrap()\n    }\n\n    pub(crate) fn cloze_notetype(&self) -> Notetype {\n        let ntid = self.storage.get_notetype_id(\"Cloze\").unwrap().unwrap();\n        self.storage.get_notetype(ntid).unwrap().unwrap()\n    }\n}\n\n#[derive(Debug, Default, Clone)]\npub(crate) struct DeckAdder {\n    name: NativeDeckName,\n    filtered: bool,\n    config: Option<DeckConfig>,\n}\n\nimpl DeckAdder {\n    pub(crate) fn new(human_name: impl AsRef<str>) -> Self {\n        Self {\n            name: NativeDeckName::from_human_name(human_name),\n            ..Default::default()\n        }\n    }\n\n    pub(crate) fn filtered(mut self, filtered: bool) -> Self {\n        self.filtered = filtered;\n        self\n    }\n\n    pub(crate) fn with_config(mut self, modifier: impl FnOnce(&mut DeckConfig)) -> Self {\n        let mut config = DeckConfig::default();\n        modifier(&mut config);\n        self.config = Some(config);\n        self\n    }\n\n    pub(crate) fn add(mut self, col: &mut Collection) -> Deck {\n        let config_opt = self.config.take();\n        let mut deck = self.deck();\n        if let Some(mut config) = config_opt {\n            col.add_or_update_deck_config(&mut config).unwrap();\n            deck.normal_mut()\n                .expect(\"can't set config for filtered deck\")\n                .config_id = config.id.0;\n        }\n        col.add_or_update_deck(&mut deck).unwrap();\n        deck\n    }\n\n    pub(crate) fn deck(self) -> Deck {\n        let mut deck = if self.filtered {\n            Deck::new_filtered()\n        } else {\n            Deck::new_normal()\n        };\n        deck.name = self.name;\n        deck\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct NoteAdder {\n    note: Note,\n    deck: DeckId,\n}\n\nimpl NoteAdder {\n    pub(crate) fn new(notetype: &Notetype) -> Self {\n        Self {\n            note: notetype.new_note(),\n            deck: DeckId(1),\n        }\n    }\n\n    pub(crate) fn basic(col: &mut Collection) -> Self {\n        Self::new(&col.basic_notetype())\n    }\n\n    pub(crate) fn cloze(col: &mut Collection) -> Self {\n        Self::new(&col.cloze_notetype())\n    }\n\n    pub(crate) fn fields(mut self, fields: &[&str]) -> Self {\n        *self.note.fields_mut() = fields.iter().map(ToString::to_string).collect();\n        self\n    }\n\n    pub(crate) fn deck(mut self, deck: DeckId) -> Self {\n        self.deck = deck;\n        self\n    }\n\n    pub(crate) fn add(mut self, col: &mut Collection) -> Note {\n        col.add_note(&mut self.note, self.deck).unwrap();\n        self.note\n    }\n\n    pub(crate) fn note(self) -> Note {\n        self.note\n    }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct CardAdder {\n    siblings: usize,\n    deck: DeckId,\n    due_dates: Vec<&'static str>,\n}\n\nimpl CardAdder {\n    pub(crate) fn new() -> Self {\n        Self {\n            siblings: 1,\n            deck: DeckId(1),\n            due_dates: Vec::new(),\n        }\n    }\n\n    pub(crate) fn siblings(mut self, siblings: usize) -> Self {\n        self.siblings = siblings;\n        self\n    }\n\n    pub(crate) fn deck(mut self, deck: DeckId) -> Self {\n        self.deck = deck;\n        self\n    }\n\n    /// Takes an array of strs and sets the due date of the first siblings\n    /// accordingly, skipping siblings if a str is empty.\n    pub(crate) fn due_dates(mut self, due_dates: impl Into<Vec<&'static str>>) -> Self {\n        self.due_dates = due_dates.into();\n        self\n    }\n\n    pub(crate) fn add(&self, col: &mut Collection) -> Vec<Card> {\n        let field = (1..self.siblings + 1)\n            .map(|n| format!(\"{{{{c{n}::}}}}\"))\n            .join(\"\");\n        let note = NoteAdder::cloze(col)\n            .fields(&[&field, \"\"])\n            .deck(self.deck)\n            .add(col);\n\n        if !self.due_dates.is_empty() {\n            let cids = col.storage.card_ids_of_notes(&[note.id]).unwrap();\n            for (ord, due_date) in self.due_dates.iter().enumerate() {\n                if !due_date.is_empty() {\n                    col.set_due_date(&cids[ord..ord + 1], due_date, None)\n                        .unwrap();\n                }\n            }\n        }\n\n        col.storage.all_cards_of_note(note.id).unwrap()\n    }\n}\n"
  },
  {
    "path": "rslib/src/text.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\n\nuse percent_encoding_iri::percent_decode_str;\nuse percent_encoding_iri::utf8_percent_encode;\nuse percent_encoding_iri::AsciiSet;\nuse percent_encoding_iri::CONTROLS;\nuse regex::Captures;\nuse regex::Regex;\nuse unicase::eq as uni_eq;\nuse unicode_normalization::char::is_combining_mark;\nuse unicode_normalization::is_nfc;\nuse unicode_normalization::is_nfkd_quick;\nuse unicode_normalization::IsNormalized;\nuse unicode_normalization::UnicodeNormalization;\n\npub trait Trimming {\n    fn trim(self) -> Self;\n}\n\nimpl Trimming for Cow<'_, str> {\n    fn trim(self) -> Self {\n        match self {\n            Cow::Borrowed(text) => text.trim().into(),\n            Cow::Owned(text) => {\n                let trimmed = text.as_str().trim();\n                if trimmed.len() == text.len() {\n                    text.into()\n                } else {\n                    trimmed.to_string().into()\n                }\n            }\n        }\n    }\n}\n\npub(crate) trait CowMapping<'a, B: ?Sized + 'a + ToOwned> {\n    /// Returns [self]\n    /// - unchanged, if the given function returns [Cow::Borrowed]\n    /// - with the new value, if the given function returns [Cow::Owned]\n    fn map_cow(self, f: impl FnOnce(&B) -> Cow<B>) -> Self;\n    fn get_owned(self) -> Option<B::Owned>;\n}\n\nimpl<'a, B: ?Sized + 'a + ToOwned> CowMapping<'a, B> for Cow<'a, B> {\n    fn map_cow(self, f: impl FnOnce(&B) -> Cow<B>) -> Self {\n        if let Cow::Owned(o) = f(&self) {\n            Cow::Owned(o)\n        } else {\n            self\n        }\n    }\n\n    fn get_owned(self) -> Option<B::Owned> {\n        match self {\n            Cow::Borrowed(_) => None,\n            Cow::Owned(s) => Some(s),\n        }\n    }\n}\n\npub(crate) fn strip_utf8_bom(s: &str) -> &str {\n    s.strip_prefix('\\u{feff}').unwrap_or(s)\n}\n\n#[derive(Debug, PartialEq)]\npub enum AvTag {\n    SoundOrVideo(String),\n    TextToSpeech {\n        field_text: String,\n        lang: String,\n        voices: Vec<String>,\n        speed: f32,\n        other_args: Vec<String>,\n    },\n}\n\nstatic HTML: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(concat!(\n        \"(?si)\",\n        // wrapped text\n        r\"(<!--.*?-->)|(<style.*?>.*?</style>)|(<script.*?>.*?</script>)\",\n        // html tags\n        r\"|(<.*?>)\",\n    ))\n    .unwrap()\n});\nstatic HTML_LINEBREAK_TAGS: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?xsi)\n            </?\n            (?:\n                br|address|article|aside|blockquote|canvas|dd|div\n                |dl|dt|fieldset|figcaption|figure|footer|form\n                |h[1-6]|header|hr|li|main|nav|noscript|ol\n                |output|p|pre|section|table|tfoot|ul|video\n            )\n            >\n        \"#,\n    )\n    .unwrap()\n});\n\npub static HTML_MEDIA_TAGS: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?xsi)\n            # the start of the image, audio, object, or source tag\n            <\\b(?:img|audio|video|object|source)\\b\n\n            # any non-`>`, except inside `\"` or `'`\n            (?:\n                [^>]\n            |\n                \"[^\"]+?\"\n            |\n                '[^']+?'\n            )+?\n\n            # capture `src` or `data` attribute\n            \\b(?:src|data)\\b=\n            (?:\n                    # 1: double-quoted filename\n                    \"\n                    ([^\"]+?)\n                    \"\n                    [^>]*>                    \n                |\n                    # 2: single-quoted filename\n                    '\n                    ([^']+?)\n                    '\n                    [^>]*>\n                |\n                    # 3: unquoted filename\n                    ([^ >]+?)\n                    (?:\n                        # then either a space and the rest\n                        \\x20[^>]*>\n                        |\n                        # or the tag immediately ends\n                        >\n                    )\n            )\n            \"#,\n    )\n    .unwrap()\n});\n\n// videos are also in sound tags\nstatic AV_TAGS: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?xs)\n            \\[sound:(.+?)\\]     # 1 - the filename in a sound tag\n            |\n            \\[anki:tts\\]\n                \\[(.*?)\\]       # 2 - arguments to tts call\n                (.*?)           # 3 - field text\n            \\[/anki:tts\\]\n            \",\n    )\n    .unwrap()\n});\n\nstatic PERSISTENT_HTML_SPACERS: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?i)<br\\s*/?>|<div>|\\n\").unwrap());\n\nstatic TYPE_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\[\\[type:[^]]+\\]\\]\").unwrap());\npub(crate) static SOUND_TAG: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"\\[sound:([^]]+)\\]\").unwrap());\n\n/// Files included in CSS with a leading underscore.\nstatic UNDERSCORED_CSS_IMPORTS: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?xi)\n            (?:@import\\s+           # import statement with a bare\n                \"(_[^\"]*.css)\"      # double quoted\n                |                   # or\n                '(_[^']*.css)'      # single quoted css filename\n            )\n            |                       # or\n            (?:url\\(\\s*             # a url function with a\n                \"(_[^\"]+)\"          # double quoted\n                |                   # or\n                '(_[^']+)'          # single quoted\n                |                   # or\n                (_.+?)              # unquoted filename\n            \\s*\\))\n    \"#,\n    )\n    .unwrap()\n});\n\n/// Strings, src and data attributes with a leading underscore.\nstatic UNDERSCORED_REFERENCES: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r#\"(?x)\n                \\[sound:(_[^]]+)\\]  # a filename in an Anki sound tag\n            |                       # or\n                \"(_[^\"]+)\"          # a double quoted\n            |                       # or\n                '(_[^']+)'          # single quoted string\n            |                       # or\n                \\b(?:src|data)      # a 'src' or 'data' attribute\n                =                   # followed by\n                (_[^ >]+)           # an unquoted value\n    \"#,\n    )\n    .unwrap()\n});\n\npub fn is_html(text: impl AsRef<str>) -> bool {\n    HTML.is_match(text.as_ref())\n}\n\npub fn html_to_text_line(html: &str, preserve_media_filenames: bool) -> Cow<'_, str> {\n    let (html_stripper, sound_rep): (fn(&str) -> Cow<'_, str>, _) = if preserve_media_filenames {\n        (strip_html_preserving_media_filenames, \"$1\")\n    } else {\n        (strip_html, \"\")\n    };\n    PERSISTENT_HTML_SPACERS\n        .replace_all(html, \" \")\n        .map_cow(|s| TYPE_TAG.replace_all(s, \"\"))\n        .map_cow(|s| SOUND_TAG.replace_all(s, sound_rep))\n        .map_cow(html_stripper)\n        .trim()\n}\n\npub fn strip_html(html: &str) -> Cow<'_, str> {\n    strip_html_preserving_entities(html).map_cow(decode_entities)\n}\n\npub fn strip_html_preserving_entities(html: &str) -> Cow<'_, str> {\n    HTML.replace_all(html, \"\")\n}\n\npub fn decode_entities(html: &str) -> Cow<'_, str> {\n    if html.contains('&') {\n        match htmlescape::decode_html(html) {\n            Ok(text) => text.replace('\\u{a0}', \" \").into(),\n            Err(_) => html.into(),\n        }\n    } else {\n        // nothing to do\n        html.into()\n    }\n}\n\npub(crate) fn newlines_to_spaces(text: &str) -> Cow<'_, str> {\n    if text.contains('\\n') {\n        text.replace('\\n', \" \").into()\n    } else {\n        text.into()\n    }\n}\n\npub fn strip_html_for_tts(html: &str) -> Cow<'_, str> {\n    HTML_LINEBREAK_TAGS\n        .replace_all(html, \" \")\n        .map_cow(strip_html)\n}\n\n/// Truncate a String on a valid UTF8 boundary.\npub(crate) fn truncate_to_char_boundary(s: &mut String, mut max: usize) {\n    if max >= s.len() {\n        return;\n    }\n    while !s.is_char_boundary(max) {\n        max -= 1;\n    }\n    s.truncate(max);\n}\n\n#[derive(Debug)]\npub(crate) struct MediaRef<'a> {\n    pub full_ref: &'a str,\n    pub fname: &'a str,\n    /// audio files may have things like &amp; that need decoding\n    pub fname_decoded: Cow<'a, str>,\n}\n\npub(crate) fn extract_media_refs(text: &str) -> Vec<MediaRef<'_>> {\n    let mut out = vec![];\n\n    for caps in HTML_MEDIA_TAGS.captures_iter(text) {\n        let fname = caps\n            .get(1)\n            .or_else(|| caps.get(2))\n            .or_else(|| caps.get(3))\n            .unwrap()\n            .as_str();\n        let fname_decoded = decode_entities(fname);\n        out.push(MediaRef {\n            full_ref: caps.get(0).unwrap().as_str(),\n            fname,\n            fname_decoded,\n        });\n    }\n\n    for caps in AV_TAGS.captures_iter(text) {\n        if let Some(m) = caps.get(1) {\n            let fname = m.as_str();\n            let fname_decoded = decode_entities(fname);\n            out.push(MediaRef {\n                full_ref: caps.get(0).unwrap().as_str(),\n                fname,\n                fname_decoded,\n            });\n        }\n    }\n\n    out\n}\n\n/// Calls `replacer` for every media reference in `text`, and optionally\n/// replaces it with something else. [None] if no reference was found.\npub fn replace_media_refs(\n    text: &str,\n    mut replacer: impl FnMut(&str) -> Option<String>,\n) -> Option<String> {\n    let mut rep = |caps: &Captures| {\n        let whole_match = caps.get(0).unwrap().as_str();\n        let old_name = caps.iter().skip(1).find_map(|g| g).unwrap().as_str();\n        let old_name_decoded = decode_entities(old_name);\n\n        if let Some(mut new_name) = replacer(&old_name_decoded) {\n            if matches!(old_name_decoded, Cow::Owned(_)) {\n                new_name = htmlescape::encode_minimal(&new_name);\n            }\n            whole_match.replace(old_name, &new_name)\n        } else {\n            whole_match.to_owned()\n        }\n    };\n\n    HTML_MEDIA_TAGS\n        .replace_all(text, &mut rep)\n        .map_cow(|s| AV_TAGS.replace_all(s, &mut rep))\n        .get_owned()\n}\n\npub(crate) fn extract_underscored_css_imports(text: &str) -> Vec<&str> {\n    UNDERSCORED_CSS_IMPORTS\n        .captures_iter(text)\n        .map(extract_match)\n        .collect()\n}\n\npub(crate) fn extract_underscored_references(text: &str) -> Vec<&str> {\n    UNDERSCORED_REFERENCES\n        .captures_iter(text)\n        .map(extract_match)\n        .collect()\n}\n\n/// Returns the first matching group as a str. This is intended for regexes\n/// where exactly one group matches, and will panic for matches without matching\n/// groups.\nfn extract_match(caps: Captures<'_>) -> &str {\n    caps.iter().skip(1).find_map(|g| g).unwrap().as_str()\n}\n\npub fn strip_html_preserving_media_filenames(html: &str) -> Cow<'_, str> {\n    HTML_MEDIA_TAGS\n        .replace_all(html, r\" ${1}${2}${3} \")\n        .map_cow(strip_html)\n}\n\npub fn contains_media_tag(html: &str) -> bool {\n    HTML_MEDIA_TAGS.is_match(html)\n}\n\n#[allow(dead_code)]\npub(crate) fn sanitize_html(html: &str) -> String {\n    ammonia::clean(html)\n}\n\npub(crate) fn sanitize_html_no_images(html: &str) -> String {\n    ammonia::Builder::default()\n        .rm_tags(&[\"img\"])\n        .clean(html)\n        .to_string()\n}\n\npub(crate) fn normalize_to_nfc(s: &str) -> Cow<'_, str> {\n    match is_nfc(s) {\n        false => s.chars().nfc().collect::<String>().into(),\n        true => s.into(),\n    }\n}\n\npub(crate) fn ensure_string_in_nfc(s: &mut String) {\n    if !is_nfc(s) {\n        *s = s.chars().nfc().collect()\n    }\n}\n\nstatic EXTRA_NO_COMBINING_REPLACEMENTS: phf::Map<char, &str> = phf::phf_map! {\n'€'  =>  \"E\",\n'Æ'  =>  \"AE\",\n'Ð'  =>  \"D\",\n'Ø'  =>  \"O\",\n'Þ'  =>  \"TH\",\n'ß'  =>  \"s\",\n'æ'  =>  \"ae\",\n'ð'  =>  \"d\",\n'ø'  =>  \"o\",\n'þ'  =>  \"th\",\n'Đ'  =>  \"D\",\n'đ'  =>  \"d\",\n'Ħ'  =>  \"H\",\n'ħ'  =>  \"h\",\n'ı'  =>  \"i\",\n'ĸ'  =>  \"k\",\n'Ł'  =>  \"L\",\n'ł'  =>  \"l\",\n'Ŋ'  =>  \"N\",\n'ŋ'  =>  \"n\",\n'Œ'  =>  \"OE\",\n'œ'  =>  \"oe\",\n'Ŧ'  =>  \"T\",\n'ŧ'  =>  \"t\",\n'Ə'  =>  \"E\",\n'ǝ'  =>  \"e\",\n'ɑ'  =>  \"a\",\n};\n\n/// Convert provided string to NFKD form and strip combining characters.\npub(crate) fn without_combining(s: &str) -> Cow<'_, str> {\n    // if the string is already normalized\n    if matches!(is_nfkd_quick(s.chars()), IsNormalized::Yes) {\n        // and no combining characters found, return unchanged\n        if !s\n            .chars()\n            .any(|c| is_combining_mark(c) || EXTRA_NO_COMBINING_REPLACEMENTS.contains_key(&c))\n        {\n            return s.into();\n        }\n    }\n\n    // we need to create a new string without the combining marks\n    let mut out = String::with_capacity(s.len());\n    for chr in s.chars().nfkd().filter(|c| !is_combining_mark(*c)) {\n        if let Some(repl) = EXTRA_NO_COMBINING_REPLACEMENTS.get(&chr) {\n            out.push_str(repl);\n        } else {\n            out.push(chr);\n        }\n    }\n\n    out.into()\n}\n\n/// Check if string contains an unescaped wildcard.\npub(crate) fn is_glob(txt: &str) -> bool {\n    // even number of \\s followed by a wildcard\n    static RE: LazyLock<Regex> = LazyLock::new(|| {\n        Regex::new(\n            r\"(?x)\n            (?:^|[^\\\\])     # not a backslash\n            (?:\\\\\\\\)*       # even number of backslashes\n            [*_]            # wildcard\n            \",\n        )\n        .unwrap()\n    });\n\n    RE.is_match(txt)\n}\n\n/// Convert to a RegEx respecting Anki wildcards.\npub(crate) fn to_re(txt: &str) -> Cow<'_, str> {\n    to_custom_re(txt, \".\")\n}\n\n/// Convert Anki style to RegEx using the provided wildcard.\npub(crate) fn to_custom_re<'a>(txt: &'a str, wildcard: &str) -> Cow<'a, str> {\n    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\\\?.\").unwrap());\n    RE.replace_all(txt, |caps: &Captures| {\n        let s = &caps[0];\n        match s {\n            r\"\\\\\" | r\"\\*\" => s.to_string(),\n            r\"\\_\" => \"_\".to_string(),\n            \"*\" => format!(\"{wildcard}*\"),\n            \"_\" => wildcard.to_string(),\n            s => regex::escape(s),\n        }\n    })\n}\n\n/// Convert to SQL respecting Anki wildcards.\npub(crate) fn to_sql(txt: &str) -> Cow<'_, str> {\n    // escape sequences and unescaped special characters which need conversion\n    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\\\[\\\\*]|[*%]\").unwrap());\n    RE.replace_all(txt, |caps: &Captures| {\n        let s = &caps[0];\n        match s {\n            r\"\\\\\" => r\"\\\\\",\n            r\"\\*\" => \"*\",\n            \"*\" => \"%\",\n            \"%\" => r\"\\%\",\n            _ => unreachable!(),\n        }\n    })\n}\n\n/// Unescape everything.\npub(crate) fn to_text(txt: &str) -> Cow<'_, str> {\n    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\\\(.)\").unwrap());\n    RE.replace_all(txt, \"$1\")\n}\n\n/// Escape Anki wildcards and the backslash for escaping them: \\*_\npub(crate) fn escape_anki_wildcards(txt: &str) -> String {\n    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"[\\\\*_]\").unwrap());\n    RE.replace_all(txt, r\"\\$0\").into()\n}\n\n/// Escape Anki wildcards unless it's _*\npub(crate) fn escape_anki_wildcards_for_search_node(txt: &str) -> String {\n    if txt == \"_*\" {\n        txt.to_string()\n    } else {\n        escape_anki_wildcards(txt)\n    }\n}\n\n/// Return a function to match input against `search`,\n/// which may contain wildcards.\npub(crate) fn glob_matcher(search: &str) -> impl Fn(&str) -> bool + '_ {\n    let mut regex = None;\n    let mut cow = None;\n    if is_glob(search) {\n        regex = Some(Regex::new(&format!(\"^(?i){}$\", to_re(search))).unwrap());\n    } else {\n        cow = Some(to_text(search));\n    }\n\n    move |text| {\n        if let Some(r) = &regex {\n            r.is_match(text)\n        } else {\n            uni_eq(text, cow.as_ref().unwrap())\n        }\n    }\n}\n\npub(crate) static REMOTE_FILENAME: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(\"(?i)^https?://\").unwrap());\n\n/// https://url.spec.whatwg.org/#fragment-percent-encode-set\nconst FRAGMENT_QUERY_UNION: &AsciiSet = &CONTROLS\n    .add(b' ')\n    .add(b'\"')\n    .add(b'<')\n    .add(b'>')\n    .add(b'`')\n    .add(b'#');\n\n/// IRI-encode unescaped local paths in HTML fragment.\npub(crate) fn encode_iri_paths(unescaped_html: &str) -> Cow<'_, str> {\n    transform_html_paths(unescaped_html, |fname| {\n        utf8_percent_encode(fname, FRAGMENT_QUERY_UNION).into()\n    })\n}\n\n/// URI-decode escaped local paths in HTML fragment.\npub(crate) fn decode_iri_paths(escaped_html: &str) -> Cow<'_, str> {\n    transform_html_paths(escaped_html, |fname| {\n        percent_decode_str(fname).decode_utf8_lossy()\n    })\n}\n\n/// Apply a transform to local filename references in tags like IMG.\n/// Required at display time, as Anki unfortunately stores the references\n/// in unencoded form in the database.\nfn transform_html_paths<F>(html: &str, transform: F) -> Cow<'_, str>\nwhere\n    F: Fn(&str) -> Cow<'_, str>,\n{\n    HTML_MEDIA_TAGS.replace_all(html, |caps: &Captures| {\n        let fname = caps\n            .get(1)\n            .or_else(|| caps.get(2))\n            .or_else(|| caps.get(3))\n            .unwrap()\n            .as_str();\n        let full = caps.get(0).unwrap().as_str();\n        if REMOTE_FILENAME.is_match(fname) {\n            full.into()\n        } else {\n            full.replace(fname, &transform(fname))\n        }\n    })\n}\n\n#[cfg(test)]\nmod test {\n    use std::borrow::Cow;\n\n    use super::*;\n\n    #[test]\n    fn stripping() {\n        assert_eq!(strip_html(\"test\"), \"test\");\n        assert_eq!(strip_html(\"t<b>e</b>st\"), \"test\");\n        assert_eq!(strip_html(\"so<SCRIPT>t<b>e</b>st</script>me\"), \"some\");\n\n        assert_eq!(\n            strip_html_preserving_media_filenames(\"<img src=foo.jpg>\"),\n            \" foo.jpg \"\n        );\n        assert_eq!(\n            strip_html_preserving_media_filenames(\"<img src='foo.jpg'><html>\"),\n            \" foo.jpg \"\n        );\n        assert_eq!(strip_html_preserving_media_filenames(\"<html>\"), \"\");\n    }\n\n    #[test]\n    fn combining() {\n        assert!(matches!(without_combining(\"test\"), Cow::Borrowed(_)));\n        assert!(matches!(without_combining(\"Über\"), Cow::Owned(_)));\n    }\n\n    #[test]\n    fn conversion() {\n        assert_eq!(&to_re(r\"[te\\*st]\"), r\"\\[te\\*st\\]\");\n        assert_eq!(&to_custom_re(\"f_o*\", r\"\\d\"), r\"f\\do\\d*\");\n        assert_eq!(&to_sql(\"%f_o*\"), r\"\\%f_o%\");\n        assert_eq!(&to_text(r\"\\*\\_*_\"), \"*_*_\");\n        assert!(is_glob(r\"\\\\\\\\_\"));\n        assert!(!is_glob(r\"\\\\\\_\"));\n        assert!(glob_matcher(r\"foo\\*bar*\")(\"foo*bar123\"));\n    }\n\n    #[test]\n    fn extracting() {\n        assert_eq!(\n            extract_underscored_css_imports(concat!(\n                \"@IMPORT '_foo.css'\\n\",\n                \"@import \\\"_bar.css\\\"\\n\",\n                \"@import '_baz.css'\\n\",\n                \"@import 'nope.css'\\n\",\n                \"url(_foo.css)\\n\",\n                \"URL(\\\"_bar.css\\\")\\n\",\n                \"@import url('_baz.css')\\n\",\n                \"url('nope.css')\\n\",\n                \"url(_foo.woff2) format('woff2')\",\n            )),\n            vec![\n                \"_foo.css\",\n                \"_bar.css\",\n                \"_baz.css\",\n                \"_foo.css\",\n                \"_bar.css\",\n                \"_baz.css\",\n                \"_foo.woff2\"\n            ]\n        );\n        assert_eq!(\n            extract_underscored_references(concat!(\n                \"<img src=\\\"_foo.jpg\\\">\",\n                \"<object data=\\\"_bar\\\">\",\n                \"\\\"_baz.js\\\"\",\n                \"\\\"nope.js\\\"\",\n                \"<img src=_foo.jpg>\",\n                \"<object data=_bar>\",\n                \"'_baz.js'\",\n            )),\n            vec![\"_foo.jpg\", \"_bar\", \"_baz.js\", \"_foo.jpg\", \"_bar\", \"_baz.js\",]\n        );\n    }\n\n    #[test]\n    fn replacing() {\n        assert_eq!(\n            &replace_media_refs(\"<img src=foo.jpg>[sound:bar.mp3]<img src=baz.jpg>\", |s| {\n                (s != \"baz.jpg\").then(|| \"spam\".to_string())\n            })\n            .unwrap(),\n            \"<img src=spam>[sound:spam]<img src=baz.jpg>\",\n        );\n    }\n\n    #[test]\n    fn truncate() {\n        let mut s = \"日本語\".to_string();\n        truncate_to_char_boundary(&mut s, 6);\n        assert_eq!(&s, \"日本\");\n        let mut s = \"日本語\".to_string();\n        truncate_to_char_boundary(&mut s, 1);\n        assert_eq!(&s, \"\");\n    }\n\n    #[test]\n    fn iri_encoding() {\n        for (input, output) in [\n            (\"foo.jpg\", \"foo.jpg\"),\n            (\"bar baz\", \"bar%20baz\"),\n            (\"sub/path.jpg\", \"sub/path.jpg\"),\n            (\"日本語\", \"日本語\"),\n            (\"a=b\", \"a=b\"),\n            (\"a&b\", \"a&b\"),\n        ] {\n            assert_eq!(\n                &encode_iri_paths(&format!(\"<img src=\\\"{input}\\\">\")),\n                &format!(\"<img src=\\\"{output}\\\">\")\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "rslib/src/timestamp.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::time;\n\nuse chrono::prelude::*;\n\nuse crate::define_newtype;\nuse crate::prelude::*;\n\ndefine_newtype!(TimestampSecs, i64);\ndefine_newtype!(TimestampMillis, i64);\n\nimpl TimestampSecs {\n    pub fn now() -> Self {\n        Self(elapsed().as_secs() as i64)\n    }\n\n    pub fn zero() -> Self {\n        Self(0)\n    }\n\n    pub fn elapsed_secs_since(self, other: TimestampSecs) -> i64 {\n        self.0 - other.0\n    }\n\n    pub fn elapsed_secs(self) -> u64 {\n        (Self::now().0 - self.0).max(0) as u64\n    }\n\n    pub fn elapsed_days_since(self, other: TimestampSecs) -> u64 {\n        (self.0 - other.0).max(0) as u64 / 86_400\n    }\n\n    pub fn as_millis(self) -> TimestampMillis {\n        TimestampMillis(self.0 * 1000)\n    }\n\n    pub(crate) fn local_datetime(self) -> Result<DateTime<Local>> {\n        Local\n            .timestamp_opt(self.0, 0)\n            .latest()\n            .or_invalid(\"invalid timestamp\")\n    }\n\n    /// YYYY-mm-dd\n    pub(crate) fn date_string(self) -> String {\n        self.local_datetime()\n            .map(|dt| dt.format(\"%Y-%m-%d\").to_string())\n            .unwrap_or_else(|_err| \"invalid date\".to_string())\n    }\n\n    /// HH-MM\n    pub(crate) fn time_string(self) -> String {\n        self.local_datetime()\n            .map(|dt| dt.format(\"%H:%M\").to_string())\n            .unwrap_or_else(|_err| \"invalid date\".to_string())\n    }\n\n    pub(crate) fn date_and_time_string(self) -> String {\n        format!(\"{} @ {}\", self.date_string(), self.time_string())\n    }\n\n    pub fn local_utc_offset(self) -> Result<FixedOffset> {\n        Ok(*self.local_datetime()?.offset())\n    }\n\n    pub fn datetime(self, utc_offset: FixedOffset) -> Result<DateTime<FixedOffset>> {\n        utc_offset\n            .timestamp_opt(self.0, 0)\n            .latest()\n            .or_invalid(\"invalid timestamp\")\n    }\n\n    pub fn adding_secs(self, secs: i64) -> Self {\n        TimestampSecs(self.0 + secs)\n    }\n}\n\nimpl TimestampMillis {\n    pub fn now() -> Self {\n        Self(elapsed().as_millis() as i64)\n    }\n\n    pub fn zero() -> Self {\n        Self(0)\n    }\n\n    pub fn as_secs(self) -> TimestampSecs {\n        TimestampSecs(self.0 / 1000)\n    }\n\n    pub fn adding_secs(self, secs: i64) -> Self {\n        Self(self.0 + secs * 1000)\n    }\n\n    pub fn elapsed_millis(self) -> u64 {\n        (Self::now().0 - self.0).max(0) as u64\n    }\n}\n\nfn elapsed() -> time::Duration {\n    if *crate::PYTHON_UNIT_TESTS {\n        // shift clock around rollover time to accommodate Python tests that make bad\n        // assumptions. we should update the tests in the future and remove this\n        // hack.\n        let mut elap = time::SystemTime::now()\n            .duration_since(time::SystemTime::UNIX_EPOCH)\n            .unwrap();\n        let now = Local::now();\n        if now.hour() >= 2 && now.hour() < 4 {\n            elap -= time::Duration::from_secs(60 * 60 * 2);\n        }\n        elap\n    } else {\n        time::SystemTime::now()\n            .duration_since(time::SystemTime::UNIX_EPOCH)\n            .unwrap()\n    }\n}\n"
  },
  {
    "path": "rslib/src/typeanswer.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\n\nuse difflib::sequencematcher::SequenceMatcher;\nuse regex::Regex;\nuse unic_ucd_category::GeneralCategory;\nuse unicode_normalization::char::is_combining_mark;\nuse unicode_normalization::UnicodeNormalization;\n\nuse crate::card_rendering::strip_av_tags;\nuse crate::text::normalize_to_nfc;\nuse crate::text::strip_html;\n\nstatic LINEBREAKS: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(\n        r\"(?six)\n        (\n            \\n\n            |\n            <br\\s?/?>\n            |\n            </?div>\n        )+\",\n    )\n    .unwrap()\n});\n\nmacro_rules! format_typeans {\n    ($typeans:expr) => {\n        format!(\"<code id=typeans>{}</code>\", $typeans)\n    };\n}\n\n// Public API\npub fn compare_answer(expected: &str, typed: &str, combining: bool) -> String {\n    let stripped = strip_expected(expected);\n\n    match typed.is_empty() {\n        true => format_typeans!(htmlescape::encode_minimal(&stripped)),\n        false if combining => Diff::new(&stripped, typed).to_html(),\n        false => DiffNonCombining::new(&stripped, typed).to_html(),\n    }\n}\n\n// Core Logic\ntrait DiffTrait {\n    fn get_typed(&self) -> &[char];\n    fn get_expected(&self) -> &[char];\n    fn get_expected_original(&self) -> Cow<'_, str>;\n\n    fn new(expected: &str, typed: &str) -> Self;\n\n    // Entry Point\n    fn to_html(&self) -> String {\n        if self.get_typed() == self.get_expected() {\n            format_typeans!(format!(\n                \"<span class=typeGood>{}</span>\",\n                htmlescape::encode_minimal(&self.get_expected_original())\n            ))\n        } else {\n            let output = self.to_tokens();\n            let typed_html = render_tokens(&output.typed_tokens);\n            let expected_html = self.render_expected_tokens(&output.expected_tokens);\n\n            format_typeans!(format!(\n                \"{typed_html}<br><span id=typearrow>&darr;</span><br>{expected_html}\"\n            ))\n        }\n    }\n\n    fn to_tokens(&self) -> DiffTokens {\n        let mut matcher = SequenceMatcher::new(self.get_typed(), self.get_expected());\n        let mut typed_tokens = Vec::new();\n        let mut expected_tokens = Vec::new();\n\n        for opcode in matcher.get_opcodes() {\n            let typed_slice = slice(self.get_typed(), opcode.first_start, opcode.first_end);\n            let expected_slice = slice(self.get_expected(), opcode.second_start, opcode.second_end);\n\n            match opcode.tag.as_str() {\n                \"equal\" => {\n                    typed_tokens.push(DiffToken::good(typed_slice));\n                    expected_tokens.push(DiffToken::good(expected_slice));\n                }\n                \"delete\" => typed_tokens.push(DiffToken::bad(typed_slice)),\n                \"insert\" => {\n                    typed_tokens.push(DiffToken::missing(\n                        \"-\".repeat(expected_slice.chars().count()),\n                    ));\n                    expected_tokens.push(DiffToken::missing(expected_slice));\n                }\n                \"replace\" => {\n                    typed_tokens.push(DiffToken::bad(typed_slice));\n                    expected_tokens.push(DiffToken::missing(expected_slice));\n                }\n                _ => unreachable!(),\n            }\n        }\n        DiffTokens {\n            typed_tokens,\n            expected_tokens,\n        }\n    }\n\n    fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String;\n}\n\n// Utility Functions\nfn normalize(string: &str) -> Vec<char> {\n    normalize_to_nfc(string).chars().collect()\n}\n\nfn slice(chars: &[char], start: usize, end: usize) -> String {\n    chars[start..end].iter().collect()\n}\n\nfn strip_expected(expected: &str) -> String {\n    let no_av_tags = strip_av_tags(expected);\n    let no_linebreaks = LINEBREAKS.replace_all(&no_av_tags, \" \");\n    strip_html(&no_linebreaks).trim().to_string()\n}\n\n// Render Functions\nfn render_tokens(tokens: &[DiffToken]) -> String {\n    tokens.iter().fold(String::new(), |mut acc, token| {\n        let isolated_text = isolate_leading_mark(&token.text);\n        let encoded_text = htmlescape::encode_minimal(&isolated_text);\n        let class = token.to_class();\n        acc.push_str(&format!(\"<span class={class}>{encoded_text}</span>\"));\n        acc\n    })\n}\n\n/// Prefixes a leading mark character with a non-breaking space to prevent\n/// it from joining the previous token.\nfn isolate_leading_mark(text: &str) -> Cow<'_, str> {\n    if text\n        .chars()\n        .next()\n        .is_some_and(|c| GeneralCategory::of(c).is_mark())\n    {\n        Cow::Owned(format!(\"\\u{a0}{text}\"))\n    } else {\n        Cow::Borrowed(text)\n    }\n}\n\n// Default Comparison\nstruct Diff {\n    typed: Vec<char>,\n    expected: Vec<char>,\n}\n\nimpl DiffTrait for Diff {\n    fn get_typed(&self) -> &[char] {\n        &self.typed\n    }\n    fn get_expected(&self) -> &[char] {\n        &self.expected\n    }\n    fn get_expected_original(&self) -> Cow<'_, str> {\n        Cow::Owned(self.get_expected().iter().collect::<String>())\n    }\n\n    fn new(expected: &str, typed: &str) -> Self {\n        Self {\n            typed: normalize(typed),\n            expected: normalize(expected),\n        }\n    }\n\n    fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String {\n        render_tokens(tokens)\n    }\n}\n\n// Non-Combining Comparison\nstruct DiffNonCombining {\n    base: Diff,\n    expected_split: Vec<String>,\n    expected_original: String,\n}\n\nimpl DiffTrait for DiffNonCombining {\n    fn get_typed(&self) -> &[char] {\n        &self.base.typed\n    }\n    fn get_expected(&self) -> &[char] {\n        &self.base.expected\n    }\n    fn get_expected_original(&self) -> Cow<'_, str> {\n        Cow::Borrowed(&self.expected_original)\n    }\n\n    fn new(expected: &str, typed: &str) -> Self {\n        // filter out combining elements\n        let typed_stripped: Vec<char> = typed.nfkd().filter(|&c| !is_combining_mark(c)).collect();\n        let mut expected_stripped: Vec<char> = Vec::new();\n        // also tokenize into \"char+combining\" for final rendering\n        let mut expected_split: Vec<String> = Vec::new();\n\n        for c in expected.nfkd() {\n            if unicode_normalization::char::is_combining_mark(c) {\n                if let Some(last) = expected_split.last_mut() {\n                    last.push(c);\n                }\n            } else {\n                expected_stripped.push(c);\n                expected_split.push(c.to_string());\n            }\n        }\n\n        Self {\n            base: Diff {\n                typed: typed_stripped,\n                expected: expected_stripped,\n            },\n            expected_split,\n            expected_original: expected.to_string(),\n        }\n    }\n\n    // Combining characters are still required learning content, so use\n    // expected_split to show them directly in the \"expected\" line, rather than\n    // having to otherwise e.g. include their field twice on the note template.\n    fn render_expected_tokens(&self, tokens: &[DiffToken]) -> String {\n        let mut idx = 0;\n        tokens.iter().fold(String::new(), |mut acc, token| {\n            let end = idx + token.text.chars().count();\n            let txt = self.expected_split[idx..end].concat();\n            idx = end;\n            let encoded_text = htmlescape::encode_minimal(&txt);\n            let class = token.to_class();\n            acc.push_str(&format!(\"<span class={class}>{encoded_text}</span>\"));\n            acc\n        })\n    }\n}\n\n// Utility Items\n#[derive(Debug, PartialEq, Eq)]\nstruct DiffTokens {\n    typed_tokens: Vec<DiffToken>,\n    expected_tokens: Vec<DiffToken>,\n}\n\n#[derive(Debug, PartialEq, Eq)]\nenum DiffTokenKind {\n    Good,\n    Bad,\n    Missing,\n}\n\n#[derive(Debug, PartialEq, Eq)]\nstruct DiffToken {\n    kind: DiffTokenKind,\n    text: String,\n}\n\nimpl DiffToken {\n    fn new(kind: DiffTokenKind, text: String) -> Self {\n        Self { kind, text }\n    }\n    fn good(text: String) -> Self {\n        Self::new(DiffTokenKind::Good, text)\n    }\n    fn bad(text: String) -> Self {\n        Self::new(DiffTokenKind::Bad, text)\n    }\n    fn missing(text: String) -> Self {\n        Self::new(DiffTokenKind::Missing, text)\n    }\n    fn to_class(&self) -> &'static str {\n        match self.kind {\n            DiffTokenKind::Good => \"typeGood\",\n            DiffTokenKind::Bad => \"typeBad\",\n            DiffTokenKind::Missing => \"typeMissed\",\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    macro_rules! token_factory {\n        ($name:ident) => {\n            fn $name(text: &str) -> DiffToken {\n                DiffToken::$name(String::from(text))\n            }\n        };\n    }\n    token_factory!(bad);\n    token_factory!(good);\n    token_factory!(missing);\n\n    #[test]\n    fn tokens() {\n        let ctx = Diff::new(\"¿Y ahora qué vamos a hacer?\", \"y ahora qe vamosa hacer\");\n        let output = ctx.to_tokens();\n        assert_eq!(\n            output.typed_tokens,\n            vec![\n                bad(\"y\"),\n                good(\" ahora q\"),\n                bad(\"e\"),\n                good(\" vamos\"),\n                missing(\"-\"),\n                good(\"a hacer\"),\n                missing(\"-\"),\n            ]\n        );\n        assert_eq!(\n            output.expected_tokens,\n            vec![\n                missing(\"¿Y\"),\n                good(\" ahora q\"),\n                missing(\"ué\"),\n                good(\" vamos\"),\n                missing(\" \"),\n                good(\"a hacer\"),\n                missing(\"?\"),\n            ]\n        );\n    }\n\n    #[test]\n    fn html_and_media() {\n        let stripped = strip_expected(\"[sound:foo.mp3]<b>1</b> &nbsp;2\");\n        let ctx = Diff::new(&stripped, \"1  2\");\n        // the spacing is handled by wrapping html output in white-space: pre-wrap\n        assert_eq!(ctx.to_tokens().expected_tokens, &[good(\"1  2\")]);\n    }\n\n    #[test]\n    fn missed_chars_only_shown_in_typed_when_after_good() {\n        let ctx = Diff::new(\"1\", \"23\");\n        assert_eq!(ctx.to_tokens().typed_tokens, &[bad(\"23\")]);\n        let ctx = Diff::new(\"12\", \"1\");\n        assert_eq!(ctx.to_tokens().typed_tokens, &[good(\"1\"), missing(\"-\"),]);\n    }\n\n    #[test]\n    fn missed_chars_counted_correctly() {\n        let ctx = Diff::new(\"нос\", \"нс\");\n        assert_eq!(\n            ctx.to_tokens().typed_tokens,\n            &[good(\"н\"), missing(\"-\"), good(\"с\")]\n        );\n    }\n\n    #[test]\n    fn handles_certain_unicode_as_expected() {\n        // this was not parsed as expected with dissimilar 1.0.4\n        let ctx = Diff::new(\"쓰다듬다\", \"스다뜸다\");\n        assert_eq!(\n            ctx.to_tokens().typed_tokens,\n            &[bad(\"스\"), good(\"다\"), bad(\"뜸\"), good(\"다\"),]\n        );\n    }\n\n    #[test]\n    fn does_not_panic_with_certain_unicode() {\n        // this was causing a panic with dissimilar 1.0.4\n        let ctx = Diff::new(\n            \"Сущность должна быть ответственна только за одно дело\",\n            concat!(\n                \"Single responsibility Сущность выполняет только одну задачу.\",\n                \"Повод для изменения сущности только один.\"\n            ),\n        );\n        ctx.to_tokens();\n    }\n\n    #[test]\n    fn tags_removed() {\n        let stripped = strip_expected(\"<div>123</div>\");\n        assert_eq!(stripped, \"123\");\n        assert_eq!(\n            Diff::new(&stripped, \"123\").to_html(),\n            \"<code id=typeans><span class=typeGood>123</span></code>\"\n        );\n    }\n\n    #[test]\n    fn empty_input_shows_as_code() {\n        let ctx = compare_answer(\"<div>123</div>\", \"\", true);\n        assert_eq!(ctx, \"<code id=typeans>123</code>\");\n    }\n\n    #[test]\n    fn correct_input_is_escaped() {\n        let ctx = Diff::new(\"source <dir>/bin/activate\", \"source <dir>/bin/activate\");\n        assert_eq!(\n            ctx.to_html(),\n            \"<code id=typeans><span class=typeGood>source &lt;dir&gt;/bin/activate</span></code>\"\n        );\n    }\n\n    #[test]\n    fn correct_input_is_collapsed() {\n        let ctx = Diff::new(\"123\", \"123\");\n        assert_eq!(\n            ctx.to_html(),\n            \"<code id=typeans><span class=typeGood>123</span></code>\"\n        );\n    }\n\n    #[test]\n    fn incorrect_input_is_not_collapsed() {\n        let ctx = Diff::new(\"123\", \"1123\");\n        assert_eq!(\n            ctx.to_html(),\n            \"<code id=typeans><span class=typeBad>1</span><span class=typeGood>123</span><br><span id=typearrow>&darr;</span><br><span class=typeGood>123</span></code>\"\n        );\n    }\n\n    #[test]\n    fn noncombining_comparison() {\n        assert_eq!(\n            compare_answer(\"שִׁנּוּן\", \"שנון\", false),\n            \"<code id=typeans><span class=typeGood>שִׁנּוּן</span></code>\"\n        );\n        assert_eq!(\n            compare_answer(\"חוֹף\", \"חופ\", false),\n            \"<code id=typeans><span class=typeGood>חו</span><span class=typeBad>פ</span><br><span id=typearrow>&darr;</span><br><span class=typeGood>חוֹ</span><span class=typeMissed>ף</span></code>\"\n        );\n        assert_eq!(\n            compare_answer(\"ば\", \"は\", false),\n            \"<code id=typeans><span class=typeGood>ば</span></code>\"\n        );\n    }\n}\n"
  },
  {
    "path": "rslib/src/types.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n#[macro_export]\nmacro_rules! define_newtype {\n    ( $name:ident, $type:ident ) => {\n        #[repr(transparent)]\n        #[derive(\n            Debug,\n            Default,\n            Clone,\n            Copy,\n            PartialEq,\n            Eq,\n            PartialOrd,\n            Ord,\n            Hash,\n            serde::Serialize,\n            serde::Deserialize,\n        )]\n        pub struct $name(pub $type);\n\n        impl std::fmt::Display for $name {\n            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n                self.0.fmt(f)\n            }\n        }\n\n        impl std::str::FromStr for $name {\n            type Err = std::num::ParseIntError;\n            fn from_str(s: &std::primitive::str) -> std::result::Result<Self, Self::Err> {\n                $type::from_str(s).map($name)\n            }\n        }\n\n        impl rusqlite::types::FromSql for $name {\n            fn column_result(\n                value: rusqlite::types::ValueRef<'_>,\n            ) -> std::result::Result<Self, rusqlite::types::FromSqlError> {\n                if let rusqlite::types::ValueRef::Integer(i) = value {\n                    Ok(Self(i as $type))\n                } else {\n                    Err(rusqlite::types::FromSqlError::InvalidType)\n                }\n            }\n        }\n\n        impl rusqlite::ToSql for $name {\n            fn to_sql(&self) -> ::rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {\n                Ok(rusqlite::types::ToSqlOutput::Owned(\n                    rusqlite::types::Value::Integer(self.0 as i64),\n                ))\n            }\n        }\n\n        impl From<$type> for $name {\n            fn from(t: $type) -> $name {\n                $name(t)\n            }\n        }\n\n        impl From<$name> for $type {\n            fn from(n: $name) -> $type {\n                n.0\n            }\n        }\n    };\n}\n\ndefine_newtype!(Usn, i32);\n\npub(crate) trait IntoNewtypeVec {\n    fn into_newtype<F, T>(self, func: F) -> Vec<T>\n    where\n        F: FnMut(i64) -> T;\n}\n\nimpl IntoNewtypeVec for Vec<i64> {\n    fn into_newtype<F, T>(self, func: F) -> Vec<T>\n    where\n        F: FnMut(i64) -> T,\n    {\n        self.into_iter().map(func).collect()\n    }\n}\n"
  },
  {
    "path": "rslib/src/undo/changes.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse crate::card::undo::UndoableCardChange;\nuse crate::collection::undo::UndoableCollectionChange;\nuse crate::config::undo::UndoableConfigChange;\nuse crate::deckconfig::undo::UndoableDeckConfigChange;\nuse crate::decks::undo::UndoableDeckChange;\nuse crate::notes::undo::UndoableNoteChange;\nuse crate::notetype::undo::UndoableNotetypeChange;\nuse crate::prelude::*;\nuse crate::revlog::undo::UndoableRevlogChange;\nuse crate::scheduler::queue::undo::UndoableQueueChange;\nuse crate::tags::undo::UndoableTagChange;\n\n#[derive(Debug)]\npub(crate) enum UndoableChange {\n    Card(UndoableCardChange),\n    Note(UndoableNoteChange),\n    Deck(UndoableDeckChange),\n    DeckConfig(UndoableDeckConfigChange),\n    Tag(UndoableTagChange),\n    Revlog(UndoableRevlogChange),\n    Queue(UndoableQueueChange),\n    Config(UndoableConfigChange),\n    Collection(UndoableCollectionChange),\n    Notetype(UndoableNotetypeChange),\n}\n\nimpl UndoableChange {\n    pub(super) fn undo(self, col: &mut Collection) -> Result<()> {\n        match self {\n            UndoableChange::Card(c) => col.undo_card_change(c),\n            UndoableChange::Note(c) => col.undo_note_change(c),\n            UndoableChange::Deck(c) => col.undo_deck_change(c),\n            UndoableChange::Tag(c) => col.undo_tag_change(c),\n            UndoableChange::Revlog(c) => col.undo_revlog_change(c),\n            UndoableChange::Queue(c) => col.undo_queue_change(c),\n            UndoableChange::Config(c) => col.undo_config_change(c),\n            UndoableChange::DeckConfig(c) => col.undo_deck_config_change(c),\n            UndoableChange::Collection(c) => col.undo_collection_change(c),\n            UndoableChange::Notetype(c) => col.undo_notetype_change(c),\n        }\n    }\n}\n\nimpl From<UndoableCardChange> for UndoableChange {\n    fn from(c: UndoableCardChange) -> Self {\n        UndoableChange::Card(c)\n    }\n}\n\nimpl From<UndoableNoteChange> for UndoableChange {\n    fn from(c: UndoableNoteChange) -> Self {\n        UndoableChange::Note(c)\n    }\n}\n\nimpl From<UndoableDeckChange> for UndoableChange {\n    fn from(c: UndoableDeckChange) -> Self {\n        UndoableChange::Deck(c)\n    }\n}\n\nimpl From<UndoableDeckConfigChange> for UndoableChange {\n    fn from(c: UndoableDeckConfigChange) -> Self {\n        UndoableChange::DeckConfig(c)\n    }\n}\n\nimpl From<UndoableTagChange> for UndoableChange {\n    fn from(c: UndoableTagChange) -> Self {\n        UndoableChange::Tag(c)\n    }\n}\n\nimpl From<UndoableRevlogChange> for UndoableChange {\n    fn from(c: UndoableRevlogChange) -> Self {\n        UndoableChange::Revlog(c)\n    }\n}\n\nimpl From<UndoableQueueChange> for UndoableChange {\n    fn from(c: UndoableQueueChange) -> Self {\n        UndoableChange::Queue(c)\n    }\n}\n\nimpl From<UndoableConfigChange> for UndoableChange {\n    fn from(c: UndoableConfigChange) -> Self {\n        UndoableChange::Config(c)\n    }\n}\n\nimpl From<UndoableCollectionChange> for UndoableChange {\n    fn from(c: UndoableCollectionChange) -> Self {\n        UndoableChange::Collection(c)\n    }\n}\n\nimpl From<UndoableNotetypeChange> for UndoableChange {\n    fn from(c: UndoableNotetypeChange) -> Self {\n        UndoableChange::Notetype(c)\n    }\n}\n"
  },
  {
    "path": "rslib/src/undo/mod.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nmod changes;\n\nuse std::collections::VecDeque;\n\npub(crate) use changes::UndoableChange;\n\npub use crate::ops::Op;\nuse crate::ops::OpChanges;\nuse crate::ops::StateChanges;\nuse crate::prelude::*;\n\nconst UNDO_LIMIT: usize = 30;\n\n#[derive(Debug)]\npub(crate) struct UndoableOp {\n    pub kind: Op,\n    pub timestamp: TimestampSecs,\n    pub changes: Vec<UndoableChange>,\n    pub counter: usize,\n}\n\nimpl UndoableOp {\n    /// True if changes non-empty, or a custom undo step.\n    fn has_changes(&self) -> bool {\n        !self.changes.is_empty() || matches!(self.kind, Op::Custom(_))\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Default)]\nenum UndoMode {\n    #[default]\n    NormalOp,\n    Undoing,\n    Redoing,\n}\n\npub struct UndoStatus {\n    pub undo: Option<Op>,\n    pub redo: Option<Op>,\n    pub last_step: usize,\n}\n\npub struct UndoOutput {\n    pub undone_op: Op,\n    pub reverted_to: TimestampSecs,\n    pub new_undo_status: UndoStatus,\n    pub counter: usize,\n}\n\n#[derive(Debug, Default)]\npub(crate) struct UndoManager {\n    // undo steps are added to the front of a double-ended queue, so we can\n    // efficiently cap the number of steps we retain in memory\n    undo_steps: VecDeque<UndoableOp>,\n    // redo steps are added to the end\n    redo_steps: Vec<UndoableOp>,\n    mode: UndoMode,\n    current_step: Option<UndoableOp>,\n    counter: usize,\n}\n\nimpl UndoManager {\n    fn save(&mut self, item: UndoableChange) {\n        if let Some(step) = self.current_step.as_mut() {\n            step.changes.push(item)\n        }\n    }\n\n    fn begin_step(&mut self, op: Option<Op>) {\n        if op.is_none() {\n            self.undo_steps.clear();\n            self.redo_steps.clear();\n        } else if self.mode == UndoMode::NormalOp {\n            // a normal op clears the redo queue\n            self.redo_steps.clear();\n        }\n        self.current_step = op.map(|op| UndoableOp {\n            kind: op,\n            timestamp: TimestampSecs::now(),\n            changes: vec![],\n            counter: {\n                self.counter += 1;\n                self.counter\n            },\n        });\n    }\n\n    fn end_step(&mut self, skip_undo: bool) {\n        if let Some(step) = self.current_step.take() {\n            if step.has_changes() && !skip_undo {\n                if self.mode == UndoMode::Undoing {\n                    self.redo_steps.push(step);\n                } else {\n                    self.undo_steps.truncate(UNDO_LIMIT - 1);\n                    self.undo_steps.push_front(step);\n                }\n            }\n        }\n    }\n\n    fn can_undo(&self) -> Option<&Op> {\n        self.undo_steps.front().map(|s| &s.kind)\n    }\n\n    fn can_redo(&self) -> Option<&Op> {\n        self.redo_steps.last().map(|s| &s.kind)\n    }\n\n    fn previous_op(&self) -> Option<&UndoableOp> {\n        self.undo_steps.front()\n    }\n\n    fn current_op(&self) -> Option<&UndoableOp> {\n        self.current_step.as_ref()\n    }\n\n    fn op_changes(&self) -> OpChanges {\n        let current_op = self\n            .current_step\n            .as_ref()\n            .expect(\"current_changes() called when no op set\");\n\n        let changes = StateChanges::from(&current_op.changes[..]);\n        OpChanges {\n            op: current_op.kind.clone(),\n            changes,\n        }\n    }\n\n    fn merge_undoable_ops(&mut self, starting_from: usize) -> Result<OpChanges> {\n        let target_idx = self\n            .undo_steps\n            .iter()\n            .enumerate()\n            .filter_map(|(idx, op)| {\n                if op.counter == starting_from {\n                    Some(idx)\n                } else {\n                    None\n                }\n            })\n            .next()\n            .or_invalid(\"target undo op not found\")?;\n        let mut removed = vec![];\n        for _ in 0..target_idx {\n            removed.push(self.undo_steps.pop_front().unwrap());\n        }\n        let target = self.undo_steps.front_mut().unwrap();\n        for step in removed.into_iter().rev() {\n            target.changes.extend(step.changes.into_iter());\n        }\n        self.counter = starting_from;\n        Ok(OpChanges {\n            op: target.kind.clone(),\n            changes: StateChanges::from(&target.changes[..]),\n        })\n    }\n\n    /// Start a new step with a custom name, and return its associated\n    /// counter value, which can be used with `merge_undoable_ops`.\n    fn add_custom_step(&mut self, name: String) -> usize {\n        self.begin_step(Some(Op::Custom(name)));\n        self.end_step(false);\n        self.counter\n    }\n}\n\nimpl Collection {\n    pub fn can_undo(&self) -> Option<&Op> {\n        self.state.undo.can_undo()\n    }\n\n    pub fn can_redo(&self) -> Option<&Op> {\n        self.state.undo.can_redo()\n    }\n\n    pub fn undo(&mut self) -> Result<OpOutput<UndoOutput>> {\n        if let Some(step) = self.state.undo.undo_steps.pop_front() {\n            self.undo_inner(step, UndoMode::Undoing)\n        } else {\n            Err(AnkiError::UndoEmpty)\n        }\n    }\n    pub fn redo(&mut self) -> Result<OpOutput<UndoOutput>> {\n        if let Some(step) = self.state.undo.redo_steps.pop() {\n            self.undo_inner(step, UndoMode::Redoing)\n        } else {\n            Err(AnkiError::UndoEmpty)\n        }\n    }\n\n    pub fn undo_status(&self) -> UndoStatus {\n        UndoStatus {\n            undo: self.can_undo().cloned(),\n            redo: self.can_redo().cloned(),\n            last_step: self.state.undo.counter,\n        }\n    }\n\n    /// Merge multiple undoable operations into one, and return the union of\n    /// their changes.\n    pub fn merge_undoable_ops(&mut self, starting_from: usize) -> Result<OpChanges> {\n        self.state.undo.merge_undoable_ops(starting_from)\n    }\n\n    /// Add an empty custom undo step, which subsequent changes can be merged\n    /// into.\n    pub fn add_custom_undo_step(&mut self, name: String) -> usize {\n        self.state.undo.add_custom_step(name)\n    }\n}\n\nimpl Collection {\n    /// If op is None, clears the undo/redo queues.\n    pub(crate) fn begin_undoable_operation(&mut self, op: Option<Op>) {\n        self.state.undo.begin_step(op);\n    }\n\n    /// Called at the end of a successful transaction.\n    /// In most instances, this will also clear the study queues.\n    pub(crate) fn end_undoable_operation(&mut self, skip_undo: bool) {\n        self.state.undo.end_step(skip_undo);\n    }\n\n    pub(crate) fn discard_undo_and_study_queues(&mut self) {\n        self.state.undo.begin_step(None);\n        self.clear_study_queues();\n    }\n\n    pub(crate) fn update_state_after_dbproxy_modification(&mut self) {\n        self.discard_undo_and_study_queues();\n        self.state.modified_by_dbproxy = true;\n    }\n\n    #[inline]\n    pub(crate) fn save_undo(&mut self, item: impl Into<UndoableChange>) {\n        self.state.undo.save(item.into());\n    }\n\n    pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> {\n        self.state.undo.current_op()\n    }\n\n    pub(crate) fn previous_undo_op(&self) -> Option<&UndoableOp> {\n        self.state.undo.previous_op()\n    }\n\n    pub(crate) fn undoing_or_redoing(&self) -> bool {\n        self.state.undo.mode != UndoMode::NormalOp\n    }\n\n    pub(crate) fn current_undo_step_has_changes(&self) -> bool {\n        self.state\n            .undo\n            .current_op()\n            .map(|op| op.has_changes())\n            .unwrap_or_default()\n    }\n\n    /// Used for coalescing successive note updates.\n    pub(crate) fn clear_last_op(&mut self) {\n        self.state\n            .undo\n            .current_step\n            .as_mut()\n            .expect(\"no operation active\")\n            .changes\n            .clear()\n    }\n\n    /// Return changes made by the current op. Must only be called in a\n    /// transaction, when an operation was passed to transact().\n    pub(crate) fn op_changes(&self) -> OpChanges {\n        self.state.undo.op_changes()\n    }\n\n    fn undo_inner(&mut self, step: UndoableOp, mode: UndoMode) -> Result<OpOutput<UndoOutput>> {\n        let undone_op = step.kind;\n        let reverted_to = step.timestamp;\n        let changes = step.changes;\n        let counter = step.counter;\n        self.state.undo.mode = mode;\n        let res = self.transact(undone_op.clone(), |col| {\n            for change in changes.into_iter().rev() {\n                change.undo(col)?;\n            }\n            Ok(UndoOutput {\n                undone_op,\n                reverted_to,\n                new_undo_status: col.undo_status(),\n                counter,\n            })\n        });\n        self.state.undo.mode = UndoMode::NormalOp;\n        res\n    }\n}\n\nimpl From<&[UndoableChange]> for StateChanges {\n    fn from(changes: &[UndoableChange]) -> Self {\n        let mut out = StateChanges::default();\n        if !changes.is_empty() {\n            out.mtime = true;\n        }\n        for change in changes {\n            match change {\n                UndoableChange::Card(_) => out.card = true,\n                UndoableChange::Note(_) => out.note = true,\n                UndoableChange::Deck(_) => out.deck = true,\n                UndoableChange::Tag(_) => out.tag = true,\n                UndoableChange::Revlog(_) => {}\n                UndoableChange::Queue(_) => {}\n                UndoableChange::Config(_) => out.config = true,\n                UndoableChange::DeckConfig(_) => out.deck_config = true,\n                UndoableChange::Collection(_) => {}\n                UndoableChange::Notetype(_) => out.notetype = true,\n            }\n        }\n        out\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::UndoableChange;\n    use crate::card::Card;\n    use crate::prelude::*;\n\n    #[test]\n    fn undo() -> Result<()> {\n        let mut col = Collection::new();\n\n        let mut card = Card {\n            interval: 1,\n            ..Default::default()\n        };\n        col.add_card(&mut card).unwrap();\n        let cid = card.id;\n\n        assert_eq!(col.can_undo(), None);\n        assert_eq!(col.can_redo(), None);\n\n        // outside of a transaction, no undo info recorded\n        let card = col\n            .get_and_update_card(cid, |card| {\n                card.interval = 2;\n                Ok(())\n            })\n            .unwrap();\n        assert_eq!(card.interval, 2);\n        assert_eq!(col.can_undo(), None);\n        assert_eq!(col.can_redo(), None);\n\n        // record a few undo steps\n        for i in 3..=4 {\n            col.transact(Op::UpdateCard, |col| {\n                col.get_and_update_card(cid, |card| {\n                    card.interval = i;\n                    Ok(())\n                })\n                .unwrap();\n                Ok(())\n            })\n            .unwrap();\n        }\n\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), None);\n\n        // undo a step\n        col.undo().unwrap();\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), Some(&Op::UpdateCard));\n\n        // and again\n        col.undo().unwrap();\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 2);\n        assert_eq!(col.can_undo(), None);\n        assert_eq!(col.can_redo(), Some(&Op::UpdateCard));\n\n        // redo a step\n        col.redo().unwrap();\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), Some(&Op::UpdateCard));\n\n        // and another\n        col.redo().unwrap();\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), None);\n\n        // and undo the redo\n        col.undo().unwrap();\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), Some(&Op::UpdateCard));\n\n        // if any action is performed, it should clear the redo queue\n        col.transact(Op::UpdateCard, |col| {\n            col.get_and_update_card(cid, |card| {\n                card.interval = 5;\n                Ok(())\n            })\n        })?;\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 5);\n        assert_eq!(col.can_undo(), Some(&Op::UpdateCard));\n        assert_eq!(col.can_redo(), None);\n\n        // and any action that doesn't support undoing will clear both queues\n        col.transact_no_undo(|_col| Ok(())).unwrap();\n        assert_eq!(col.can_undo(), None);\n        assert_eq!(col.can_redo(), None);\n\n        // if an object is mutated multiple times in one operation,\n        // the changes should be undone in the correct order\n        col.transact(Op::UpdateCard, |col| {\n            col.get_and_update_card(cid, |card| {\n                card.interval = 10;\n                Ok(())\n            })?;\n            col.get_and_update_card(cid, |card| {\n                card.interval = 15;\n                Ok(())\n            })\n        })?;\n\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 15);\n        col.undo()?;\n        assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 5);\n\n        Ok(())\n    }\n\n    #[test]\n    fn custom() -> Result<()> {\n        let mut col = Collection::new();\n\n        // perform some actions in separate steps\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n        assert_eq!(col.undo_status().last_step, 1);\n\n        let card = col.storage.all_cards_of_note(note.id)?.remove(0);\n\n        col.transact(Op::UpdateCard, |col| {\n            col.get_and_update_card(card.id, |card| {\n                card.due = 10;\n                Ok(())\n            })\n        })?;\n\n        let restore_point = col.add_custom_undo_step(\"hello\".to_string());\n\n        col.transact(Op::UpdateCard, |col| {\n            col.get_and_update_card(card.id, |card| {\n                card.due = 20;\n                Ok(())\n            })\n        })?;\n        col.transact(Op::UpdateCard, |col| {\n            col.get_and_update_card(card.id, |card| {\n                card.due = 30;\n                Ok(())\n            })\n        })?;\n        // dummy op name\n        col.transact(Op::Bury, |col| col.set_current_notetype_id(NotetypeId(123)))?;\n\n        // merge subsequent changes into our restore point\n        let op = col.merge_undoable_ops(restore_point)?;\n        assert!(op.changes.card);\n        assert!(op.changes.config);\n\n        // the last undo action should be at the end of the step list,\n        // before the modtime bump\n        assert!(matches!(\n            col.state\n                .undo\n                .previous_op()\n                .unwrap()\n                .changes\n                .iter()\n                .rev()\n                .nth(1)\n                .unwrap(),\n            UndoableChange::Config(_)\n        ));\n\n        // if we then undo, we'll be back to before step 3\n        assert_eq!(col.storage.get_card(card.id)?.unwrap().due, 30);\n        col.undo()?;\n        assert_eq!(col.storage.get_card(card.id)?.unwrap().due, 10);\n\n        Ok(())\n    }\n\n    #[test]\n    fn undo_mtime_bump() -> Result<()> {\n        let mut col = Collection::new();\n        col.storage.db.execute_batch(\"update col set mod = 0\")?;\n\n        // a no-op change should not bump mtime\n        let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, true, true)?;\n        assert_eq!(\n            col.storage.get_collection_timestamps()?.collection_change.0,\n            0\n        );\n        assert!(!out.changes.had_change());\n\n        // if there is an undoable step, mtime should change\n        let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, false, true)?;\n        assert_ne!(\n            col.storage.get_collection_timestamps()?.collection_change.0,\n            0\n        );\n        assert!(out.changes.had_change());\n\n        // when skipping undo, mtime should still only be bumped on a change\n        col.storage.db.execute_batch(\"update col set mod = 0\")?;\n        let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, false, false)?;\n        assert_eq!(\n            col.storage.get_collection_timestamps()?.collection_change.0,\n            0\n        );\n        assert!(!out.changes.had_change());\n\n        // op output will reflect changes were made\n        let out = col.set_config_bool(BoolKey::AddingDefaultsToCurrentDeck, true, false)?;\n        assert_ne!(\n            col.storage.get_collection_timestamps()?.collection_change.0,\n            0\n        );\n        assert!(out.changes.had_change());\n\n        Ok(())\n    }\n\n    #[test]\n    fn coalesce_note_undo_entries() -> Result<()> {\n        let mut col = Collection::new();\n        let nt = col.get_notetype_by_name(\"Basic\")?.unwrap();\n        let mut note = nt.new_note();\n        col.add_note(&mut note, DeckId(1))?;\n        note.set_field(0, \"foo\")?;\n        col.update_note(&mut note)?;\n        note.set_field(0, \"bar\")?;\n        col.update_note(&mut note)?;\n        assert_eq!(col.state.undo.undo_steps.len(), 2);\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rslib/src/version.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::env;\nuse std::sync::LazyLock;\n\npub fn version() -> &'static str {\n    include_str!(\"../../.version\").trim()\n}\n\npub fn buildhash() -> &'static str {\n    option_env!(\"BUILDHASH\").unwrap_or(\"dev\").trim()\n}\n\npub(crate) fn sync_client_version() -> &'static str {\n    static VER: LazyLock<String> = LazyLock::new(|| {\n        format!(\n            \"anki,{version} ({buildhash}),{platform}\",\n            version = version(),\n            buildhash = buildhash(),\n            platform = env::var(\"PLATFORM\").unwrap_or_else(|_| env::consts::OS.to_string())\n        )\n    });\n    &VER\n}\n\npub(crate) fn sync_client_version_short() -> &'static str {\n    static VER: LazyLock<String> = LazyLock::new(|| {\n        format!(\n            \"{version},{buildhash},{platform}\",\n            version = version(),\n            buildhash = buildhash(),\n            platform = env::consts::OS\n        )\n    });\n    &VER\n}\n"
  },
  {
    "path": "rslib/sync/Cargo.toml",
    "content": "[package]\nname = \"anki-sync-server\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\ndescription = \"Standalone sync server\"\n\n[[bin]]\npath = \"main.rs\"\nname = \"anki-sync-server\"\n\n[dependencies]\n\n[target.'cfg(windows)'.dependencies]\nanki = { workspace = true, features = [\"native-tls\"] }\n\n[target.'cfg(not(windows))'.dependencies]\nanki = { workspace = true, features = [\"rustls\"] }\n"
  },
  {
    "path": "rslib/sync/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nuse std::env;\nuse std::process;\n\nuse anki::log::set_global_logger;\nuse anki::sync::http_server::SimpleServer;\n\nfn main() {\n    if let Some(arg) = env::args().nth(1) {\n        if arg == \"--healthcheck\" {\n            run_health_check();\n            return;\n        }\n    }\n    if env::var(\"RUST_LOG\").is_err() {\n        env::set_var(\"RUST_LOG\", \"anki=info\")\n    }\n    set_global_logger(None).unwrap();\n    println!(\"{}\", SimpleServer::run());\n}\n\nfn run_health_check() {\n    if SimpleServer::is_running() {\n        process::exit(0);\n    } else {\n        process::exit(1);\n    }\n}\n"
  },
  {
    "path": "run",
    "content": "#!/bin/bash\n\nset -e\n\nexport PYTHONWARNINGS=default\nexport PYTHONPYCACHEPREFIX=out/pycache\n# define these as blank before calling the script if you want to disable them\nexport ANKIDEV=${ANKIDEV-1}\nexport QTWEBENGINE_REMOTE_DEBUGGING=${QTWEBENGINE_REMOTE_DEBUGGING-8080}\nexport QTWEBENGINE_CHROMIUM_FLAGS=${QTWEBENGINE_CHROMIUM_FLAGS---remote-allow-origins=http://localhost:$QTWEBENGINE_REMOTE_DEBUGGING}\nexport PYENV=${PYENV-out/pyenv}\n\n# The pages can be accessed by, e.g. surfing to\n# http://localhost:40000/_anki/pages/deckconfig.html\n# Useful in conjunction with tools/web-watch for auto-rebuilding.\nexport ANKI_API_PORT=${ANKI_API_PORT-40000}\nexport ANKI_API_HOST=${ANKI_API_HOST-127.0.0.1}\n\n./ninja pylib qt\n${PYENV}/bin/python tools/run.py $*\n"
  },
  {
    "path": "run.bat",
    "content": "@echo off\npushd \"%~dp0\"\n\nset PYTHONWARNINGS=default\nset PYTHONPYCACHEPREFIX=out\\pycache\nset ANKIDEV=1\nset QTWEBENGINE_REMOTE_DEBUGGING=8080\nset QTWEBENGINE_CHROMIUM_FLAGS=--remote-allow-origins=http://localhost:8080\nset ANKI_API_PORT=40000\nset ANKI_API_HOST=127.0.0.1\n\n@if not defined PYENV set PYENV=out\\pyenv\n  \ncall tools\\ninja pylib qt || exit /b 1\n%PYENV%\\Scripts\\python tools\\run.py %* || exit /b 1\npopd\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\n# older versions may fail to compile; newer versions may fail the clippy tests\nchannel = \"1.92.0\"\n"
  },
  {
    "path": "tools/build",
    "content": "#!/bin/bash\n\nset -eo pipefail\n\nrm -rf out/wheels/*\nRELEASE=2 ./ninja wheels\n(cd qt/release && ./build.sh)\necho \"wheels are in out/wheels\"\n"
  },
  {
    "path": "tools/build-arm-lin",
    "content": "#!/bin/bash\n\nset -e\n\n# sudo apt install libc6-dev-arm64-cross gcc-aarch64-linux-gnu\nrustup target add aarch64-unknown-linux-gnu\n\nexport CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc\nexport LIN_ARM64=1\n\nRELEASE=2 ./ninja wheels:anki\necho \"wheels are in out/wheels\"\n"
  },
  {
    "path": "tools/build-x64-mac",
    "content": "#!/bin/bash\n\nset -e\n\nrustup target add x86_64-apple-darwin\n\nexport MAC_X86=1\n\nRELEASE=2 ./ninja wheels:anki\necho \"wheels are in out/wheels\"\n"
  },
  {
    "path": "tools/build.bat",
    "content": "@echo off\npushd \"%~dp0\"\\..\nif exist out\\wheels rmdir /s /q out\\wheels\nset RELEASE=2\ntools\\ninja wheels || exit /b 1\necho wheels are in out/wheels\npopd\n"
  },
  {
    "path": "tools/clean",
    "content": "#!/bin/bash\n#\n# Remove most things from the build folder, to test a clean build. Keeps\n# the download folder, and optionally node_modules/pyenv.\n\nset -e\nshopt -s extglob\n\nif [ \"$1\" == \"keep-env\" ]; then\n    rm -rf out/!(node_modules|pyenv|download)\nelse\n    rm -rf out/!(download)\nfi\n"
  },
  {
    "path": "tools/dmypy",
    "content": "#!/bin/bash\n#\n# Run mypy in daemon mode for fast checking\n\n./ninja pylib qt\nMYPY_CACHE_DIR=out/tests/mypy out/pyenv/bin/dmypy run pylib/anki qt/aqt pylib/tests\n"
  },
  {
    "path": "tools/install-n2",
    "content": "#!/bin/bash\n\ncargo install --git https://github.com/evmar/n2.git --rev 53ec691df749277104d1d4201a344fe4243d6d0a\n"
  },
  {
    "path": "tools/minilints/Cargo.toml",
    "content": "[package]\nname = \"minilints\"\nversion.workspace = true\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanki_io.workspace = true\nanki_process.workspace = true\nanyhow.workspace = true\ncamino.workspace = true\nserde_json.workspace = true\nwalkdir.workspace = true\nwhich.workspace = true\n"
  },
  {
    "path": "tools/minilints/src/main.rs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nuse std::cell::LazyCell;\nuse std::collections::BTreeMap;\nuse std::collections::HashSet;\nuse std::env;\nuse std::fs;\nuse std::fs::File;\nuse std::io::Read;\nuse std::io::Write;\nuse std::path::Path;\nuse std::process::Command;\n\nuse anki_io::read_to_string;\nuse anki_io::write_file;\nuse anki_process::CommandExt;\nuse anyhow::Context;\nuse anyhow::Result;\nuse camino::Utf8Path;\nuse walkdir::WalkDir;\n\nconst NONSTANDARD_HEADER: &[&str] = &[\n    \"./pylib/anki/_vendor/stringcase.py\",\n    \"./pylib/anki/statsbg.py\",\n    \"./qt/aqt/mpv.py\",\n    \"./qt/aqt/winpaths.py\",\n];\n\nconst IGNORED_FOLDERS: &[&str] = &[\n    \"./out\",\n    \"./node_modules\",\n    \"./qt/aqt/forms\",\n    \"./tools/workspace-hack\",\n    \"./target\",\n    \".mypy_cache\",\n    \"./extra\",\n    \"./ts/.svelte-kit\",\n];\n\nfn main() -> Result<()> {\n    let mut args = env::args();\n    let want_fix = args.nth(1) == Some(\"fix\".to_string());\n    let stamp = args.next().unwrap();\n    let mut ctx = LintContext::new(want_fix);\n    ctx.check_contributors()?;\n    ctx.check_rust_licenses()?;\n    ctx.walk_folders(Path::new(\".\"))?;\n    if ctx.found_problems {\n        std::process::exit(1);\n    }\n    write_file(stamp, \"\")?;\n\n    Ok(())\n}\n\nstruct LintContext {\n    want_fix: bool,\n    unstaged_changes: LazyCell<()>,\n    found_problems: bool,\n    nonstandard_headers: HashSet<&'static Utf8Path>,\n}\n\nimpl LintContext {\n    pub fn new(want_fix: bool) -> Self {\n        Self {\n            want_fix,\n            unstaged_changes: LazyCell::new(check_for_unstaged_changes),\n            found_problems: false,\n            nonstandard_headers: NONSTANDARD_HEADER.iter().map(Utf8Path::new).collect(),\n        }\n    }\n\n    pub fn walk_folders(&mut self, root: &Path) -> Result<()> {\n        let ignored_folders: HashSet<_> = IGNORED_FOLDERS.iter().map(Utf8Path::new).collect();\n        let walker = WalkDir::new(root).into_iter();\n        for entry in walker.filter_entry(|e| {\n            !ignored_folders.contains(&Utf8Path::from_path(e.path()).expect(\"utf8\"))\n        }) {\n            let entry = entry.unwrap();\n            let path = Utf8Path::from_path(entry.path()).context(\"utf8\")?;\n\n            let exts: HashSet<_> = [\"py\", \"ts\", \"rs\", \"svelte\", \"mjs\"]\n                .into_iter()\n                .map(Some)\n                .collect();\n            if exts.contains(&path.extension()) && !sveltekit_temp_file(path.as_str()) {\n                self.check_copyright(path)?;\n                self.check_triple_slash(path)?;\n            }\n        }\n        Ok(())\n    }\n\n    fn check_copyright(&mut self, path: &Utf8Path) -> Result<()> {\n        if path.file_name().unwrap().ends_with(\".d.ts\") {\n            return Ok(());\n        }\n        let head = head_of_file(path)?;\n        if head.is_empty() {\n            return Ok(());\n        }\n        if self.nonstandard_headers.contains(&path) {\n            return Ok(());\n        }\n        let missing = !head.contains(\"Ankitects Pty Ltd and contributors\");\n        if missing {\n            if self.want_fix {\n                LazyCell::force(&self.unstaged_changes);\n                fix_copyright(path)?;\n            } else {\n                println!(\"missing standard copyright header: {path:?}\");\n                self.found_problems = true;\n            }\n        }\n        Ok(())\n    }\n\n    fn check_triple_slash(&mut self, path: &Utf8Path) -> Result<()> {\n        if !matches!(path.extension(), Some(\"ts\") | Some(\"svelte\")) {\n            return Ok(());\n        }\n        for line in fs::read_to_string(path)?.lines() {\n            if line.contains(\"///\") && !line.contains(\"/// <reference\") {\n                println!(\"not a docstring: {path}: {line}\");\n                self.found_problems = true;\n            }\n        }\n        Ok(())\n    }\n\n    fn check_contributors(&self) -> Result<()> {\n        let antispam = \", at the domain \";\n\n        let last_author = String::from_utf8(\n            Command::new(\"git\")\n                .args([\"log\", \"-1\", \"--pretty=format:%ae\"])\n                .output()?\n                .stdout,\n        )?;\n\n        if last_author == \"49699333+dependabot[bot]@users.noreply.github.com\" {\n            println!(\"Dependabot whitelisted.\");\n            std::process::exit(0);\n        }\n\n        if let Ok(bypass) = std::env::var(\"CONTRIBUTORS_BYPASS_EMAILS\") {\n            if bypass.split(',').any(|e| e.trim() == last_author) {\n                println!(\"Author allowlisted via CONTRIBUTORS_BYPASS_EMAILS.\");\n                return Ok(());\n            }\n        }\n\n        // Parse identifiers from the CONTRIBUTORS file instead of relying\n        // on git history, which requires a full clone. Entries may contain an\n        // email (user@example.com) or a GitHub profile URL (github.com/user).\n        let contents = fs::read_to_string(\"CONTRIBUTORS\")?;\n        let all_contributors: HashSet<&str> = contents\n            .lines()\n            .filter_map(|line| {\n                let start = line.find('<')?;\n                let end = line.find('>')?;\n                Some(&line[start + 1..end])\n            })\n            .collect();\n\n        if all_contributors.contains(last_author.as_str()) {\n            return Ok(());\n        }\n\n        // Match GitHub noreply emails (ID+user@users.noreply.github.com)\n        // against CONTRIBUTORS entries like github.com/user or\n        // https://github.com/user.\n        if let Some(username) = last_author\n            .strip_suffix(\"@users.noreply.github.com\")\n            .and_then(|s| s.rsplit_once('+'))\n            .map(|(_, user)| user)\n        {\n            let gh_entry = format!(\"github.com/{username}\");\n            if all_contributors.iter().any(|c| {\n                let normalized = c\n                    .trim_end_matches('/')\n                    .trim_start_matches(\"https://\")\n                    .trim_start_matches(\"http://\");\n                normalized.eq_ignore_ascii_case(&gh_entry)\n            }) {\n                return Ok(());\n            }\n        }\n\n        println!(\"All contributors:\");\n        println!(\"{}\", {\n            let mut contribs: Vec<_> = all_contributors\n                .iter()\n                .map(|s| s.replace('@', antispam))\n                .collect();\n            contribs.sort();\n            contribs.join(\"\\n\")\n        });\n\n        println!(\n            \"Author {} NOT found in list\",\n            last_author.replace('@', antispam)\n        );\n\n        println!(\n            \"\\nPlease make sure you modify the CONTRIBUTORS file using the email address you \\\n                are committing from. If you have GitHub configured to hide your email address, \\\n                you may need to make a change to the CONTRIBUTORS file using the GitHub UI, \\\n                then try again.\"\n        );\n\n        std::process::exit(1);\n    }\n\n    fn check_rust_licenses(&mut self) -> Result<()> {\n        let license_path = Path::new(\"cargo/licenses.json\");\n        let licenses = generate_licences()?;\n        let existing_licenses = read_to_string(license_path)?;\n        if licenses != existing_licenses {\n            if self.want_fix {\n                check_cargo_deny()?;\n                write_file(license_path, licenses)?;\n            } else {\n                println!(\"cargo/licenses.json is out of date; run ./ninja fix:minilints\");\n                self.found_problems = true;\n            }\n        }\n        Ok(())\n    }\n}\n\n/// Annoyingly, sveltekit writes temp files into ts/ folder when it's running.\nfn sveltekit_temp_file(path: &str) -> bool {\n    path.contains(\"vite.config.ts.timestamp\")\n}\n\nfn check_cargo_deny() -> Result<()> {\n    // Used by `fix:minilints` locally. CI uses EmbarkStudios/cargo-deny-action.\n    Command::run(\"cargo install cargo-deny@0.19.0\")?;\n    Command::run(\"cargo deny check\")?;\n    Ok(())\n}\n\nfn head_of_file(path: &Utf8Path) -> Result<String> {\n    let mut file = File::open(path)?;\n    let mut buffer = vec![0; 256];\n    let size = file.read(&mut buffer)?;\n    buffer.truncate(size);\n    Ok(String::from_utf8(buffer).unwrap_or_default())\n}\n\nfn fix_copyright(path: &Utf8Path) -> Result<()> {\n    let header = match path.extension().unwrap() {\n        \"py\" => {\n            r#\"# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\"#\n        }\n        \"ts\" | \"rs\" | \"mjs\" => {\n            r#\"// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\"#\n        }\n        \"svelte\" => {\n            r#\"<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n\"#\n        }\n        _ => unreachable!(),\n    };\n\n    let data = fs::read_to_string(path).with_context(|| format!(\"reading {path}\"))?;\n    let mut file = fs::OpenOptions::new()\n        .write(true)\n        .open(path)\n        .with_context(|| format!(\"opening {path}\"))?;\n    write!(file, \"{header}{data}\").with_context(|| format!(\"writing {path}\"))?;\n    Ok(())\n}\n\nfn check_for_unstaged_changes() {\n    let output = Command::new(\"git\").arg(\"diff\").output().unwrap();\n    if !output.stdout.is_empty() {\n        println!(\"stage any changes first\");\n        std::process::exit(1);\n    }\n}\n\nfn generate_licences() -> Result<String> {\n    Command::run(\"cargo install cargo-license@0.7.0\")?;\n    let output = Command::run_with_output([\n        \"cargo-license\",\n        \"--features\",\n        \"rustls\",\n        \"--features\",\n        \"native-tls\",\n        \"--json\",\n        \"--manifest-path\",\n        \"rslib/Cargo.toml\",\n    ])?;\n\n    let licenses: Vec<BTreeMap<String, serde_json::Value>> = serde_json::from_str(&output.stdout)?;\n\n    let filtered: Vec<BTreeMap<String, serde_json::Value>> = licenses\n        .into_iter()\n        .map(|mut entry| {\n            entry.remove(\"version\");\n            entry\n        })\n        .collect();\n\n    Ok(serde_json::to_string_pretty(&filtered)?)\n}\n"
  },
  {
    "path": "tools/ninja.bat",
    "content": "@echo off\nset CARGO_TARGET_DIR=%~dp0..\\out\\rust\nREM separate build+run steps so build env doesn't leak into subprocesses\ncargo build -p runner --release || exit /b 1\nout\\rust\\release\\runner build %* || exit /b 1\n"
  },
  {
    "path": "tools/profile",
    "content": "#!/bin/bash\n\nANKI_PROFILE_CODE=1 ./run\nout/pyenv/bin/pip install snakeviz\nout/pyenv/bin/snakeviz out/anki.prof\n"
  },
  {
    "path": "tools/publish",
    "content": "#!/bin/bash\n\nset -e\nshopt -s extglob\n\n#export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test)\n#out/extracted/uv/uv publish --index testpypi out/wheels/*\n\nexport UV_PUBLISH_TOKEN=$(pass show w/pypi-api)\n\n# Upload all wheels except anki_release*.whl first\nout/extracted/uv/uv publish out/wheels/!(anki_release*).whl\n# Then upload anki_release*.whl\nout/extracted/uv/uv publish out/wheels/anki_release*.whl\n"
  },
  {
    "path": "tools/rebuild-web",
    "content": "#!/bin/bash\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# Manually trigger a rebuild and reload of Anki's web stack\n\n# NOTE: This script needs to be run from the project root\n\nset -e\n\n./ninja qt\n./out/pyenv/bin/python tools/reload_webviews.py\n"
  },
  {
    "path": "tools/reload_webviews.py",
    "content": "#!/usr/bin/env python\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n\"\"\"\nTrigger a reload of Anki's web views using QtWebEngine' Chromium\nRemote Debugging interface.\n\"\"\"\n\nimport argparse\nimport sys\n\nimport PyChromeDevTools  # type: ignore[import]\n\nDEFAULT_HOST = \"localhost\"\nDEFAULT_PORT = 8080\n\n\ndef print_error(message: str):\n    print(f\"Error: {message}\", file=sys.stderr)\n\n\nparser = argparse.ArgumentParser(\"reload_webviews\")\nparser.add_argument(\n    \"--host\",\n    help=f\"Host via which the Chrome session can be reached, e.g. {DEFAULT_HOST}\",\n    type=str,\n    default=DEFAULT_HOST,\n    required=False,\n)\nparser.add_argument(\n    \"--port\",\n    help=f\"Port via which the Chrome session can be reached, e.g. {DEFAULT_PORT}\",\n    type=str,\n    default=DEFAULT_PORT,\n    required=False,\n)\nargs = parser.parse_args()\n\ntry:\n    chrome = PyChromeDevTools.ChromeInterface(host=args.host, port=args.port)\nexcept Exception as e:\n    print_error(\n        f\"Could not establish connection to Chromium remote debugger. Is Anki Open? Exception:\\n{e}\"\n    )\n    sys.exit(1)\n\nif chrome.tabs is None:\n    print_error(\"Was unable to get active web views.\")\n    sys.exit(1)\n\nfor tab_index, tab_data in enumerate(chrome.tabs):\n    print(f\"Reloading page: {tab_data['title']}\")\n    chrome.connect(tab=tab_index, update_tabs=False)\n    chrome.Page.reload()\n"
  },
  {
    "path": "tools/run-qt6.6",
    "content": "#!/bin/bash\n\nset -e\n\n./ninja extract:uv\n\nexport PYENV=./out/pyenv66\nUV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt66\n./run $*\n"
  },
  {
    "path": "tools/run-qt6.7",
    "content": "#!/bin/bash\n\nset -e\n\n./ninja extract:uv\n\nexport PYENV=./out/pyenv67\nUV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt67\n./run $*\n"
  },
  {
    "path": "tools/run-qt6.8",
    "content": "#!/bin/bash\n\nset -e\n\n./ninja extract:uv\n\nexport PYENV=./out/pyenv68\nUV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt68\n./run $*\n"
  },
  {
    "path": "tools/run.py",
    "content": "# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport os\nimport sys\n\nsys.path.extend([\"pylib\", \"qt\", \"out/pylib\", \"out/qt\"])\n\nimport aqt\n\nif not os.environ.get(\"SKIP_RUN\"):\n    aqt.run()\n"
  },
  {
    "path": "tools/runopt",
    "content": "#!/bin/bash\n\nset -e\n\nRELEASE=1 $(dirname $0)/../run $*\n"
  },
  {
    "path": "tools/unused-rust-deps",
    "content": "#!/bin/bash\n\ncargo install cargo-udeps@0.1.40\ncargo +nightly-2023-01-24-x86_64-unknown-linux-gnu udeps --all-targets\n"
  },
  {
    "path": "tools/update-launcher-env",
    "content": "#!/bin/bash\n#\n# Install our latest anki/aqt code into the launcher venv\n\nset -e\n\nrm -rf out/wheels\n./ninja wheels\nif [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n    export VIRTUAL_ENV=$HOME/Library/Application\\ Support/AnkiProgramFiles/.venv\nelse\n    export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv\nfi\n./out/extracted/uv/uv pip install out/wheels/*\n \n"
  },
  {
    "path": "tools/update-launcher-env.bat",
    "content": "@echo off\nrem\nrem Install our latest anki/aqt code into the launcher venv\n\nrmdir /s /q out\\wheels 2>nul\ncall tools\\ninja wheels\nset VIRTUAL_ENV=%LOCALAPPDATA%\\AnkiProgramFiles\\.venv\nfor %%f in (out\\wheels\\*.whl) do out\\extracted\\uv\\uv pip install \"%%f\""
  },
  {
    "path": "tools/web-watch",
    "content": "#!/bin/bash\n# Copyright: Ankitects Pty Ltd and contributors\n# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n# Monitor all web-related folders and rebuild and reload Anki's web stack\n# when a change is detected.\n\nset -e\n\nMONITORED_FOLDERS=(\"ts/\" \"sass/\" \"qt/aqt/data/web/\")\nMONITORED_EVENTS=(\"Created\" \"Updated\" \"Removed\")\n\non_change_detected=\"clear; ./tools/rebuild-web; echo Rebuilt at $(date +%H:%M:%S)\"\n\nevent_args=\"\"\nfor event in \"${MONITORED_EVENTS[@]}\"; do\n    event_args+=\"--event ${event} \"\ndone\n\nbash -c \"$on_change_detected\"\n\n# poll_monitor comes with a slight performance penalty, but seems to more\n# reliably identify file system events across both macOS and Linux\nfswatch -r -o -m poll_monitor ${event_args[@]} \\\n    \"${MONITORED_FOLDERS[@]}\" | xargs -I{} bash -c \"$on_change_detected\"\n"
  },
  {
    "path": "ts/.gitignore",
    "content": "node_modules\nyarn-error.log\n"
  },
  {
    "path": "ts/README.md",
    "content": "Anki's TypeScript and Sass dependencies. Some TS/JS code is also\nstored separately in ../qt/aqt/data/web/.\n\nTo update all dependencies:\n\n./update.sh\n\nTo add a new dev dependency, use something like:\n\n./add.sh -D @rollup/plugin-alias\n"
  },
  {
    "path": "ts/bundle_svelte.mjs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { build } from \"esbuild\";\nimport { sassPlugin } from \"esbuild-sass-plugin\";\nimport sveltePlugin from \"esbuild-svelte\";\nimport { readFileSync, writeFileSync } from \"fs\";\nimport { basename } from \"path\";\nimport { argv, env } from \"process\";\nimport sveltePreprocess from \"svelte-preprocess\";\nimport { typescript } from \"svelte-preprocess-esbuild\";\n\nconst [_tsx, _script, entrypoint, bundle_js, bundle_css, page_html] = argv;\n\nif (page_html != null) {\n    const template = readFileSync(\"ts/page.html\", { encoding: \"utf8\" });\n    writeFileSync(page_html, template.replace(/{PAGE}/g, basename(page_html, \".html\")));\n}\n\n// support Qt 5.14\nconst target = [\"es2020\", \"chrome77\"];\nconst inlineCss = bundle_css == null;\nconst sourcemap = env.SOURCEMAP && true;\nlet sveltePlugins;\n\nif (!sourcemap) {\n    sveltePlugins = [\n        // use esbuild for faster typescript transpilation\n        typescript({\n            target,\n            define: {\n                \"process.browser\": \"true\",\n            },\n            tsconfig: \"ts/tsconfig_legacy.json\",\n        }),\n        sveltePreprocess({ typescript: false }),\n    ];\n} else {\n    sveltePlugins = [\n        // use tsc for more accurate sourcemaps\n        sveltePreprocess({ typescript: true, sourceMap: true }),\n    ];\n}\n\nbuild({\n    bundle: true,\n    entryPoints: [entrypoint],\n    globalName: \"anki\",\n    outfile: bundle_js,\n    minify: env.RELEASE && true,\n    loader: { \".svg\": \"text\" },\n    preserveSymlinks: true,\n    sourcemap: sourcemap ? \"inline\" : false,\n    plugins: [\n        sassPlugin({ loadPaths: [\"node_modules\"] }),\n        sveltePlugin({\n            compilerOptions: { css: inlineCss ? \"injected\" : \"external\" },\n            preprocess: sveltePlugins,\n            // let us focus on errors; we can see the warnings with svelte-check\n            filterWarnings: (_warning) => false,\n        }),\n    ],\n    target,\n    // logLevel: \"info\",\n}).catch(() => process.exit(1));\n"
  },
  {
    "path": "ts/bundle_ts.mjs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { build } from \"esbuild\";\nimport { argv, env } from \"process\";\n\nconst [_node, _script, entrypoint, bundle_js] = argv;\n\n// support Qt 5.14\nconst target = [\"es6\", \"chrome77\"];\n\nbuild({\n    bundle: true,\n    entryPoints: [entrypoint],\n    outfile: bundle_js,\n    minify: env.RELEASE && true,\n    sourcemap: env.SOURCEMAP ? \"inline\" : false,\n    preserveSymlinks: true,\n    target,\n}).catch(() => process.exit(1));\n"
  },
  {
    "path": "ts/editable/ContentEditable.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    export type { ContentEditableAPI } from \"./content-editable\";\n</script>\n\n<script lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n\n    import { updateAllState } from \"$lib/components/WithState.svelte\";\n    import actionList from \"$lib/sveltelib/action-list\";\n    import type { MirrorAction } from \"$lib/sveltelib/dom-mirror\";\n    import type { SetupInputHandlerAction } from \"$lib/sveltelib/input-handler\";\n\n    import type { ContentEditableAPI } from \"./content-editable\";\n    import {\n        fixRTLKeyboardNav,\n        preventBuiltinShortcuts,\n        useFocusHandler,\n    } from \"./content-editable\";\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    export let resolve: (editable: HTMLElement) => void;\n\n    export let mirrors: MirrorAction[];\n    export let nodes: Writable<DocumentFragment>;\n\n    const mirrorAction = actionList(mirrors);\n    const mirrorOptions = { store: nodes };\n\n    export let inputHandlers: SetupInputHandlerAction[];\n\n    const inputHandlerAction = actionList(inputHandlers);\n\n    export let api: Partial<ContentEditableAPI>;\n\n    const [focusHandler, setupFocusHandling] = useFocusHandler();\n\n    Object.assign(api, { focusHandler });\n</script>\n\n<anki-editable\n    class:nightMode={$pageTheme.isDark}\n    contenteditable=\"true\"\n    role=\"textbox\"\n    tabindex=\"0\"\n    use:resolve\n    use:setupFocusHandling\n    use:preventBuiltinShortcuts\n    use:fixRTLKeyboardNav\n    use:mirrorAction={mirrorOptions}\n    use:inputHandlerAction={{}}\n    on:focus\n    on:blur\n    on:click={updateAllState}\n    on:keyup={updateAllState}\n></anki-editable>\n\n<style lang=\"scss\">\n    anki-editable {\n        display: block;\n        position: relative;\n\n        overflow: auto;\n        overflow-wrap: anywhere;\n        /* fallback for iOS */\n        word-break: break-word;\n\n        &:focus {\n            outline: none;\n        }\n\n        min-height: 1.5em;\n    }\n\n    /* editable-base.scss contains styling targeting user HTML */\n</style>\n"
  },
  {
    "path": "ts/editable/Mathjax.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n    import { LRUCache } from \"lru-cache\";\n\n    const imageToHeightMap = new Map<string, Writable<number>>();\n    const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {\n        for (const entry of entries) {\n            const image = entry.target as HTMLImageElement;\n            const store = imageToHeightMap.get(image.dataset.uuid!)!;\n            store.set(entry.contentRect.height);\n\n            setTimeout(() => entry.target.dispatchEvent(new Event(\"resize\")));\n        }\n    });\n\n    type Cache = LRUCache<string, [string, string]>;\n\n    const caches: { [key: string]: Cache } = {};\n\n    function getCache(...keyParts: any) {\n        const key = keyParts.toString(); // primitive parts or arrays only\n        if (!(key in caches)) {\n            caches[key] = new LRUCache({ max: 10 });\n        }\n        return caches[key];\n    }\n</script>\n\n<script lang=\"ts\">\n    import { onDestroy } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import { convertMathjax, unescapeSomeEntities } from \"./mathjax\";\n    import { CooldownTimer } from \"./cooldown-timer\";\n\n    export let mathjax: string;\n    export let block: boolean;\n    export let fontSize: number;\n\n    let converted: string, title: string;\n\n    const debouncer = new CooldownTimer(500);\n\n    $: debouncer.schedule(() => {\n        const cache = getCache($pageTheme.isDark, fontSize);\n        const entry = cache.get(mathjax);\n        if (entry) {\n            [converted, title] = entry;\n        } else {\n            const entry = convertMathjax(\n                unescapeSomeEntities(mathjax),\n                $pageTheme.isDark,\n                fontSize,\n            );\n            [converted, title] = entry;\n            cache.set(mathjax, entry);\n        }\n    });\n    $: empty = title === \"MathJax\";\n    $: encoded = encodeURIComponent(converted);\n\n    const uuid = crypto.randomUUID();\n    const imageHeight = writable(0);\n    imageToHeightMap.set(uuid, imageHeight);\n\n    $: verticalCenter = -$imageHeight / 2 + fontSize / 4;\n\n    let image: HTMLImageElement;\n\n    export function moveCaretAfter(position?: [number, number]): void {\n        // This should trigger a focusing of the Mathjax Handle\n        image.dispatchEvent(\n            new CustomEvent(\"movecaretafter\", {\n                detail: { image, position },\n                bubbles: true,\n                composed: true,\n            }),\n        );\n    }\n\n    export function selectAll(): void {\n        image.dispatchEvent(\n            new CustomEvent(\"selectall\", {\n                detail: image,\n                bubbles: true,\n                composed: true,\n            }),\n        );\n    }\n\n    function observe(image: Element) {\n        observer.observe(image);\n\n        return {\n            destroy() {\n                observer.unobserve(image);\n            },\n        };\n    }\n\n    onDestroy(() => imageToHeightMap.delete(uuid));\n</script>\n\n<img\n    bind:this={image}\n    src=\"data:image/svg+xml,{encoded}\"\n    class:block\n    class:empty\n    class=\"mathjax\"\n    style:--vertical-center=\"{verticalCenter}px\"\n    style:--font-size=\"{fontSize}px\"\n    alt=\"Mathjax\"\n    {title}\n    data-anki=\"mathjax\"\n    data-uuid={uuid}\n    on:dragstart|preventDefault\n    use:observe\n/>\n\n<style lang=\"scss\">\n    :global(anki-mathjax) {\n        white-space: pre;\n    }\n\n    img {\n        vertical-align: var(--vertical-center);\n    }\n\n    .block {\n        display: block;\n        margin: 1rem auto;\n        transform: scale(1.1);\n    }\n\n    .empty {\n        vertical-align: text-bottom;\n\n        width: var(--font-size);\n        height: var(--font-size);\n    }\n</style>\n"
  },
  {
    "path": "ts/editable/change-timer.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport class ChangeTimer {\n    private value: number | null = null;\n    private action: (() => void) | null = null;\n\n    constructor() {\n        this.fireImmediately = this.fireImmediately.bind(this);\n    }\n\n    schedule(action: () => void, delay: number): void {\n        this.clear();\n        this.action = action;\n        this.value = setTimeout(this.fireImmediately, delay) as any;\n    }\n\n    clear(): void {\n        if (this.value) {\n            clearTimeout(this.value);\n            this.value = null;\n        }\n    }\n\n    fireImmediately(): void {\n        if (this.action) {\n            this.action();\n            this.action = null;\n        }\n\n        this.clear();\n    }\n}\n"
  },
  {
    "path": "ts/editable/content-editable.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { bridgeCommand } from \"@tslib/bridgecommand\";\nimport { getSelection } from \"@tslib/cross-browser\";\nimport { on, preventDefault } from \"@tslib/events\";\nimport { isApplePlatform } from \"@tslib/platform\";\nimport { registerShortcut } from \"@tslib/shortcuts\";\nimport type { Callback } from \"@tslib/typing\";\n\nimport type { SelectionLocation } from \"$lib/domlib/location\";\nimport { restoreSelection, saveSelection } from \"$lib/domlib/location\";\nimport { placeCaretAfterContent } from \"$lib/domlib/place-caret\";\nimport { HandlerList } from \"$lib/sveltelib/handler-list\";\n\n/**\n * Workaround: If you try to invoke an IME after calling\n * `placeCaretAfterContent` on a cE element, the IME will immediately\n * end and the input character will be duplicated\n */\nfunction safePlaceCaretAfterContent(editable: HTMLElement): void {\n    placeCaretAfterContent(editable);\n    restoreSelection(editable, saveSelection(editable)!);\n}\n\nfunction restoreCaret(element: HTMLElement, location: SelectionLocation | null): void {\n    if (!location) {\n        return safePlaceCaretAfterContent(element);\n    }\n\n    try {\n        restoreSelection(element, location);\n    } catch {\n        safePlaceCaretAfterContent(element);\n    }\n}\n\ntype SetupFocusHandlerAction = (element: HTMLElement) => { destroy(): void };\n\nexport interface FocusHandlerAPI {\n    /**\n     * Prevent the automatic caret restoration, that happens upon field focus\n     */\n    flushCaret(): void;\n    /**\n     * Executed upon focus event of editable.\n     */\n    focus: HandlerList<{ event: FocusEvent }>;\n    /**\n     * Executed upon blur event of editable.\n     */\n    blur: HandlerList<{ event: FocusEvent }>;\n}\n\nexport function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {\n    let latestLocation: SelectionLocation | null = null;\n    let offFocus: Callback | null;\n    let offPointerDown: Callback | null;\n    let flush = false;\n\n    function flushCaret(): void {\n        flush = true;\n    }\n\n    const focus = new HandlerList<{ event: FocusEvent }>();\n    const blur = new HandlerList<{ event: FocusEvent }>();\n\n    function prepareFocusHandling(\n        editable: HTMLElement,\n        location: SelectionLocation | null = null,\n    ): void {\n        latestLocation = location;\n\n        offFocus?.();\n        offFocus = on(\n            editable,\n            \"focus\",\n            (event: FocusEvent): void => {\n                if (flush) {\n                    flush = false;\n                } else {\n                    restoreCaret(event.currentTarget as HTMLElement, latestLocation);\n                }\n\n                focus.dispatch({ event });\n            },\n            { once: true },\n        );\n\n        offPointerDown?.();\n        offPointerDown = on(\n            editable,\n            \"pointerdown\",\n            () => {\n                offFocus?.();\n                offFocus = null;\n            },\n            { once: true },\n        );\n    }\n\n    /**\n     * Must execute before DOMMirror.\n     */\n    function onBlur(this: HTMLElement, event: FocusEvent): void {\n        prepareFocusHandling(this, saveSelection(this));\n        blur.dispatch({ event });\n    }\n\n    function setupFocusHandler(editable: HTMLElement): { destroy(): void } {\n        prepareFocusHandling(editable);\n        const off = on(editable, \"blur\", onBlur);\n\n        return {\n            destroy() {\n                off();\n                offFocus?.();\n                offPointerDown?.();\n            },\n        };\n    }\n\n    return [\n        {\n            flushCaret,\n            focus,\n            blur,\n        },\n        setupFocusHandler,\n    ];\n}\n\nif (isApplePlatform()) {\n    registerShortcut(() => bridgeCommand(\"paste\"), \"Control+Shift+V\");\n}\n\nexport function preventBuiltinShortcuts(editable: HTMLElement): void {\n    for (const keyCombination of [\"Control+B\", \"Control+U\", \"Control+I\"]) {\n        registerShortcut(preventDefault, keyCombination, { target: editable });\n    }\n}\n\ndeclare global {\n    interface Selection {\n        modify(s: string, t: string, u: string): void;\n    }\n}\n\n// Fix inverted Ctrl+right/left handling in RTL fields\nexport function fixRTLKeyboardNav(editable: HTMLElement): void {\n    editable.addEventListener(\"keydown\", (evt: KeyboardEvent) => {\n        if (window.getComputedStyle(editable).direction === \"rtl\") {\n            const selection = getSelection(editable)!;\n            let granularity = \"character\";\n            let alter = \"move\";\n            if (evt.ctrlKey) {\n                granularity = \"word\";\n            }\n            if (evt.shiftKey) {\n                alter = \"extend\";\n            }\n            if (evt.code === \"ArrowRight\") {\n                selection.modify(alter, \"right\", granularity);\n                evt.preventDefault();\n                return;\n            } else if (evt.code === \"ArrowLeft\") {\n                selection.modify(alter, \"left\", granularity);\n                evt.preventDefault();\n                return;\n            }\n        }\n    });\n}\n\n/** API */\n\nexport interface ContentEditableAPI {\n    /**\n     * Can be used to turn off the caret restoring functionality of\n     * the ContentEditable. Can be used when you want to set the caret\n     * yourself.\n     */\n    focusHandler: FocusHandlerAPI;\n}\n"
  },
  {
    "path": "ts/editable/cooldown-timer.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport class CooldownTimer {\n    private executing = false;\n    private queuedAction: (() => void) | null = null;\n    private delay: number;\n\n    constructor(delayMs: number) {\n        this.delay = delayMs;\n    }\n\n    schedule(action: () => void): void {\n        if (this.executing) {\n            this.queuedAction = action;\n        } else {\n            this.executing = true;\n            action();\n            setTimeout(this.#pop.bind(this), this.delay);\n        }\n    }\n\n    #pop(): void {\n        this.executing = false;\n        if (this.queuedAction) {\n            const action = this.queuedAction;\n            this.queuedAction = null;\n            this.schedule(action);\n        }\n    }\n}\n"
  },
  {
    "path": "ts/editable/decorated.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * decorated elements know three states:\n * - stored, which is stored in the DB, e.g. `\\(\\alpha + \\beta\\)`\n * - undecorated, which is displayed to the user in Codable, e.g. `<anki-mathjax>\\alpha + \\beta</anki-mathjax>`\n * - decorated, which is displayed to the user in Editable, e.g. `<anki-mathjax data-mathjax=\"\\alpha + \\beta\"><img src=\"data:...\"></anki-mathjax>`\n */\n\nexport interface DecoratedElement extends HTMLElement {\n    /**\n     * Transforms itself from undecorated to decorated state.\n     * Should be called in connectedCallback.\n     */\n    decorate(): void;\n    /**\n     * Transforms itself from decorated to undecorated state.\n     */\n    undecorate(): void;\n}\n\ninterface WithTagName {\n    tagName: string;\n}\n\nexport interface DecoratedElementConstructor extends CustomElementConstructor, WithTagName {\n    prototype: DecoratedElement;\n    /**\n     * Transforms elements in input HTML from undecorated to stored state.\n     */\n    toStored(undecorated: string): string;\n    /**\n     * Transforms elements in input HTML from stored to undecorated state.\n     */\n    toUndecorated(stored: string): string;\n}\n\nexport class CustomElementArray extends Array<DecoratedElementConstructor> {\n    push(...elements: DecoratedElementConstructor[]): number {\n        for (const element of elements) {\n            customElements.define(element.tagName, element);\n        }\n        return super.push(...elements);\n    }\n\n    /**\n     * Transforms any decorated elements in input HTML from undecorated to stored state.\n     */\n    toStored(html: string): string {\n        let result = html;\n\n        for (const element of this) {\n            result = element.toStored(result);\n        }\n\n        return result;\n    }\n\n    /**\n     * Transforms any decorated elements in input HTML from stored to undecorated state.\n     */\n    toUndecorated(html: string): string {\n        let result = html;\n\n        for (const element of this) {\n            result = element.toUndecorated(result);\n        }\n\n        return result;\n    }\n}\n"
  },
  {
    "path": "ts/editable/editable-base.scss",
    "content": "@use \"../lib/sass/scrollbar\";\n\n* {\n    max-width: 100%;\n}\n\np {\n    margin-top: 0;\n    margin-bottom: 1rem;\n\n    &:empty::after {\n        content: \"\\a\";\n        white-space: pre;\n    }\n}\n\n[hidden] {\n    display: none;\n}\n\n:host(body),\n:host(body) * {\n    @include scrollbar.custom;\n}\n\npre {\n    white-space: pre-wrap;\n}\n\n// image size constraints\nimg:not(.mathjax) {\n    &:not([data-editor-shrink=\"false\"]) {\n        :host-context(.shrink-image) & {\n            max-width: var(--editor-default-max-width);\n            max-height: var(--editor-default-max-height);\n            // prevent inline width/height from skewing aspect ratio\n            width: unset;\n            height: unset;\n        }\n    }\n\n    &[data-editor-shrink=\"true\"] {\n        max-width: var(--editor-shrink-max-width);\n        max-height: var(--editor-shrink-max-height);\n    }\n}\n"
  },
  {
    "path": "ts/editable/frame-element.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getSelection, isSelectionCollapsed } from \"@tslib/cross-browser\";\nimport { elementIsBlock, hasBlockAttribute, nodeIsElement, nodeIsText } from \"@tslib/dom\";\nimport { on } from \"@tslib/events\";\n\nimport { moveChildOutOfElement } from \"$lib/domlib/move-nodes\";\nimport { placeCaretAfter, placeCaretBefore } from \"$lib/domlib/place-caret\";\n\nimport type { FrameHandle } from \"./frame-handle\";\nimport { checkHandles, frameElementTagName, FrameEnd, FrameStart, isFrameHandle } from \"./frame-handle\";\n\nfunction restoreFrameHandles(mutations: MutationRecord[]): void {\n    let referenceNode: Node | null = null;\n\n    for (const mutation of mutations) {\n        const frameElement = mutation.target as FrameElement;\n        const framed = frameElement.querySelector(frameElement.frames!) as HTMLElement;\n\n        if (!framed) {\n            frameElement.remove();\n            continue;\n        }\n\n        for (const node of mutation.addedNodes) {\n            if (node === framed || isFrameHandle(node)) {\n                continue;\n            }\n\n            // In some rare cases, nodes might be inserted into the frame itself.\n            // For example after using execCommand.\n            const placement = framed.compareDocumentPosition(node);\n\n            if (placement & Node.DOCUMENT_POSITION_PRECEDING) {\n                referenceNode = moveChildOutOfElement(\n                    frameElement,\n                    node,\n                    \"beforebegin\",\n                );\n            } else if (placement & Node.DOCUMENT_POSITION_FOLLOWING) {\n                referenceNode = moveChildOutOfElement(frameElement, node, \"afterend\");\n            }\n        }\n\n        for (const node of mutation.removedNodes) {\n            if (!isFrameHandle(node)) {\n                continue;\n            }\n\n            if (\n                /* avoid triggering when (un)mounting whole frame */\n                mutations.length === 1\n                && !node.partiallySelected\n            ) {\n                // Similar to a \"movein\", this could be considered a\n                // \"deletein\" event and could get some special treatment, e.g.\n                // first highlight the entire frame-element.\n                frameElement.remove();\n                continue;\n            }\n\n            if (frameElement.isConnected) {\n                frameElement.refreshHandles();\n                continue;\n            }\n        }\n    }\n\n    if (referenceNode) {\n        placeCaretAfter(referenceNode);\n    }\n}\n\nconst frameObserver = new MutationObserver(restoreFrameHandles);\nconst frameElements = new Set<FrameElement>();\n\nexport class FrameElement extends HTMLElement {\n    static tagName = frameElementTagName;\n\n    static get observedAttributes(): string[] {\n        return [\"data-frames\", \"block\"];\n    }\n\n    get framedElement(): HTMLElement | null {\n        return this.frames ? this.querySelector(this.frames) : null;\n    }\n\n    frames?: string;\n    block: boolean;\n\n    handleStart?: FrameStart;\n    handleEnd?: FrameEnd;\n\n    constructor() {\n        super();\n        this.block = hasBlockAttribute(this);\n        frameObserver.observe(this, { childList: true });\n    }\n\n    attributeChangedCallback(name: string, old: string, newValue: string): void {\n        if (newValue === old) {\n            return;\n        }\n\n        switch (name) {\n            case \"data-frames\":\n                this.frames = newValue;\n\n                if (!this.framedElement) {\n                    this.remove();\n                    return;\n                }\n                break;\n\n            case \"block\":\n                this.block = newValue !== \"false\";\n                this.refreshHandles();\n                break;\n        }\n    }\n\n    getHandleFrom(node: Element | null, start: boolean): FrameHandle {\n        const handle = isFrameHandle(node)\n            ? node\n            : (document.createElement(\n                start ? FrameStart.tagName : FrameEnd.tagName,\n            ) as FrameHandle);\n\n        handle.dataset.frames = this.frames;\n\n        return handle;\n    }\n\n    refreshHandles(): void {\n        customElements.upgrade(this);\n\n        this.handleStart = this.getHandleFrom(this.firstElementChild, true);\n        this.handleEnd = this.getHandleFrom(this.lastElementChild, false);\n\n        if (!this.handleStart.isConnected) {\n            this.prepend(this.handleStart);\n        }\n\n        if (!this.handleEnd.isConnected) {\n            this.append(this.handleEnd);\n        }\n    }\n\n    removeStart?: () => void;\n    removeEnd?: () => void;\n\n    addEventListeners(): void {\n        this.removeStart = on(\n            this,\n            \"moveinstart\" as keyof HTMLElementEventMap,\n            () => this.framedElement?.dispatchEvent(new Event(\"moveinstart\")),\n        );\n\n        this.removeEnd = on(\n            this,\n            \"moveinend\" as keyof HTMLElementEventMap,\n            () => this.framedElement?.dispatchEvent(new Event(\"moveinend\")),\n        );\n    }\n\n    removeEventListeners(): void {\n        this.removeStart?.();\n        this.removeStart = undefined;\n\n        this.removeEnd?.();\n        this.removeEnd = undefined;\n    }\n\n    connectedCallback(): void {\n        frameElements.add(this);\n        this.addEventListeners();\n    }\n\n    disconnectedCallback(): void {\n        frameElements.delete(this);\n        this.removeEventListeners();\n    }\n\n    insertLineBreak(offset: number): void {\n        const lineBreak = document.createElement(\"br\");\n\n        if (offset === 0) {\n            const previous = this.previousSibling;\n            const focus = previous\n                    && (nodeIsText(previous)\n                        || (nodeIsElement(previous) && !elementIsBlock(previous)))\n                ? previous\n                : this.insertAdjacentElement(\n                    \"beforebegin\",\n                    document.createElement(\"br\"),\n                );\n\n            placeCaretAfter(focus ?? this);\n        } else if (offset === 1) {\n            const next = this.nextSibling;\n\n            const focus = next\n                    && (nodeIsText(next) || (nodeIsElement(next) && !elementIsBlock(next)))\n                ? next\n                : this.insertAdjacentElement(\"afterend\", lineBreak);\n\n            placeCaretBefore(focus ?? this);\n        }\n    }\n}\n\nfunction checkIfInsertingLineBreakAdjacentToBlockFrame() {\n    for (const frame of frameElements) {\n        if (!frame.block) {\n            continue;\n        }\n\n        const selection = getSelection(frame)!;\n\n        if (\n            selection.anchorNode === frame.framedElement\n            && isSelectionCollapsed(selection)\n        ) {\n            frame.insertLineBreak(selection.anchorOffset);\n        }\n    }\n}\n\nfunction onSelectionChange() {\n    checkHandles();\n    checkIfInsertingLineBreakAdjacentToBlockFrame();\n}\n\ndocument.addEventListener(\"selectionchange\", onSelectionChange);\n\n/**\n * This function wraps an element into a \"frame\", which looks like this:\n * <anki-frame>\n *     <frame-handle-start> </frame-handle-start>\n *     <your-element ... />\n *     <frame-handle-end> </frame-handle-end>\n * </anki-frame>\n */\nexport function frameElement(element: HTMLElement, block: boolean): FrameElement {\n    const frame = document.createElement(FrameElement.tagName) as FrameElement;\n    frame.setAttribute(\"block\", String(block));\n    frame.dataset.frames = element.tagName.toLowerCase();\n\n    const range = new Range();\n    range.selectNode(element);\n    range.surroundContents(frame);\n\n    return frame;\n}\n"
  },
  {
    "path": "ts/editable/frame-handle.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getSelection, isSelectionCollapsed } from \"@tslib/cross-browser\";\nimport { elementIsEmpty, nodeIsElement, nodeIsText } from \"@tslib/dom\";\nimport { on } from \"@tslib/events\";\nimport type { Unsubscriber } from \"svelte/store\";\nimport { get } from \"svelte/store\";\n\nimport { moveChildOutOfElement } from \"$lib/domlib/move-nodes\";\nimport { placeCaretAfter } from \"$lib/domlib/place-caret\";\nimport { isComposing } from \"$lib/sveltelib/composition\";\n\nimport type { FrameElement } from \"./frame-element\";\n\n/**\n * The frame handle also needs some awareness that it's hosted below\n * the frame\n */\nexport const frameElementTagName = \"anki-frame\";\n\n/**\n * I originally used a zero width space, however, in contentEditable, if\n * a line ends in a zero width space, and you click _after_ the line,\n * the caret will be placed _before_ the zero width space.\n * Instead I use a hairline space.\n */\nconst spaceCharacter = \"\\u200a\";\nconst spaceRegex = /[\\u200a]/g;\n\nexport function isFrameHandle(node: unknown): node is FrameHandle {\n    return node instanceof FrameHandle;\n}\n\nfunction skippableNode(handleElement: FrameHandle, node: Node): boolean {\n    /**\n     * We only want to move nodes, which are direct descendants of the FrameHandle\n     * MutationRecords however might include nodes which were directly removed again\n     */\n    return (\n        (nodeIsText(node)\n            && (node.data === spaceCharacter || node.data.length === 0))\n        || !Array.prototype.includes.call(handleElement.childNodes, node)\n    );\n}\n\nfunction restoreHandleContent(mutations: MutationRecord[]): void {\n    let referenceNode: Node | null = null;\n\n    for (const mutation of mutations) {\n        const target = mutation.target;\n\n        if (mutation.type === \"childList\") {\n            if (!isFrameHandle(target)) {\n                /* nested insertion */\n                continue;\n            }\n\n            const handleElement = target;\n            const frameElement = handleElement.parentElement as FrameElement;\n\n            for (const node of mutation.addedNodes) {\n                if (skippableNode(handleElement, node)) {\n                    continue;\n                }\n\n                if (\n                    nodeIsElement(node)\n                    && !elementIsEmpty(node)\n                    && (node.textContent === spaceCharacter\n                        || node.textContent?.length === 0)\n                ) {\n                    /**\n                     * When we surround the spaceCharacter of the frame handle\n                     */\n                    node.replaceWith(new Text(spaceCharacter));\n                } else {\n                    referenceNode = moveChildOutOfElement(\n                        frameElement,\n                        node,\n                        handleElement.placement,\n                    );\n                }\n            }\n        } else if (mutation.type === \"characterData\") {\n            if (\n                !nodeIsText(target)\n                || !isFrameHandle(target.parentElement)\n                || skippableNode(target.parentElement, target)\n                || target.parentElement.unsubscribe\n            ) {\n                continue;\n            }\n            if (get(isComposing)) {\n                target.parentElement.subscribeToCompositionEvent();\n                continue;\n            }\n\n            referenceNode = target.parentElement.moveTextOutOfFrame(target.data);\n        }\n    }\n\n    if (referenceNode) {\n        placeCaretAfter(referenceNode);\n    }\n}\n\nconst handleObserver = new MutationObserver(restoreHandleContent);\nconst handles: Set<FrameHandle> = new Set();\n\ntype Placement = Extract<InsertPosition, \"beforebegin\" | \"afterend\">;\n\nexport abstract class FrameHandle extends HTMLElement {\n    static get observedAttributes(): string[] {\n        return [\"data-frames\"];\n    }\n\n    /**\n     * When a deletion is trigger with a FrameHandle selected, it will be treated\n     * differently depending on whether it is selected:\n     * - If partially selected, it should be restored (unless the frame element\n     * is also selected).\n     * - Otherwise, it should be deleted along with the frame element.\n     */\n    partiallySelected = false;\n    frames?: string;\n    abstract placement: Placement;\n    unsubscribe: Unsubscriber | null;\n\n    constructor() {\n        super();\n        handleObserver.observe(this, {\n            childList: true,\n            subtree: true,\n            characterData: true,\n        });\n        this.unsubscribe = null;\n    }\n\n    attributeChangedCallback(name: string, old: string, newValue: string): void {\n        if (newValue === old) {\n            return;\n        }\n\n        switch (name) {\n            case \"data-frames\":\n                this.frames = newValue;\n                break;\n        }\n    }\n\n    abstract getFrameRange(): Range;\n\n    invalidSpace(): boolean {\n        return (\n            !this.firstChild\n            || !(nodeIsText(this.firstChild) && this.firstChild.data === spaceCharacter)\n        );\n    }\n\n    refreshSpace(): void {\n        while (this.firstChild) {\n            this.removeChild(this.firstChild);\n        }\n\n        this.append(new Text(spaceCharacter));\n    }\n\n    hostedUnderFrame(): boolean {\n        return this.parentElement!.tagName === frameElementTagName.toUpperCase();\n    }\n\n    connectedCallback(): void {\n        if (this.invalidSpace()) {\n            this.refreshSpace();\n        }\n\n        if (!this.hostedUnderFrame()) {\n            const range = this.getFrameRange();\n\n            const frameElement = document.createElement(\n                frameElementTagName,\n            ) as FrameElement;\n            frameElement.dataset.frames = this.frames;\n\n            range.surroundContents(frameElement);\n        }\n\n        handles.add(this);\n    }\n\n    removeMoveIn?: () => void;\n\n    disconnectedCallback(): void {\n        handles.delete(this);\n\n        this.removeMoveIn?.();\n        this.removeMoveIn = undefined;\n        this.unsubscribeToCompositionEvent();\n    }\n\n    abstract notifyMoveIn(offset: number): void;\n\n    moveTextOutOfFrame(data: string): Text {\n        const frameElement = this.parentElement! as FrameElement;\n        const cleaned = data.replace(spaceRegex, \"\");\n        const text = new Text(cleaned);\n\n        if (this.placement === \"beforebegin\") {\n            frameElement.before(text);\n        } else if (this.placement === \"afterend\") {\n            frameElement.after(text);\n        }\n        this.refreshSpace();\n        return text;\n    }\n\n    /**\n     * https://github.com/ankitects/anki/issues/2251\n     *\n     * Work around the issue by not moving the input string while an IME session\n     * is active, and moving the final output from IME only after the session ends.\n     */\n    subscribeToCompositionEvent(): void {\n        this.unsubscribe = isComposing.subscribe((composing) => {\n            if (!composing) {\n                if (this.firstChild && nodeIsText(this.firstChild)) {\n                    placeCaretAfter(this.moveTextOutOfFrame(this.firstChild.data));\n                }\n                this.unsubscribeToCompositionEvent();\n            }\n        });\n    }\n\n    unsubscribeToCompositionEvent(): void {\n        this.unsubscribe?.();\n        this.unsubscribe = null;\n    }\n}\n\nexport class FrameStart extends FrameHandle {\n    static tagName = \"frame-start\";\n    placement: Placement;\n\n    constructor() {\n        super();\n        this.placement = \"beforebegin\";\n    }\n\n    getFrameRange(): Range {\n        const range = new Range();\n        range.setStartBefore(this);\n\n        const maybeFramed = this.nextElementSibling;\n\n        if (maybeFramed?.matches(this.frames ?? \":not(*)\")) {\n            const maybeHandleEnd = maybeFramed.nextElementSibling;\n\n            range.setEndAfter(\n                maybeHandleEnd?.tagName.toLowerCase() === FrameStart.tagName\n                    ? maybeHandleEnd\n                    : maybeFramed,\n            );\n        } else {\n            range.setEndAfter(this);\n        }\n\n        return range;\n    }\n\n    notifyMoveIn(offset: number): void {\n        if (offset === 1) {\n            this.dispatchEvent(new Event(\"movein\"));\n        }\n    }\n\n    connectedCallback(): void {\n        super.connectedCallback();\n\n        this.removeMoveIn = on(\n            this,\n            \"movein\" as keyof HTMLElementEventMap,\n            () => this.parentElement?.dispatchEvent(new Event(\"moveinstart\")),\n        );\n    }\n}\n\nexport class FrameEnd extends FrameHandle {\n    static tagName = \"frame-end\";\n    placement: Placement;\n\n    constructor() {\n        super();\n        this.placement = \"afterend\";\n    }\n\n    getFrameRange(): Range {\n        const range = new Range();\n        range.setEndAfter(this);\n\n        const maybeFramed = this.previousElementSibling;\n\n        if (maybeFramed?.matches(this.frames ?? \":not(*)\")) {\n            const maybeHandleStart = maybeFramed.previousElementSibling;\n\n            range.setEndAfter(\n                maybeHandleStart?.tagName.toLowerCase() === FrameEnd.tagName\n                    ? maybeHandleStart\n                    : maybeFramed,\n            );\n        } else {\n            range.setStartBefore(this);\n        }\n\n        return range;\n    }\n\n    notifyMoveIn(offset: number): void {\n        if (offset === 0) {\n            this.dispatchEvent(new Event(\"movein\"));\n        }\n    }\n\n    connectedCallback(): void {\n        super.connectedCallback();\n\n        this.removeMoveIn = on(\n            this,\n            \"movein\" as keyof HTMLElementEventMap,\n            () => this.parentElement?.dispatchEvent(new Event(\"moveinend\")),\n        );\n    }\n}\n\nfunction checkWhetherMovingIntoHandle(selection: Selection, handle: FrameHandle): void {\n    if (selection.anchorNode === handle.firstChild && isSelectionCollapsed(selection)) {\n        handle.notifyMoveIn(selection.anchorOffset);\n    }\n}\n\nfunction checkWhetherSelectingHandle(selection: Selection, handle: FrameHandle): void {\n    handle.partiallySelected = handle.firstChild && !isSelectionCollapsed(selection)\n        ? selection.containsNode(handle.firstChild)\n        : false;\n}\n\nexport function checkHandles(): void {\n    for (const handle of handles) {\n        const selection = getSelection(handle)!;\n\n        if (selection.rangeCount === 0) {\n            continue;\n        }\n\n        checkWhetherMovingIntoHandle(selection, handle);\n        checkWhetherSelectingHandle(selection, handle);\n    }\n}\n"
  },
  {
    "path": "ts/editable/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./editable-base.scss\";\n/* only imported for the CSS */\nimport \"./ContentEditable.svelte\";\nimport \"./Mathjax.svelte\";\n"
  },
  {
    "path": "ts/editable/mathjax-element.svelte.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { on } from \"@tslib/events\";\n\nimport { placeCaretAfter, placeCaretBefore } from \"$lib/domlib/place-caret\";\n\nimport { mount, tick } from \"svelte\";\nimport type { DecoratedElement, DecoratedElementConstructor } from \"./decorated\";\nimport { FrameElement, frameElement } from \"./frame-element\";\nimport Mathjax_svelte from \"./Mathjax.svelte\";\n\nconst mathjaxTagPattern = /<anki-mathjax(?:[^>]*?block=\"(.*?)\")?[^>]*?>(.*?)<\\/anki-mathjax>/gsu;\n\nconst mathjaxBlockDelimiterPattern = /\\\\\\[(.*?)\\\\\\]/gsu;\nconst mathjaxInlineDelimiterPattern = /\\\\\\((.*?)\\\\\\)/gsu;\n\nfunction trimBreaks(text: string): string {\n    return text\n        .replace(/<br[ ]*\\/?>/gsu, \"\\n\")\n        .replace(/^\\n*/, \"\")\n        .replace(/\\n*$/, \"\");\n}\n\nexport const mathjaxConfig = {\n    enabled: true,\n};\n\ninterface MathjaxProps {\n    mathjax: string;\n    block: boolean;\n    fontSize: number;\n}\n\nexport const Mathjax: DecoratedElementConstructor = class Mathjax extends HTMLElement implements DecoratedElement {\n    static tagName = \"anki-mathjax\";\n\n    static toStored(undecorated: string): string {\n        const stored = undecorated.replace(\n            mathjaxTagPattern,\n            (_match: string, block: string | undefined, text: string) => {\n                const trimmed = trimBreaks(text);\n                return typeof block === \"string\" && block !== \"false\"\n                    ? `\\\\[${trimmed}\\\\]`\n                    : `\\\\(${trimmed}\\\\)`;\n            },\n        );\n\n        return stored;\n    }\n\n    static toUndecorated(stored: string): string {\n        if (!mathjaxConfig.enabled) {\n            return stored;\n        }\n        return stored\n            .replace(mathjaxBlockDelimiterPattern, (_match: string, text: string) => {\n                const trimmed = trimBreaks(text);\n                return `<${Mathjax.tagName} block=\"true\">${trimmed}</${Mathjax.tagName}>`;\n            })\n            .replace(mathjaxInlineDelimiterPattern, (_match: string, text: string) => {\n                const trimmed = trimBreaks(text);\n                return `<${Mathjax.tagName}>${trimmed}</${Mathjax.tagName}>`;\n            });\n    }\n\n    block = false;\n    frame?: FrameElement;\n    component?: Record<string, any> | null;\n    props?: MathjaxProps;\n\n    static get observedAttributes(): string[] {\n        return [\"block\", \"data-mathjax\"];\n    }\n\n    connectedCallback(): void {\n        this.decorate();\n        this.addEventListeners();\n    }\n\n    disconnectedCallback(): void {\n        this.removeEventListeners();\n    }\n\n    attributeChangedCallback(name: string, old: string, newValue: string): void {\n        if (newValue === old) {\n            return;\n        }\n\n        switch (name) {\n            case \"block\":\n                this.block = newValue !== \"false\";\n                if (this.props) { this.props.block = this.block; }\n                this.frame?.setAttribute(\"block\", String(this.block));\n                break;\n\n            case \"data-mathjax\":\n                if (typeof newValue !== \"string\") {\n                    return;\n                }\n                if (this.props) { this.props.mathjax = newValue; }\n                break;\n        }\n    }\n\n    decorate(): void {\n        if (this.hasAttribute(\"decorated\")) {\n            this.undecorate();\n        }\n\n        if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) {\n            this.frame = this.parentElement as FrameElement;\n        } else {\n            frameElement(this, this.block);\n            /* Framing will place this element inside of an anki-frame element,\n             * causing the connectedCallback to be called again.\n             * If we'd continue decorating at this point, we'd loose all the information */\n            return;\n        }\n\n        this.dataset.mathjax = this.innerHTML;\n        this.innerHTML = \"\";\n        this.style.whiteSpace = \"normal\";\n\n        const props = $state<MathjaxProps>({\n            mathjax: this.dataset.mathjax,\n            block: this.block,\n            fontSize: 20,\n        });\n\n        const component = mount(Mathjax_svelte, {\n            target: this,\n            props,\n        });\n\n        this.component = component;\n        this.props = props;\n\n        if (this.hasAttribute(\"focusonmount\")) {\n            let position: [number, number] | undefined = undefined;\n\n            if (this.getAttribute(\"focusonmount\")!.length > 0) {\n                position = this.getAttribute(\"focusonmount\")!\n                    .split(\",\")\n                    .map(Number) as [number, number];\n            }\n\n            tick().then(() => {\n                this.component?.moveCaretAfter(position);\n            });\n        }\n\n        this.setAttribute(\"contentEditable\", \"false\");\n        this.setAttribute(\"decorated\", \"true\");\n    }\n\n    undecorate(): void {\n        if (this.parentElement?.tagName === FrameElement.tagName.toUpperCase()) {\n            this.parentElement.replaceWith(this);\n        }\n\n        this.innerHTML = this.dataset.mathjax ?? \"\";\n        delete this.dataset.mathjax;\n        this.removeAttribute(\"style\");\n        this.removeAttribute(\"focusonmount\");\n\n        if (this.block) {\n            this.setAttribute(\"block\", \"true\");\n        } else {\n            this.removeAttribute(\"block\");\n        }\n\n        this.removeAttribute(\"contentEditable\");\n        this.removeAttribute(\"decorated\");\n    }\n\n    removeMoveInStart?: () => void;\n    removeMoveInEnd?: () => void;\n\n    addEventListeners(): void {\n        this.removeMoveInStart = on(\n            this,\n            \"moveinstart\" as keyof HTMLElementEventMap,\n            () => this.component!.selectAll(),\n        );\n\n        this.removeMoveInEnd = on(this, \"moveinend\" as keyof HTMLElementEventMap, () => this.component!.selectAll());\n    }\n\n    removeEventListeners(): void {\n        this.removeMoveInStart?.();\n        this.removeMoveInStart = undefined;\n\n        this.removeMoveInEnd?.();\n        this.removeMoveInEnd = undefined;\n    }\n\n    placeCaretBefore(): void {\n        if (this.frame) {\n            placeCaretBefore(this.frame);\n        }\n    }\n\n    placeCaretAfter(): void {\n        if (this.frame) {\n            placeCaretAfter(this.frame);\n        }\n    }\n};\n"
  },
  {
    "path": "ts/editable/mathjax.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport \"mathjax/es5/tex-svg-full\";\n\nimport mathIcon from \"@mdi/svg/svg/math-integral-box.svg?src\";\n\nconst parser = new DOMParser();\n\nfunction getCSS(nightMode: boolean, fontSize: number): string {\n    const color = nightMode ? \"white\" : \"black\";\n    /* color is set for Maths, fill for the empty icon */\n    return `svg { color: ${color}; fill: ${color}; font-size: ${fontSize}px; };`;\n}\n\nfunction getStyle(css: string): HTMLStyleElement {\n    const style = document.createElement(\"style\");\n    style.appendChild(document.createTextNode(css));\n    return style;\n}\n\nfunction getEmptyIcon(style: HTMLStyleElement): [string, string] {\n    const icon = parser.parseFromString(mathIcon, \"image/svg+xml\");\n    const svg = icon.children[0];\n    svg.insertBefore(style, svg.children[0]);\n\n    return [svg.outerHTML, \"MathJax\"];\n}\n\nexport function convertMathjax(\n    input: string,\n    nightMode: boolean,\n    fontSize: number,\n): [string, string] {\n    input = revealClozeAnswers(input);\n    const style = getStyle(getCSS(nightMode, fontSize));\n\n    if (input.trim().length === 0) {\n        return getEmptyIcon(style);\n    }\n\n    let output: Element;\n    try {\n        output = globalThis.MathJax.tex2svg(input);\n    } catch (e) {\n        return [\"Mathjax Error\", String(e)];\n    }\n\n    const svg = output.children[0] as SVGElement;\n\n    if ((svg as any).viewBox.baseVal.height === 16) {\n        return getEmptyIcon(style);\n    }\n\n    let title = \"\";\n\n    if (svg.innerHTML.includes(\"data-mjx-error\")) {\n        svg.querySelector(\"rect\")?.setAttribute(\"fill\", \"yellow\");\n        svg.querySelector(\"text\")?.setAttribute(\"color\", \"red\");\n        title = svg.querySelector(\"title\")?.innerHTML ?? \"\";\n    } else {\n        svg.insertBefore(style, svg.children[0]);\n    }\n\n    return [svg.outerHTML, title];\n}\n\n/**\n * Escape characters which are technically legal in Mathjax, but confuse HTML.\n */\nexport function escapeSomeEntities(value: string): string {\n    return value.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n}\n\nexport function unescapeSomeEntities(value: string): string {\n    return value.replace(/&lt;/g, \"<\").replace(/&gt;/g, \">\").replace(/&amp;/g, \"&\");\n}\n\nfunction revealClozeAnswers(input: string): string {\n    // one-line version of regex in cloze.rs\n    const regex = /\\{\\{c(\\d+)::(.*?)(?:::(.*?))?\\}\\}/gis;\n    return input.replace(regex, \"[$2]\");\n}\n"
  },
  {
    "path": "ts/editor/BrowserEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ButtonGroupItem from \"$lib/components/ButtonGroupItem.svelte\";\n\n    import type { NoteEditorAPI } from \"./NoteEditor.svelte\";\n    import NoteEditor from \"./NoteEditor.svelte\";\n    import PreviewButton from \"./PreviewButton.svelte\";\n\n    const api: Partial<NoteEditorAPI> = {};\n    let noteEditor: NoteEditor;\n\n    export let uiResolve: (api: NoteEditorAPI) => void;\n\n    $: if (noteEditor) {\n        uiResolve(api as NoteEditorAPI);\n    }\n</script>\n\n<NoteEditor bind:this={noteEditor} {api}>\n    <svelte:fragment slot=\"notetypeButtons\">\n        <ButtonGroupItem>\n            <PreviewButton />\n        </ButtonGroupItem>\n    </svelte:fragment>\n</NoteEditor>\n"
  },
  {
    "path": "ts/editor/ClozeButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { chromiumVersion, isApplePlatform } from \"@tslib/platform\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { createEventDispatcher } from \"svelte\";\n    import { get } from \"svelte/store\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { clozeIcon, incrementClozeIcon } from \"$lib/components/icons\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    import { context as noteEditorContext } from \"./NoteEditor.svelte\";\n    import { editingInputIsRichText } from \"./rich-text-input\";\n\n    export let alwaysEnabled = false;\n\n    const { focusedInput, fields } = noteEditorContext.get();\n\n    // Workaround for Cmd+Option+Shift+C not working on macOS on older Chromium\n    // versions.\n    const chromiumVer = chromiumVersion();\n    const event =\n        isApplePlatform() && chromiumVer != null && chromiumVer <= 112\n            ? \"keyup\"\n            : \"keydown\";\n\n    const clozePattern = /\\{\\{c(\\d+)::/gu;\n    function getCurrentHighestCloze(increment: boolean): number {\n        let highest = 0;\n\n        for (const field of fields) {\n            const content = field.editingArea?.content;\n            const fieldHTML = content ? get(content) : \"\";\n\n            const matches: number[] = [];\n            let match: RegExpMatchArray | null = null;\n\n            while ((match = clozePattern.exec(fieldHTML))) {\n                matches.push(Number(match[1]));\n            }\n\n            highest = Math.max(highest, ...matches);\n        }\n\n        if (increment) {\n            highest++;\n        }\n\n        return Math.max(1, highest);\n    }\n\n    const dispatch = createEventDispatcher();\n\n    async function onIncrementCloze(): Promise<void> {\n        const highestCloze = getCurrentHighestCloze(true);\n\n        dispatch(\"surround\", {\n            prefix: `{{c${highestCloze}::`,\n            suffix: \"}}\",\n        });\n    }\n\n    async function onSameCloze(): Promise<void> {\n        const highestCloze = getCurrentHighestCloze(false);\n\n        dispatch(\"surround\", {\n            prefix: `{{c${highestCloze}::`,\n            suffix: \"}}\",\n        });\n    }\n\n    $: enabled =\n        alwaysEnabled ||\n        ($focusedInput &&\n            editingInputIsRichText($focusedInput) &&\n            $focusedInput.isClozeField);\n    $: disabled = !enabled;\n\n    const incrementKeyCombination = \"Control+Shift+C\";\n    const sameKeyCombination = \"Control+Alt+Shift+C\";\n</script>\n\n<ButtonGroup>\n    <IconButton\n        tooltip=\"{tr.editingClozeDeletion()} ({getPlatformString(\n            incrementKeyCombination,\n        )})\"\n        {disabled}\n        on:click={onIncrementCloze}\n        --border-left-radius=\"5px\"\n    >\n        <Icon icon={incrementClozeIcon} />\n    </IconButton>\n\n    <Shortcut\n        keyCombination={incrementKeyCombination}\n        event=\"keydown\"\n        on:action={onIncrementCloze}\n    />\n\n    <IconButton\n        tooltip=\"{tr.editingClozeDeletionRepeat()} ({getPlatformString(\n            sameKeyCombination,\n        )})\"\n        {disabled}\n        on:click={onSameCloze}\n        --border-right-radius=\"5px\"\n    >\n        <Icon icon={clozeIcon} />\n    </IconButton>\n\n    <Shortcut keyCombination={sameKeyCombination} {event} on:action={onSameCloze} />\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/CodeMirror.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type CodeMirrorLib from \"codemirror\";\n\n    export interface CodeMirrorAPI {\n        readonly editor: Promise<CodeMirrorLib.Editor>;\n        setOption<T extends keyof CodeMirrorLib.EditorConfiguration>(\n            key: T,\n            value: CodeMirrorLib.EditorConfiguration[T],\n        ): Promise<void>;\n    }\n</script>\n\n<script lang=\"ts\">\n    import { directionKey } from \"@tslib/context-keys\";\n    import { promiseWithResolver } from \"@tslib/promise\";\n    import { createEventDispatcher, getContext, onMount } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import {\n        darkTheme,\n        lightTheme,\n        openCodeMirror,\n        setupCodeMirror,\n    } from \"./code-mirror\";\n\n    export let configuration: CodeMirrorLib.EditorConfiguration;\n    export let code: Writable<string>;\n    export let hidden = false;\n\n    const defaultConfiguration = {\n        rtlMoveVisually: true,\n        lineNumbers: false,\n    };\n\n    const [editorPromise, resolve] = promiseWithResolver<CodeMirrorLib.Editor>();\n\n    /**\n     * Convenience function for editor.setOption.\n     */\n    async function setOption<T extends keyof CodeMirrorLib.EditorConfiguration>(\n        key: T,\n        value: CodeMirrorLib.EditorConfiguration[T],\n    ): Promise<void> {\n        const editor = await editorPromise;\n        editor.setOption(key, value);\n    }\n\n    const direction = getContext<Writable<\"ltr\" | \"rtl\">>(directionKey);\n\n    let apiPartial: Partial<CodeMirrorAPI>;\n    export { apiPartial as api };\n\n    Object.assign(apiPartial, {\n        editor: editorPromise,\n        setOption,\n    });\n\n    const dispatch = createEventDispatcher();\n\n    onMount(async () => {\n        const editor = await editorPromise;\n        setupCodeMirror(editor, code);\n        editor.on(\"change\", () => dispatch(\"change\", editor.getValue()));\n        editor.on(\"focus\", (codeMirror, event) =>\n            dispatch(\"focus\", { codeMirror, event }),\n        );\n        editor.on(\"blur\", (codeMirror, event) =>\n            dispatch(\"blur\", { codeMirror, event }),\n        );\n        editor.on(\"keydown\", (codeMirror, event) => {\n            if (event.code === \"Tab\") {\n                dispatch(\"tab\", { codeMirror, event });\n            }\n        });\n    });\n</script>\n\n<div class=\"code-mirror\">\n    <textarea\n        tabindex=\"-1\"\n        hidden\n        use:openCodeMirror={{\n            configuration: {\n                ...configuration,\n                ...defaultConfiguration,\n                direction: $direction,\n                theme: $pageTheme.isDark ? darkTheme : lightTheme,\n            },\n            resolve,\n            hidden,\n        }}\n    ></textarea>\n</div>\n\n<style lang=\"scss\">\n    .code-mirror {\n        height: 100%;\n\n        :global(.CodeMirror) {\n            height: auto;\n            font-family: Consolas, monospace;\n        }\n\n        :global(.CodeMirror-wrap pre) {\n            word-break: break-word;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/CollapseBadge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { chevronDown } from \"$lib/components/icons\";\n\n    export let collapsed = false;\n</script>\n\n<div class=\"collapse-badge\" class:collapsed>\n    <Badge iconSize={80}><Icon icon={chevronDown} /></Badge>\n</div>\n\n<style lang=\"scss\">\n    .collapse-badge {\n        display: inline-block;\n        opacity: 0.4;\n        transition:\n            opacity var(--transition) ease-in-out,\n            transform var(--transition) ease-in;\n        :global(.collapse-label:hover) & {\n            opacity: 1;\n        }\n        &.collapsed {\n            transform: rotate(-90deg);\n        }\n    }\n\n    :global([dir=\"rtl\"]) {\n        .collapse-badge.collapsed {\n            transform: rotate(90deg);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/CollapseLabel.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher } from \"svelte\";\n\n    import CollapseBadge from \"./CollapseBadge.svelte\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n\n    export let collapsed: boolean;\n    export let tooltip: string;\n\n    const dispatch = createEventDispatcher();\n\n    function toggle() {\n        dispatch(\"toggle\");\n    }\n</script>\n\n<span\n    class=\"collapse-label\"\n    title={tooltip}\n    on:click|stopPropagation={toggle}\n    on:keydown={onEnterOrSpace(() => toggle())}\n    tabindex=\"-1\"\n    role=\"button\"\n    aria-expanded={!collapsed}\n>\n    <CollapseBadge {collapsed} />\n    <slot />\n</span>\n\n<style lang=\"scss\">\n    .collapse-label {\n        cursor: pointer;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/DuplicateLink.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n</script>\n\n<span class=\"duplicate-link-container\">\n    <a class=\"duplicate-link\" href=\"/#\" on:click={() => bridgeCommand(\"dupes\")}>\n        {tr.editingShowDuplicates()}\n    </a>\n</span>\n\n<style lang=\"scss\">\n    .duplicate-link-container {\n        text-align: center;\n        flex-grow: 1;\n    }\n\n    .duplicate-link {\n        color: var(--highlight-color);\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/EditingArea.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n\n    export interface FocusableInputAPI {\n        readonly name: string;\n        focusable: boolean;\n        /**\n         * The reaction to a user-initiated focus, e.g. by clicking on the\n         * editor label, or pressing Tab.\n         */\n        focus(): void;\n        /**\n         * Behaves similar to a refresh, e.g. sync with content, put the caret\n         * into a neutral position, and/or clear selections.\n         */\n        refocus(): void;\n    }\n\n    export interface EditingInputAPI extends FocusableInputAPI {\n        /**\n         * Check whether blurred target belongs to an editing input.\n         * The editing area can then restore focus to this input.\n         *\n         * @returns An editing input api that is associated with the event target.\n         */\n        getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null>;\n    }\n\n    export interface EditingAreaAPI {\n        content: Writable<string>;\n        editingInputs: Writable<EditingInputAPI[]>;\n        focus(): void;\n        refocus(): void;\n    }\n\n    const key = Symbol(\"editingArea\");\n    const [context, setContextProperty] = contextProperty<EditingAreaAPI>(key);\n\n    export { context };\n</script>\n\n<script lang=\"ts\">\n    import { fontFamilyKey, fontSizeKey } from \"@tslib/context-keys\";\n    import { setContext as svelteSetContext } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    export let fontFamily: string;\n    const fontFamilyStore = writable(fontFamily);\n    $: $fontFamilyStore = fontFamily;\n    svelteSetContext(fontFamilyKey, fontFamilyStore);\n\n    export let fontSize: number;\n    const fontSizeStore = writable(fontSize);\n    $: $fontSizeStore = fontSize;\n    svelteSetContext(fontSizeKey, fontSizeStore);\n\n    export let content: Writable<string>;\n\n    let editingArea: HTMLElement;\n\n    const inputsStore = writable<EditingInputAPI[]>([]);\n    $: editingInputs = $inputsStore;\n\n    function getAvailableInput(): EditingInputAPI | undefined {\n        return editingInputs.find((input) => input.focusable);\n    }\n\n    function focus(): void {\n        editingArea.contains(document.activeElement);\n    }\n\n    function refocus(): void {\n        const availableInput = getAvailableInput();\n\n        if (availableInput) {\n            availableInput.refocus();\n        }\n    }\n\n    let apiPartial: Partial<EditingAreaAPI>;\n    export { apiPartial as api };\n\n    const api = Object.assign(apiPartial, {\n        content,\n        editingInputs: inputsStore,\n        focus,\n        refocus,\n    });\n\n    setContextProperty(api);\n</script>\n\n<div bind:this={editingArea} class=\"editing-area\">\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .editing-area {\n        display: grid;\n\n        /* This defines the border between inputs */\n        grid-gap: 1px;\n        background-color: var(--border);\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/EditorField.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Readable } from \"svelte/store\";\n\n    import type { EditingAreaAPI } from \"./EditingArea.svelte\";\n\n    export interface FieldData {\n        name: string;\n        fontFamily: string;\n        fontSize: number;\n        direction: \"ltr\" | \"rtl\";\n        plainText: boolean;\n        description: string;\n        collapsed: boolean;\n        hidden: boolean;\n        isClozeField: boolean;\n    }\n\n    export interface EditorFieldAPI {\n        element: Promise<HTMLElement>;\n        direction: Readable<\"ltr\" | \"rtl\">;\n        editingArea: EditingAreaAPI;\n    }\n\n    import { registerPackage } from \"@tslib/runtime-require\";\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n    import lifecycleHooks from \"$lib/sveltelib/lifecycle-hooks\";\n\n    const key = Symbol(\"editorField\");\n    const [context, setContextProperty] = contextProperty<EditorFieldAPI>(key);\n    const [lifecycle, instances, setupLifecycleHooks] =\n        lifecycleHooks<EditorFieldAPI>();\n\n    export { context };\n\n    registerPackage(\"anki/EditorField\", {\n        context,\n        lifecycle,\n        instances,\n    });\n</script>\n\n<script lang=\"ts\">\n    import { collapsedKey, directionKey } from \"@tslib/context-keys\";\n    import { promiseWithResolver } from \"@tslib/promise\";\n    import { onDestroy, setContext } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n    import { writable } from \"svelte/store\";\n\n    import Collapsible from \"$lib/components/Collapsible.svelte\";\n\n    import type { Destroyable } from \"./destroyable\";\n    import EditingArea from \"./EditingArea.svelte\";\n\n    export let content: Writable<string>;\n    export let field: FieldData;\n    export let collapsed = false;\n    export let flipInputs = false;\n    export let dupe = false;\n    export let index;\n\n    const directionStore = writable<\"ltr\" | \"rtl\">();\n    setContext(directionKey, directionStore);\n\n    $: $directionStore = field.direction;\n\n    const collapsedStore = writable<boolean>();\n    setContext(collapsedKey, collapsedStore);\n\n    $: $collapsedStore = collapsed;\n\n    const editingArea: Partial<EditingAreaAPI> = {};\n    const [element, elementResolve] = promiseWithResolver<HTMLElement>();\n\n    let apiPartial: Partial<EditorFieldAPI> & Destroyable;\n    export { apiPartial as api };\n\n    const api: EditorFieldAPI & Destroyable = Object.assign(apiPartial, {\n        element,\n        direction: directionStore,\n        editingArea: editingArea as EditingAreaAPI,\n        isClozeField: field.isClozeField,\n    });\n\n    setContextProperty(api);\n    setupLifecycleHooks(api);\n\n    onDestroy(() => api?.destroy());\n</script>\n\n<div\n    class=\"field-container\"\n    class:hide={field.hidden}\n    on:mouseenter\n    on:mouseleave\n    role=\"presentation\"\n    data-index={index}\n>\n    <slot name=\"field-label\" />\n\n    <Collapsible collapse={collapsed} let:collapsed={hidden}>\n        <div\n            use:elementResolve\n            class=\"editor-field\"\n            class:dupe\n            on:focusin\n            on:focusout\n            {hidden}\n        >\n            <EditingArea\n                {content}\n                fontFamily={field.fontFamily}\n                fontSize={field.fontSize}\n                api={editingArea}\n            >\n                {#if flipInputs}\n                    <slot name=\"plain-text-input\" />\n                    <slot name=\"rich-text-input\" />\n                {:else}\n                    <slot name=\"rich-text-input\" />\n                    <slot name=\"plain-text-input\" />\n                {/if}\n            </EditingArea>\n        </div>\n    </Collapsible>\n</div>\n\n<style lang=\"scss\">\n    @use \"../lib/sass/elevation\" as *;\n\n    /* Make sure labels are readable on custom Qt backgrounds */\n    .field-container {\n        background: var(--canvas);\n        border-radius: var(--border-radius);\n        overflow: hidden;\n    }\n\n    .field-container.hide {\n        display: none;\n    }\n\n    .editor-field {\n        overflow: hidden;\n        /* make room for thicker focus border */\n        margin: 1px;\n\n        border-radius: var(--border-radius);\n        border: 1px solid var(--border);\n\n        @include elevation(1);\n\n        outline-offset: -1px;\n        &.dupe,\n        &.dupe:focus-within {\n            outline: 2px solid var(--accent-danger);\n        }\n        &:focus-within {\n            outline: 2px solid var(--border-focus);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/FieldDescription.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { directionKey, fontFamilyKey, fontSizeKey } from \"@tslib/context-keys\";\n    import { getContext } from \"svelte\";\n    import type { Readable } from \"svelte/store\";\n\n    import { context } from \"./EditingArea.svelte\";\n\n    const { content } = context.get();\n\n    const fontFamily = getContext<Readable<string>>(fontFamilyKey);\n    const fontSize = getContext<Readable<number>>(fontSizeKey);\n    const direction = getContext<Readable<\"ltr\" | \"rtl\">>(directionKey);\n\n    $: empty = $content.length === 0;\n</script>\n\n{#if empty}\n    <div\n        class=\"field-description\"\n        style:font-family={$fontFamily}\n        style:font-size=\"{$fontSize}px\"\n        style:direction={$direction}\n    >\n        <slot />\n    </div>\n{/if}\n\n<style>\n    .field-description {\n        position: absolute;\n        top: 0;\n        left: 0;\n        right: 0;\n        bottom: 0;\n\n        color: var(--fg-subtle);\n        pointer-events: none;\n\n        /* Stay a on single line */\n        white-space: nowrap;\n        text-overflow: ellipsis;\n\n        /* The field description is placed absolutely on top of the editor field */\n        /* So we need to make sure it does not escape the editor field if the */\n        /* description is too long */\n        overflow: hidden;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/FieldState.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<span class=\"field-state justify-content-end\">\n    <slot />\n</span>\n\n<style lang=\"scss\">\n    .field-state {\n        display: flex;\n        justify-content: flex;\n        flex-grow: 1;\n\n        /* replace with \"gap: 5px\" once it's available\n           - required: Chromium 84 (Qt6 only) and iOS 14.1 */\n        > :global(*) {\n            margin: 0 3px;\n\n            &:first-child {\n                margin-left: 0;\n            }\n            &:last-child {\n                margin-right: 0;\n            }\n        }\n    }\n    :global([dir=\"rtl\"]) .field-state > :global(*) {\n        margin: 0 3px;\n\n        &:last-child {\n            margin-left: 0;\n        }\n        &:first-child {\n            margin-right: 0;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/Fields.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ScrollArea from \"$lib/components/ScrollArea.svelte\";\n</script>\n\n<!--\n@component\nContains the fields. This contains the scrollable area.\n-->\n<ScrollArea>\n    <div class=\"fields\">\n        <slot />\n    </div>\n</ScrollArea>\n\n<style lang=\"scss\">\n    .fields {\n        margin-top: 5px;\n        display: grid;\n        grid-auto-rows: min-content;\n        grid-gap: 6px;\n\n        /* Add space after the last field and the start of the tag editor */\n        padding-bottom: 5px;\n\n        /* Move the scrollbar for the NoteEditor into this element */\n        position: relative;\n        overflow-y: auto;\n\n        /* Push the tag editor to the bottom of the note editor */\n        flex-grow: 1;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/HandleBackground.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let tooltip: string | undefined = undefined;\n</script>\n\n<div\n    class=\"handle-background\"\n    title={tooltip}\n    on:mousedown|preventDefault\n    on:dblclick\n    tabindex=\"-1\"\n    role=\"button\"\n></div>\n\n<style lang=\"scss\">\n    .handle-background {\n        width: 100%;\n        height: 100%;\n        background-color: var(--handle-background-color, #aaa);\n        border-radius: 5px;\n        opacity: 0.2;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/HandleControl.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher } from \"svelte\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    export let offsetX = 0;\n    export let offsetY = 0;\n\n    export let active = false;\n    export let activeSize = 5;\n\n    const dispatch = createEventDispatcher();\n\n    const onPointerdown =\n        (north: boolean, west: boolean) =>\n        (event: PointerEvent): void => {\n            dispatch(\"pointerclick\", { north, west, originalEvent: event });\n        };\n</script>\n\n<div\n    class=\"handle-control\"\n    style=\"--offsetX: {offsetX}px; --offsetY: {offsetY}px; --activeSize: {activeSize}px;\"\n>\n    <div\n        class:nightMode={$pageTheme.isDark}\n        class=\"bordered\"\n        on:mousedown|preventDefault\n        tabindex=\"-1\"\n        role=\"button\"\n    ></div>\n    <div\n        class:nightMode={$pageTheme.isDark}\n        class:active\n        class=\"control nw\"\n        on:mousedown|preventDefault\n        on:pointerdown={onPointerdown(true, true)}\n        on:pointermove\n        tabindex=\"-1\"\n        role=\"button\"\n    ></div>\n    <div\n        class:nightMode={$pageTheme.isDark}\n        class:active\n        class=\"control ne\"\n        on:mousedown|preventDefault\n        on:pointerdown={onPointerdown(true, false)}\n        on:pointermove\n        tabindex=\"-1\"\n        role=\"button\"\n    ></div>\n    <div\n        class:nightMode={$pageTheme.isDark}\n        class:active\n        class=\"control sw\"\n        on:mousedown|preventDefault\n        on:pointerdown={onPointerdown(false, true)}\n        on:pointermove\n        tabindex=\"-1\"\n        role=\"button\"\n    ></div>\n    <div\n        class:nightMode={$pageTheme.isDark}\n        class:active\n        class=\"control se\"\n        on:mousedown|preventDefault\n        on:pointerdown={onPointerdown(false, false)}\n        on:pointermove\n        tabindex=\"-1\"\n        role=\"button\"\n    ></div>\n</div>\n\n<style lang=\"scss\">\n    .handle-control {\n        display: contents;\n    }\n\n    .bordered {\n        position: absolute;\n\n        top: calc(0px - var(--activeSize) + var(--offsetY));\n        bottom: calc(0px - var(--activeSize) + var(--offsetY));\n        left: calc(0px - var(--activeSize) + var(--offsetX));\n        right: calc(0px - var(--activeSize) + var(--offsetX));\n\n        pointer-events: none;\n        border: 2px dashed black;\n\n        &.nightMode {\n            border-color: white;\n        }\n    }\n\n    .control {\n        position: absolute;\n\n        width: var(--activeSize);\n        height: var(--activeSize);\n\n        &.active {\n            background-color: black;\n        }\n\n        &.nightMode {\n            border-color: white;\n\n            &.active {\n                background-color: white;\n            }\n        }\n\n        &.nw {\n            top: calc(0px - var(--offsetY));\n            left: calc(0px - var(--offsetX));\n\n            &.active {\n                cursor: nw-resize;\n            }\n        }\n\n        &.ne {\n            top: calc(0px - var(--offsetY));\n            right: calc(0px - var(--offsetX));\n\n            &.active {\n                cursor: ne-resize;\n            }\n        }\n\n        &.sw {\n            bottom: calc(0px - var(--offsetY));\n            left: calc(0px - var(--offsetX));\n\n            &.active {\n                cursor: sw-resize;\n            }\n        }\n\n        &.se {\n            bottom: calc(0px - var(--offsetY));\n            right: calc(0px - var(--offsetX));\n\n            &.active {\n                cursor: se-resize;\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/HandleLabel.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { directionKey } from \"@tslib/context-keys\";\n    import { getContext } from \"svelte\";\n    import type { Readable } from \"svelte/store\";\n\n    const direction = getContext<Readable<\"ltr\" | \"rtl\">>(directionKey);\n</script>\n\n<div class=\"handle-label\" class:is-rtl={$direction === \"rtl\"}>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .handle-label {\n        position: absolute;\n        width: fit-content;\n\n        left: 0;\n        right: 0;\n        bottom: 3px;\n\n        margin-left: auto;\n        margin-right: auto;\n\n        font-size: 13px;\n        color: white;\n        background-color: rgba(0 0 0 / 0.4);\n        border-color: black;\n        border-radius: 5px;\n        padding: 0 5px;\n\n        pointer-events: none;\n        user-select: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/LabelContainer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import CollapseLabel from \"./CollapseLabel.svelte\";\n\n    export let collapsed: boolean;\n\n    $: tooltip = collapsed ? tr.editingExpandField() : tr.editingCollapseField();\n</script>\n\n<div class=\"label-container\">\n    <CollapseLabel {collapsed} {tooltip} on:toggle>\n        <slot name=\"field-name\" />\n    </CollapseLabel>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .label-container {\n        display: flex;\n        justify-content: space-between;\n        background: var(--canvas);\n        border-top-right-radius: var(--border-radius);\n        border-top-left-radius: var(--border-radius);\n        padding: 0 3px 1px;\n\n        position: sticky;\n        top: 0;\n        z-index: 50;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/LabelName.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n\n<span class=\"label-name\">\n    <slot />\n</span>\n"
  },
  {
    "path": "ts/editor/NoteCreator.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { registerShortcut } from \"@tslib/shortcuts\";\n    import { onDestroy, onMount } from \"svelte\";\n\n    import type { NoteEditorAPI } from \"./NoteEditor.svelte\";\n    import NoteEditor from \"./NoteEditor.svelte\";\n    import StickyBadge from \"./StickyBadge.svelte\";\n\n    const api: Partial<NoteEditorAPI> = {};\n    let noteEditor: NoteEditor;\n\n    export let uiResolve: (api: NoteEditorAPI) => void;\n\n    $: if (noteEditor) {\n        uiResolve(api as NoteEditorAPI);\n    }\n\n    let stickies: boolean[] = [];\n\n    function setSticky(stckies: boolean[]): void {\n        stickies = stckies;\n    }\n\n    function toggleStickyAll(): void {\n        bridgeCommand(\"toggleStickyAll\", (values: boolean[]) => (stickies = values));\n    }\n\n    let deregisterSticky: () => void;\n    export function activateStickyShortcuts() {\n        deregisterSticky = registerShortcut(toggleStickyAll, \"Shift+F9\");\n    }\n\n    onMount(() => {\n        Object.assign(globalThis, {\n            setSticky,\n        });\n    });\n\n    onDestroy(() => deregisterSticky);\n</script>\n\n<NoteEditor bind:this={noteEditor} {api}>\n    <svelte:fragment slot=\"field-state\" let:index let:show>\n        <StickyBadge bind:active={stickies[index]} {index} {show} />\n    </svelte:fragment>\n</NoteEditor>\n"
  },
  {
    "path": "ts/editor/NoteEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n\n    import Collapsible from \"$lib/components/Collapsible.svelte\";\n\n    import type { EditingInputAPI } from \"./EditingArea.svelte\";\n    import type { EditorToolbarAPI } from \"./editor-toolbar\";\n    import type { EditorFieldAPI } from \"./EditorField.svelte\";\n    import FieldState from \"./FieldState.svelte\";\n    import LabelContainer from \"./LabelContainer.svelte\";\n    import LabelName from \"./LabelName.svelte\";\n\n    export interface NoteEditorAPI {\n        fields: EditorFieldAPI[];\n        hoveredField: Writable<EditorFieldAPI | null>;\n        focusedField: Writable<EditorFieldAPI | null>;\n        focusedInput: Writable<EditingInputAPI | null>;\n        toolbar: EditorToolbarAPI;\n    }\n\n    import { registerPackage } from \"@tslib/runtime-require\";\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n    import lifecycleHooks from \"$lib/sveltelib/lifecycle-hooks\";\n\n    const key = Symbol(\"noteEditor\");\n    const [context, setContextProperty] = contextProperty<NoteEditorAPI>(key);\n    const [lifecycle, instances, setupLifecycleHooks] = lifecycleHooks<NoteEditorAPI>();\n\n    export { context };\n\n    registerPackage(\"anki/NoteEditor\", {\n        context,\n        lifecycle,\n        instances,\n    });\n</script>\n\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { onMount, tick } from \"svelte\";\n    import { get, writable } from \"svelte/store\";\n    import { nodeIsCommonElement } from \"@tslib/dom\";\n\n    import Absolute from \"$lib/components/Absolute.svelte\";\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { alertIcon } from \"$lib/components/icons\";\n    import { TagEditor } from \"$lib/tag-editor\";\n    import { commitTagEdits } from \"$lib/tag-editor/TagInput.svelte\";\n\n    import {\n        type ImageLoadedEvent,\n        resetIOImage,\n    } from \"../routes/image-occlusion/mask-editor\";\n    import { ChangeTimer } from \"../editable/change-timer\";\n    import { clearableArray } from \"./destroyable\";\n    import DuplicateLink from \"./DuplicateLink.svelte\";\n    import EditorToolbar from \"./editor-toolbar\";\n    import type { FieldData } from \"./EditorField.svelte\";\n    import EditorField from \"./EditorField.svelte\";\n    import Fields from \"./Fields.svelte\";\n    import ImageOverlay from \"./image-overlay\";\n    import { shrinkImagesByDefault } from \"./image-overlay/ImageOverlay.svelte\";\n    import MathjaxOverlay from \"./mathjax-overlay\";\n    import { closeMathjaxEditor } from \"./mathjax-overlay/MathjaxEditor.svelte\";\n    import Notification from \"./Notification.svelte\";\n    import PlainTextInput from \"./plain-text-input\";\n    import { closeHTMLTags } from \"./plain-text-input/PlainTextInput.svelte\";\n    import PlainTextBadge from \"./PlainTextBadge.svelte\";\n    import RichTextInput, { editingInputIsRichText } from \"./rich-text-input\";\n    import RichTextBadge from \"./RichTextBadge.svelte\";\n    import type { NotetypeIdAndModTime, SessionOptions } from \"./types\";\n    import { EditorState } from \"./types\";\n\n    function quoteFontFamily(fontFamily: string): string {\n        // generic families (e.g. sans-serif) must not be quoted\n        if (!/^[-a-z]+$/.test(fontFamily)) {\n            fontFamily = `\"${fontFamily}\"`;\n        }\n        return fontFamily;\n    }\n\n    const size = 1.6;\n    const wrap = true;\n\n    const sessionOptions: SessionOptions = {};\n    export function saveSession(): void {\n        if (notetypeMeta) {\n            sessionOptions[notetypeMeta.id] = {\n                fieldsCollapsed,\n                fieldStates: {\n                    richTextsHidden,\n                    plainTextsHidden,\n                    plainTextDefaults,\n                },\n                modTimeOfNotetype: notetypeMeta.modTime,\n            };\n        }\n    }\n\n    const fieldStores: Writable<string>[] = [];\n    let fieldNames: string[] = [];\n    export function setFields(fs: [string, string][]): void {\n        // this is a bit of a mess -- when moving to Rust calls, we should make\n        // sure to have two backend endpoints for:\n        // * the note, which can be set through this view\n        // * the fieldname, font, etc., which cannot be set\n\n        const newFieldNames: string[] = [];\n\n        for (const [index, [fieldName]] of fs.entries()) {\n            newFieldNames[index] = fieldName;\n        }\n\n        for (let i = fieldStores.length; i < newFieldNames.length; i++) {\n            const newStore = writable(\"\");\n            fieldStores[i] = newStore;\n            newStore.subscribe((value) => updateField(i, value));\n        }\n\n        for (\n            let i = fieldStores.length;\n            i > newFieldNames.length;\n            i = fieldStores.length\n        ) {\n            fieldStores.pop();\n        }\n\n        for (const [index, [, fieldContent]] of fs.entries()) {\n            fieldStores[index].set(fieldContent);\n        }\n\n        fieldNames = newFieldNames;\n    }\n\n    let fieldsCollapsed: boolean[] = [];\n    export function setCollapsed(defaultCollapsed: boolean[]): void {\n        fieldsCollapsed =\n            sessionOptions[notetypeMeta?.id]?.fieldsCollapsed ?? defaultCollapsed;\n    }\n    let clozeFields: boolean[] = [];\n    export function setClozeFields(defaultClozeFields: boolean[]): void {\n        clozeFields = defaultClozeFields;\n    }\n\n    let richTextsHidden: boolean[] = [];\n    let plainTextsHidden: boolean[] = [];\n    let plainTextDefaults: boolean[] = [];\n\n    export function setPlainTexts(defaultPlainTexts: boolean[]): void {\n        const states = sessionOptions[notetypeMeta?.id]?.fieldStates;\n        if (states) {\n            richTextsHidden = states.richTextsHidden;\n            plainTextsHidden = states.plainTextsHidden;\n            plainTextDefaults = states.plainTextDefaults;\n        } else {\n            plainTextDefaults = defaultPlainTexts;\n            richTextsHidden = [...defaultPlainTexts];\n            plainTextsHidden = Array.from(defaultPlainTexts, (v) => !v);\n        }\n    }\n\n    export function triggerChanges(): void {\n        // I know this looks quite weird and doesn't seem to do anything\n        // but if we don't call this after setPlainTexts() and setCollapsed()\n        // when switching notetypes, existing collapsibles won't react\n        // automatically to the updated props\n        tick().then(() => {\n            fieldsCollapsed = fieldsCollapsed;\n            plainTextDefaults = plainTextDefaults;\n            richTextsHidden = richTextsHidden;\n            plainTextsHidden = plainTextsHidden;\n        });\n    }\n\n    function setMathjaxEnabled(enabled: boolean): void {\n        mathjaxConfig.enabled = enabled;\n    }\n\n    let fieldDescriptions: string[] = [];\n    export function setDescriptions(descriptions: string[]): void {\n        fieldDescriptions = descriptions.map((d) =>\n            d.replace(/\\\\/g, \"\").replace(/\"/g, '\\\\\"'),\n        );\n    }\n\n    let fonts: [string, number, boolean][] = [];\n\n    const fields = clearableArray<EditorFieldAPI>();\n\n    export function setFonts(fs: [string, number, boolean][]): void {\n        fonts = fs;\n    }\n\n    export function focusField(index: number | null): void {\n        tick().then(() => {\n            if (typeof index === \"number\") {\n                if (!(index in fields)) {\n                    return;\n                }\n\n                fields[index].editingArea?.refocus();\n            } else {\n                $focusedInput?.refocus();\n            }\n        });\n    }\n\n    const tags = writable<string[]>([]);\n    export function setTags(ts: string[]): void {\n        $tags = ts;\n    }\n\n    const tagsCollapsed = writable<boolean>();\n    export function setTagsCollapsed(collapsed: boolean): void {\n        $tagsCollapsed = collapsed;\n    }\n\n    function updateTagsCollapsed(collapsed: boolean) {\n        $tagsCollapsed = collapsed;\n        bridgeCommand(`setTagsCollapsed:${$tagsCollapsed}`);\n    }\n\n    let noteId: number | null = null;\n    export function setNoteId(ntid: number): void {\n        // TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput.\n        // It should be refactored once we work on our own Undo stack\n        for (const pi of plainTextInputs) {\n            pi.api.codeMirror.editor.then((editor) => editor.clearHistory());\n        }\n        noteId = ntid;\n    }\n\n    let notetypeMeta: NotetypeIdAndModTime;\n    function setNotetypeMeta({ id, modTime }: NotetypeIdAndModTime): void {\n        notetypeMeta = { id, modTime };\n        // Discard the saved state of the fields if the notetype has been modified.\n        if (sessionOptions[id]?.modTimeOfNotetype !== modTime) {\n            delete sessionOptions[id];\n        }\n        if (isImageOcclusion) {\n            getImageOcclusionFields({\n                notetypeId: BigInt(notetypeMeta.id),\n            }).then((r) => (ioFields = r.fields!));\n        }\n    }\n\n    function getNoteId(): number | null {\n        return noteId;\n    }\n\n    let isImageOcclusion = false;\n    function setIsImageOcclusion(val: boolean) {\n        isImageOcclusion = val;\n        $ioMaskEditorVisible = val;\n    }\n\n    let cols: (\"dupe\" | \"\")[] = [];\n    export function setBackgrounds(cls: (\"dupe\" | \"\")[]): void {\n        cols = cls;\n    }\n\n    let hint: string = \"\";\n    export function setClozeHint(hnt: string): void {\n        hint = hnt;\n    }\n\n    $: fieldsData = fieldNames.map((name, index) => ({\n        name,\n        plainText: plainTextDefaults[index],\n        description: fieldDescriptions[index],\n        fontFamily: quoteFontFamily(fonts[index][0]),\n        fontSize: fonts[index][1],\n        direction: fonts[index][2] ? \"rtl\" : \"ltr\",\n        collapsed: fieldsCollapsed[index],\n        hidden: hideFieldInOcclusionType(index, ioFields),\n        isClozeField: clozeFields[index],\n    })) as FieldData[];\n\n    let lastSavedTags: string[] | null = null;\n    function saveTags({ detail }: CustomEvent): void {\n        tagAmount = detail.tags.filter((tag: string) => tag != \"\").length;\n        lastSavedTags = detail.tags;\n        bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);\n    }\n\n    const fieldSave = new ChangeTimer();\n\n    function transformContentBeforeSave(content: string): string {\n        return content.replace(/ data-editor-shrink=\"(true|false)\"/g, \"\");\n    }\n\n    function updateField(index: number, content: string): void {\n        fieldSave.schedule(\n            () =>\n                bridgeCommand(\n                    `key:${index}:${getNoteId()}:${transformContentBeforeSave(\n                        content,\n                    )}`,\n                ),\n            600,\n        );\n    }\n\n    function saveFieldNow(): void {\n        /* this will always be a key save */\n        fieldSave.fireImmediately();\n    }\n\n    function saveNow(): void {\n        closeMathjaxEditor?.();\n        $commitTagEdits();\n        saveFieldNow();\n    }\n\n    export function saveOnPageHide() {\n        if (document.visibilityState === \"hidden\") {\n            // will fire on session close and minimize\n            saveFieldNow();\n        }\n    }\n\n    export function focusIfField(x: number, y: number): boolean {\n        const elements = document.elementsFromPoint(x, y);\n        const first = elements[0].closest(\".field-container\");\n\n        if (!first || !nodeIsCommonElement(first)) {\n            return false;\n        }\n\n        const index = parseInt(first.dataset?.index ?? \"\");\n\n        if (Number.isNaN(index) || !fields[index] || fieldsCollapsed[index]) {\n            return false;\n        }\n\n        if (richTextsHidden[index]) {\n            toggleRichTextInput(index);\n        } else {\n            richTextInputs[index].api.refocus();\n        }\n\n        return true;\n    }\n\n    let richTextInputs: RichTextInput[] = [];\n    $: richTextInputs = richTextInputs.filter(Boolean);\n\n    let plainTextInputs: PlainTextInput[] = [];\n    $: plainTextInputs = plainTextInputs.filter(Boolean);\n\n    function toggleRichTextInput(index: number): void {\n        const hidden = !richTextsHidden[index];\n        richTextInputs[index].focusFlag.setFlag(!hidden);\n        richTextsHidden[index] = hidden;\n        if (hidden) {\n            plainTextInputs[index].api.refocus();\n        }\n    }\n\n    function togglePlainTextInput(index: number): void {\n        const hidden = !plainTextsHidden[index];\n        plainTextInputs[index].focusFlag.setFlag(!hidden);\n        plainTextsHidden[index] = hidden;\n        if (hidden) {\n            richTextInputs[index].api.refocus();\n        }\n    }\n\n    function toggleField(index: number): void {\n        const collapsed = !fieldsCollapsed[index];\n        fieldsCollapsed[index] = collapsed;\n\n        const defaultInput = !plainTextDefaults[index]\n            ? richTextInputs[index]\n            : plainTextInputs[index];\n\n        if (!collapsed) {\n            defaultInput.api.refocus();\n        } else if (!plainTextDefaults[index]) {\n            plainTextsHidden[index] = true;\n        } else {\n            richTextsHidden[index] = true;\n        }\n    }\n\n    const toolbar: Partial<EditorToolbarAPI> = {};\n\n    function setShrinkImages(shrinkByDefault: boolean) {\n        $shrinkImagesByDefault = shrinkByDefault;\n    }\n\n    function setCloseHTMLTags(closeTags: boolean) {\n        $closeHTMLTags = closeTags;\n    }\n\n    /**\n     * Enable/Disable add-on buttons that do not have the `perm` class\n     */\n    function setAddonButtonsDisabled(disabled: boolean): void {\n        document\n            .querySelectorAll<HTMLButtonElement>(\"button.linkb:not(.perm)\")\n            .forEach((button) => {\n                button.disabled = disabled;\n            });\n    }\n\n    import { ImageOcclusionFieldIndexes } from \"@generated/anki/image_occlusion_pb\";\n    import { getImageOcclusionFields } from \"@generated/backend\";\n    import { wrapInternal } from \"@tslib/wrap\";\n\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    import { mathjaxConfig } from \"../editable/mathjax-element.svelte\";\n    import ImageOcclusionPage from \"../routes/image-occlusion/ImageOcclusionPage.svelte\";\n    import ImageOcclusionPicker from \"../routes/image-occlusion/ImageOcclusionPicker.svelte\";\n    import type { IOMode } from \"../routes/image-occlusion/lib\";\n    import { exportShapesToClozeDeletions } from \"../routes/image-occlusion/shapes/to-cloze\";\n    import {\n        hideAllGuessOne,\n        ioImageLoadedStore,\n        ioMaskEditorVisible,\n    } from \"../routes/image-occlusion/store\";\n    import CollapseLabel from \"./CollapseLabel.svelte\";\n    import * as oldEditorAdapter from \"./old-editor-adapter\";\n\n    $: isIOImageLoaded = false;\n    $: ioImageLoadedStore.set(isIOImageLoaded);\n    let imageOcclusionMode: IOMode | undefined;\n    let ioFields = new ImageOcclusionFieldIndexes({});\n\n    function pickIOImage() {\n        imageOcclusionMode = undefined;\n        bridgeCommand(\"addImageForOcclusion\");\n    }\n\n    function pickIOImageFromClipboard() {\n        imageOcclusionMode = undefined;\n        bridgeCommand(\"addImageForOcclusionFromClipboard\");\n    }\n\n    async function setupMaskEditor(options: { html: string; mode: IOMode }) {\n        imageOcclusionMode = undefined;\n        await tick();\n        imageOcclusionMode = options.mode;\n        if (options.mode.kind === \"add\" && !(\"clonedNoteId\" in options.mode)) {\n            fieldStores[ioFields.image].set(options.html);\n            // the image field is set programmatically and does not need debouncing\n            // commit immediately to avoid a race condition with the occlusions field\n            saveFieldNow();\n\n            // new image is being added\n            if (isIOImageLoaded) {\n                resetIOImage(options.mode.imagePath, (event: ImageLoadedEvent) =>\n                    onImageLoaded(\n                        new CustomEvent(\"image-loaded\", {\n                            detail: event,\n                        }),\n                    ),\n                );\n            }\n        }\n\n        isIOImageLoaded = true;\n    }\n\n    function setImageField(html) {\n        fieldStores[ioFields.image].set(html);\n    }\n    globalThis.setImageField = setImageField;\n\n    function saveOcclusions(): void {\n        if (isImageOcclusion && globalThis.canvas) {\n            const occlusionsData = exportShapesToClozeDeletions($hideAllGuessOne);\n            fieldStores[ioFields.occlusions].set(occlusionsData.clozes);\n        }\n    }\n\n    // reset for new occlusion in add mode\n    function resetIOImageLoaded() {\n        isIOImageLoaded = false;\n        globalThis.canvas.clear();\n        globalThis.canvas = undefined;\n        if (imageOcclusionMode?.kind === \"add\") {\n            // canvas.clear indirectly calls saveOcclusions\n            saveFieldNow();\n            fieldStores[ioFields.image].set(\"\");\n        }\n        const page = document.querySelector(\".image-occlusion\");\n        if (page) {\n            page.remove();\n        }\n    }\n    globalThis.resetIOImageLoaded = resetIOImageLoaded;\n\n    /** hide occlusions and image */\n    function hideFieldInOcclusionType(\n        index: number,\n        ioFields: ImageOcclusionFieldIndexes,\n    ) {\n        if (isImageOcclusion) {\n            if (index === ioFields.occlusions || index === ioFields.image) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // Signal image occlusion image loading to Python\n    function onImageLoaded(event: CustomEvent<ImageLoadedEvent>) {\n        const detail = event.detail;\n        bridgeCommand(\n            `ioImageLoaded:${JSON.stringify(detail.path || detail.noteId?.toString())}`,\n        );\n    }\n\n    // Signal editor UI state changes to add-ons\n\n    let editorState: EditorState = EditorState.Initial;\n    let lastEditorState: EditorState = editorState;\n\n    function getEditorState(\n        ioMaskEditorVisible: boolean,\n        isImageOcclusion: boolean,\n        isIOImageLoaded: boolean,\n        imageOcclusionMode: IOMode | undefined,\n    ): EditorState {\n        if (isImageOcclusion && ioMaskEditorVisible && !isIOImageLoaded) {\n            return EditorState.ImageOcclusionPicker;\n        } else if (imageOcclusionMode && ioMaskEditorVisible) {\n            return EditorState.ImageOcclusionMasks;\n        } else if (!ioMaskEditorVisible && isImageOcclusion) {\n            return EditorState.ImageOcclusionFields;\n        }\n        return EditorState.Fields;\n    }\n\n    function signalEditorState(newState: EditorState) {\n        tick().then(() => {\n            globalThis.editorState = newState;\n            bridgeCommand(`editorState:${newState}:${lastEditorState}`);\n            lastEditorState = newState;\n        });\n    }\n\n    $: signalEditorState(editorState);\n\n    $: editorState = getEditorState(\n        $ioMaskEditorVisible,\n        isImageOcclusion,\n        isIOImageLoaded,\n        imageOcclusionMode,\n    );\n\n    $: if (isImageOcclusion && $ioMaskEditorVisible && lastSavedTags) {\n        setTags(lastSavedTags);\n        lastSavedTags = null;\n    }\n\n    onMount(() => {\n        function wrap(before: string, after: string): void {\n            if (!$focusedInput || !editingInputIsRichText($focusedInput)) {\n                return;\n            }\n\n            $focusedInput.element.then((element) => {\n                wrapInternal(element, before, after, false);\n            });\n        }\n\n        Object.assign(globalThis, {\n            saveSession,\n            setFields,\n            setCollapsed,\n            setClozeFields,\n            setPlainTexts,\n            setDescriptions,\n            setFonts,\n            focusField,\n            setTags,\n            setTagsCollapsed,\n            setBackgrounds,\n            setClozeHint,\n            saveNow,\n            focusIfField,\n            getNoteId,\n            setNoteId,\n            setNotetypeMeta,\n            wrap,\n            setMathjaxEnabled,\n            setShrinkImages,\n            setCloseHTMLTags,\n            triggerChanges,\n            setIsImageOcclusion,\n            setupMaskEditor,\n            saveOcclusions,\n            ...oldEditorAdapter,\n        });\n\n        editorState = getEditorState(\n            $ioMaskEditorVisible,\n            isImageOcclusion,\n            isIOImageLoaded,\n            imageOcclusionMode,\n        );\n\n        document.addEventListener(\"visibilitychange\", saveOnPageHide);\n        return () => document.removeEventListener(\"visibilitychange\", saveOnPageHide);\n    });\n\n    let apiPartial: Partial<NoteEditorAPI> = {};\n    export { apiPartial as api };\n\n    const hoveredField: NoteEditorAPI[\"hoveredField\"] = writable(null);\n    const focusedField: NoteEditorAPI[\"focusedField\"] = writable(null);\n    const focusedInput: NoteEditorAPI[\"focusedInput\"] = writable(null);\n\n    const api: NoteEditorAPI = {\n        ...apiPartial,\n        hoveredField,\n        focusedField,\n        focusedInput,\n        toolbar: toolbar as EditorToolbarAPI,\n        fields,\n    };\n\n    setContextProperty(api);\n    setupLifecycleHooks(api);\n\n    $: tagAmount = $tags.length;\n</script>\n\n<!--\n@component\nServes as a pre-slotted convenience component which combines all the common\ncomponents and functionality for general note editing.\n\nFunctionality exclusive to specific note-editing views (e.g. in the browser or\nthe AddCards dialog) should be implemented in the user of this component.\n-->\n<div class=\"note-editor\">\n    <EditorToolbar {size} {wrap} api={toolbar}>\n        <slot slot=\"notetypeButtons\" name=\"notetypeButtons\" />\n    </EditorToolbar>\n\n    {#if hint}\n        <Absolute bottom right --margin=\"10px\">\n            <Notification>\n                <Badge --badge-color=\"tomato\" --icon-align=\"top\">\n                    <Icon icon={alertIcon} />\n                </Badge>\n                <span>{@html hint}</span>\n            </Notification>\n        </Absolute>\n    {/if}\n\n    {#if imageOcclusionMode && ($ioMaskEditorVisible || imageOcclusionMode?.kind === \"add\")}\n        <div style=\"display: {$ioMaskEditorVisible ? 'block' : 'none'};\">\n            <ImageOcclusionPage\n                mode={imageOcclusionMode}\n                on:save={saveOcclusions}\n                on:image-loaded={onImageLoaded}\n            />\n        </div>\n    {/if}\n\n    {#if $ioMaskEditorVisible && isImageOcclusion && !isIOImageLoaded}\n        <ImageOcclusionPicker\n            onPickImage={pickIOImage}\n            onPickImageFromClipboard={pickIOImageFromClipboard}\n        />\n    {/if}\n\n    {#if !$ioMaskEditorVisible}\n        <Fields>\n            {#each fieldsData as field, index}\n                {@const content = fieldStores[index]}\n\n                <EditorField\n                    {field}\n                    {content}\n                    {index}\n                    flipInputs={plainTextDefaults[index]}\n                    api={fields[index]}\n                    on:focusin={() => {\n                        $focusedField = fields[index];\n                        setAddonButtonsDisabled(false);\n                        bridgeCommand(`focus:${index}`);\n                    }}\n                    on:focusout={() => {\n                        $focusedField = null;\n                        setAddonButtonsDisabled(true);\n                        bridgeCommand(\n                            `blur:${index}:${getNoteId()}:${transformContentBeforeSave(\n                                get(content),\n                            )}`,\n                        );\n                    }}\n                    on:mouseenter={() => {\n                        $hoveredField = fields[index];\n                    }}\n                    on:mouseleave={() => {\n                        $hoveredField = null;\n                    }}\n                    collapsed={fieldsCollapsed[index]}\n                    dupe={cols[index] === \"dupe\"}\n                    --description-font-size=\"{field.fontSize}px\"\n                    --description-content={`\"${field.description}\"`}\n                >\n                    <svelte:fragment slot=\"field-label\">\n                        <LabelContainer\n                            collapsed={fieldsCollapsed[index]}\n                            on:toggle={() => toggleField(index)}\n                            --icon-align=\"bottom\"\n                        >\n                            <svelte:fragment slot=\"field-name\">\n                                <LabelName>\n                                    {field.name}\n                                </LabelName>\n                            </svelte:fragment>\n                            <FieldState>\n                                {#if cols[index] === \"dupe\"}\n                                    <DuplicateLink />\n                                {/if}\n                                <slot\n                                    name=\"field-state\"\n                                    {field}\n                                    {index}\n                                    show={fields[index] === $hoveredField ||\n                                        fields[index] === $focusedField}\n                                />\n                                {#if plainTextDefaults[index]}\n                                    <RichTextBadge\n                                        show={!fieldsCollapsed[index] &&\n                                            (fields[index] === $hoveredField ||\n                                                fields[index] === $focusedField)}\n                                        bind:off={richTextsHidden[index]}\n                                        on:toggle={() => toggleRichTextInput(index)}\n                                    />\n                                {:else}\n                                    <PlainTextBadge\n                                        show={!fieldsCollapsed[index] &&\n                                            (fields[index] === $hoveredField ||\n                                                fields[index] === $focusedField)}\n                                        bind:off={plainTextsHidden[index]}\n                                        on:toggle={() => togglePlainTextInput(index)}\n                                    />\n                                {/if}\n                            </FieldState>\n                        </LabelContainer>\n                    </svelte:fragment>\n                    <svelte:fragment slot=\"rich-text-input\">\n                        <Collapsible\n                            collapse={richTextsHidden[index]}\n                            let:collapsed={hidden}\n                            toggleDisplay\n                        >\n                            <RichTextInput\n                                {hidden}\n                                on:focusout={() => {\n                                    saveFieldNow();\n                                    $focusedInput = null;\n                                }}\n                                bind:this={richTextInputs[index]}\n                                isClozeField={field.isClozeField}\n                            />\n                        </Collapsible>\n                    </svelte:fragment>\n                    <svelte:fragment slot=\"plain-text-input\">\n                        <Collapsible\n                            collapse={plainTextsHidden[index]}\n                            let:collapsed={hidden}\n                            toggleDisplay\n                        >\n                            <PlainTextInput\n                                {hidden}\n                                fieldCollapsed={fieldsCollapsed[index]}\n                                on:focusout={() => {\n                                    saveFieldNow();\n                                    $focusedInput = null;\n                                }}\n                                bind:this={plainTextInputs[index]}\n                            />\n                        </Collapsible>\n                    </svelte:fragment>\n                </EditorField>\n            {/each}\n\n            <MathjaxOverlay />\n            <ImageOverlay maxWidth={250} maxHeight={125} />\n        </Fields>\n\n        <Shortcut\n            keyCombination=\"Control+Shift+T\"\n            on:action={() => {\n                updateTagsCollapsed(false);\n            }}\n        />\n        <CollapseLabel\n            collapsed={$tagsCollapsed}\n            tooltip={$tagsCollapsed ? tr.editingExpand() : tr.editingCollapse()}\n            on:toggle={() => updateTagsCollapsed(!$tagsCollapsed)}\n        >\n            {@html `${tagAmount > 0 ? tagAmount : \"\"} ${tr.editingTags()}`}\n        </CollapseLabel>\n        <Collapsible toggleDisplay collapse={$tagsCollapsed}>\n            <TagEditor {tags} on:tagsupdate={saveTags} />\n        </Collapsible>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .note-editor {\n        display: flex;\n        flex-direction: column;\n        height: 100%;\n    }\n\n    :global(.image-occlusion) {\n        position: fixed;\n    }\n\n    :global(.image-occlusion .tab-buttons) {\n        display: none !important;\n    }\n\n    :global(.image-occlusion .top-tool-bar-container) {\n        margin-left: 28px !important;\n    }\n    :global(.top-tool-bar-container .icon-button) {\n        height: 36px !important;\n        line-height: 1;\n    }\n    :global(.image-occlusion .tool-bar-container) {\n        top: unset !important;\n        margin-top: 2px !important;\n    }\n    :global(.image-occlusion .sticky-footer) {\n        display: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/Notification.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { fly } from \"svelte/transition\";\n</script>\n\n<div class=\"notification\" transition:fly={{ x: 200 }}>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .notification {\n        background-color: var(--notification-bg, var(--canvas));\n        user-select: none;\n\n        border: 1px solid var(--border);\n        border-radius: 5px;\n        padding: 0.9rem 1.2rem;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/PlainTextBadge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString, registerShortcut } from \"@tslib/shortcuts\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n    import { createEventDispatcher, onDestroy } from \"svelte\";\n\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { plainTextIcon } from \"$lib/components/icons\";\n\n    import { context as editorFieldContext } from \"./EditorField.svelte\";\n\n    const animated = !document.body.classList.contains(\"reduce-motion\");\n\n    const editorField = editorFieldContext.get();\n    const keyCombination = \"Control+Shift+X\";\n    const dispatch = createEventDispatcher();\n\n    export let show = false;\n    export let off = false;\n\n    function toggle() {\n        dispatch(\"toggle\");\n    }\n\n    let unregister: ReturnType<typeof registerShortcut> | undefined;\n\n    editorField.element.then((target) => {\n        unregister = registerShortcut(toggle, keyCombination, { target });\n    });\n\n    onDestroy(() => unregister?.());\n</script>\n\n<span\n    class=\"plain-text-badge\"\n    class:visible={show || !animated}\n    class:highlighted={!off}\n    on:click|stopPropagation={toggle}\n    on:keydown={onEnterOrSpace(() => toggle())}\n    tabindex=\"-1\"\n    role=\"button\"\n>\n    <Badge\n        tooltip=\"{tr.editingToggleHtmlEditor()} ({getPlatformString(keyCombination)})\"\n        iconSize={80}\n    >\n        <Icon icon={plainTextIcon} />\n    </Badge>\n</span>\n\n<style lang=\"scss\">\n    span {\n        cursor: pointer;\n        opacity: 0;\n\n        &.visible {\n            opacity: 0.4;\n            &:hover {\n                opacity: 0.8;\n            }\n        }\n        &.highlighted {\n            opacity: 1;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/PreviewButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import { writable } from \"svelte/store\";\n\n    const active = writable(false);\n\n    export function togglePreviewButtonState(state: boolean): void {\n        active.set(state);\n    }\n\n    Object.assign(globalThis, { togglePreviewButtonState });\n</script>\n\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    const keyCombination = \"Control+Shift+P\";\n    function preview(): void {\n        bridgeCommand(\"preview\");\n    }\n</script>\n\n<LabelButton\n    tooltip={tr.browsingPreviewSelectedCard({ val: getPlatformString(keyCombination) })}\n    active={$active}\n    on:click={preview}\n>\n    {tr.actionsPreview()}\n</LabelButton>\n\n<Shortcut keyCombination=\"Control+Shift+P\" on:action={preview} />\n"
  },
  {
    "path": "ts/editor/ReviewerEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { NoteEditorAPI } from \"./NoteEditor.svelte\";\n    import NoteEditor from \"./NoteEditor.svelte\";\n\n    const api: Partial<NoteEditorAPI> = {};\n    let noteEditor: NoteEditor;\n\n    export let uiResolve: (api: NoteEditorAPI) => void;\n\n    $: if (noteEditor) {\n        uiResolve(api as NoteEditorAPI);\n    }\n</script>\n\n<NoteEditor bind:this={noteEditor} {api} />\n"
  },
  {
    "path": "ts/editor/RichTextBadge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString, registerShortcut } from \"@tslib/shortcuts\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n    import { createEventDispatcher, onDestroy } from \"svelte\";\n\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { richTextIcon } from \"$lib/components/icons\";\n\n    import { context as editorFieldContext } from \"./EditorField.svelte\";\n\n    const animated = !document.body.classList.contains(\"reduce-motion\");\n\n    const editorField = editorFieldContext.get();\n    const keyCombination = \"Control+Shift+X\";\n    const dispatch = createEventDispatcher();\n\n    export let show = false;\n    export let off = false;\n\n    function toggle() {\n        dispatch(\"toggle\");\n    }\n\n    let unregister: ReturnType<typeof registerShortcut> | undefined;\n\n    editorField.element.then((target) => {\n        unregister = registerShortcut(toggle, keyCombination, { target });\n    });\n\n    onDestroy(() => unregister?.());\n</script>\n\n<span\n    class=\"plain-text-badge\"\n    class:visible={show || !animated}\n    class:highlighted={!off}\n    on:click|stopPropagation={toggle}\n    on:keydown={onEnterOrSpace(() => toggle())}\n    tabindex=\"-1\"\n    role=\"button\"\n>\n    <Badge\n        tooltip=\"{tr.editingToggleVisualEditor()} ({getPlatformString(keyCombination)})\"\n        iconSize={80}\n    >\n        <Icon icon={richTextIcon} />\n    </Badge>\n</span>\n\n<style lang=\"scss\">\n    span {\n        cursor: pointer;\n        opacity: 0;\n\n        &.visible {\n            opacity: 0.4;\n            &:hover {\n                opacity: 0.8;\n            }\n        }\n        &.highlighted {\n            opacity: 1;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/StickyBadge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { getPlatformString, registerShortcut } from \"@tslib/shortcuts\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n    import { onMount } from \"svelte\";\n\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { stickyIconHollow } from \"$lib/components/icons\";\n    import { stickyIconSolid } from \"$lib/components/icons\";\n\n    import { context as editorFieldContext } from \"./EditorField.svelte\";\n\n    const animated = !document.body.classList.contains(\"reduce-motion\");\n\n    export let active: boolean;\n    export let show: boolean;\n\n    const editorField = editorFieldContext.get();\n    const keyCombination = \"F9\";\n\n    export let index: number;\n\n    function toggle() {\n        bridgeCommand(`toggleSticky:${index}`, (value: boolean) => {\n            active = value;\n        });\n    }\n\n    function shortcut(target: HTMLElement): () => void {\n        return registerShortcut(toggle, keyCombination, { target });\n    }\n\n    onMount(() => {\n        editorField.element.then(shortcut);\n    });\n</script>\n\n<span\n    class:highlighted={active}\n    class:visible={show || !animated}\n    on:click|stopPropagation={toggle}\n    on:keydown={onEnterOrSpace(() => toggle())}\n    tabindex=\"-1\"\n    role=\"button\"\n>\n    <Badge\n        tooltip=\"{tr.editingToggleSticky()} ({getPlatformString(keyCombination)})\"\n        widthMultiplier={0.7}\n    ></Badge>\n    {#if active}\n        <Icon icon={stickyIconSolid} />\n    {:else}\n        <Icon icon={stickyIconHollow} />\n    {/if}\n</span>\n\n<style lang=\"scss\">\n    span {\n        cursor: pointer;\n        opacity: 0;\n        &.visible {\n            transition: none;\n            opacity: 0.4;\n            &:hover {\n                opacity: 0.8;\n            }\n        }\n        &.highlighted {\n            opacity: 1;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/base.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * Code that is shared among all entry points in /ts/editor\n */\n\nimport \"./legacy.scss\";\nimport \"./editor-base.scss\";\nimport \"@tslib/runtime-require\";\nimport \"$lib/sveltelib/export-runtime\";\n\nimport { setupI18n } from \"@tslib/i18n\";\nimport { uiResolve } from \"@tslib/ui\";\n\nimport * as contextKeys from \"$lib/components/context-keys\";\nimport IconButton from \"$lib/components/IconButton.svelte\";\nimport LabelButton from \"$lib/components/LabelButton.svelte\";\nimport WithContext from \"$lib/components/WithContext.svelte\";\nimport WithState from \"$lib/components/WithState.svelte\";\n\nimport BrowserEditor from \"./BrowserEditor.svelte\";\nimport NoteCreator from \"./NoteCreator.svelte\";\nimport * as editorContextKeys from \"./NoteEditor.svelte\";\nimport ReviewerEditor from \"./ReviewerEditor.svelte\";\n\ndeclare global {\n    interface Selection {\n        addRange(r: Range): void;\n        removeAllRanges(): void;\n        getRangeAt(n: number): Range;\n    }\n}\n\nimport { ModuleName } from \"@tslib/i18n\";\nimport { mount } from \"svelte\";\n\nexport const editorModules = [\n    ModuleName.EDITING,\n    ModuleName.KEYBOARD,\n    ModuleName.ACTIONS,\n    ModuleName.BROWSING,\n    ModuleName.NOTETYPES,\n    ModuleName.IMPORTING,\n    ModuleName.UNDO,\n];\n\nexport const components = {\n    IconButton,\n    LabelButton,\n    WithContext,\n    WithState,\n    contextKeys: { ...contextKeys, ...editorContextKeys },\n};\n\nexport { editorToolbar } from \"./editor-toolbar\";\n\nasync function setupBrowserEditor(): Promise<void> {\n    await setupI18n({ modules: editorModules });\n    mount(BrowserEditor, { target: document.body, props: { uiResolve } });\n}\n\nasync function setupNoteCreator(): Promise<void> {\n    await setupI18n({ modules: editorModules });\n    mount(NoteCreator, { target: document.body, props: { uiResolve } });\n}\n\nasync function setupReviewerEditor(): Promise<void> {\n    await setupI18n({ modules: editorModules });\n    mount(ReviewerEditor, { target: document.body, props: { uiResolve } });\n}\n\nexport function setupEditor(mode: \"add\" | \"browse\" | \"review\") {\n    switch (mode) {\n        case \"add\":\n            setupNoteCreator();\n            break;\n        case \"browse\":\n            setupBrowserEditor();\n            break;\n        case \"review\":\n            setupReviewerEditor();\n            break;\n        default:\n            alert(\"unexpected editor type\");\n    }\n}\n"
  },
  {
    "path": "ts/editor/code-mirror.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"codemirror/lib/codemirror.css\";\nimport \"codemirror/addon/fold/foldgutter.css\";\nimport \"codemirror/theme/monokai.css\";\nimport \"codemirror/mode/htmlmixed/htmlmixed\";\nimport \"codemirror/mode/stex/stex\";\nimport \"codemirror/addon/fold/foldcode\";\nimport \"codemirror/addon/fold/foldgutter\";\nimport \"codemirror/addon/fold/xml-fold\";\nimport \"codemirror/addon/edit/matchtags\";\nimport \"codemirror/addon/edit/closetag\";\nimport \"codemirror/addon/display/placeholder\";\n\nimport CodeMirror from \"codemirror\";\nimport type { Readable } from \"svelte/store\";\n\nimport storeSubscribe from \"$lib/sveltelib/store-subscribe\";\n\nexport { CodeMirror };\n\nexport const latex = {\n    name: \"stex\",\n    inMathMode: true,\n};\n\nexport const htmlanki = {\n    name: \"htmlmixed\",\n    tags: {\n        \"anki-mathjax\": [[null, null, latex]],\n    },\n};\n\nexport const lightTheme = \"default\";\nexport const darkTheme = \"monokai\";\n\nexport const baseOptions: CodeMirror.EditorConfiguration = {\n    theme: lightTheme,\n    lineWrapping: true,\n    matchTags: { bothTags: true },\n    extraKeys: { Tab: false, \"Shift-Tab\": false },\n    tabindex: 0,\n    viewportMargin: Infinity,\n    lineWiseCopyCut: false,\n};\n\nexport const gutterOptions: CodeMirror.EditorConfiguration = {\n    gutters: [\"CodeMirror-linenumbers\", \"CodeMirror-foldgutter\"],\n    lineNumbers: true,\n    foldGutter: true,\n};\n\nexport function focusAndSetCaret(\n    editor: CodeMirror.Editor,\n    position: CodeMirror.Position = { line: editor.lineCount(), ch: 0 },\n): void {\n    editor.focus();\n    editor.setCursor(position);\n}\n\ninterface OpenCodeMirrorOptions {\n    configuration: CodeMirror.EditorConfiguration;\n    resolve(editor: CodeMirror.EditorFromTextArea): void;\n    hidden: boolean;\n}\n\nexport function openCodeMirror(\n    textarea: HTMLTextAreaElement,\n    options: Partial<OpenCodeMirrorOptions>,\n): { update: (options: Partial<OpenCodeMirrorOptions>) => void; destroy: () => void } {\n    let editor: CodeMirror.EditorFromTextArea | null = null;\n\n    function update({\n        configuration,\n        resolve,\n        hidden,\n    }: Partial<OpenCodeMirrorOptions>): void {\n        if (editor) {\n            for (const key in configuration) {\n                editor.setOption(\n                    key as keyof CodeMirror.EditorConfiguration,\n                    configuration[key],\n                );\n            }\n        } else if (!hidden) {\n            editor = CodeMirror.fromTextArea(textarea, configuration);\n            resolve?.(editor);\n        }\n    }\n\n    update(options);\n\n    return {\n        update,\n        destroy(): void {\n            editor?.toTextArea();\n            editor = null;\n        },\n    };\n}\n\n/**\n * Sets up the contract with the code store and location restoration.\n */\nexport function setupCodeMirror(\n    editor: CodeMirror.Editor,\n    code: Readable<string>,\n): void {\n    const { subscribe, unsubscribe } = storeSubscribe(\n        code,\n        (value: string): void => editor.setValue(value),\n        false,\n    );\n\n    // TODO passing in the tabindex option does not do anything: bug?\n    editor.getInputField().tabIndex = 0;\n\n    let ranges: CodeMirror.Range[] | null = null;\n\n    editor.on(\"focus\", () => {\n        if (ranges) {\n            try {\n                editor.setSelections(ranges);\n            } catch {\n                ranges = null;\n                editor.setCursor(editor.lineCount(), 0);\n            }\n        }\n        unsubscribe();\n    });\n\n    editor.on(\"mousedown\", () => {\n        // Prevent focus restoring location\n        ranges = null;\n    });\n\n    editor.on(\"blur\", () => {\n        ranges = editor.listSelections();\n        subscribe();\n    });\n\n    subscribe();\n}\n"
  },
  {
    "path": "ts/editor/decorated-elements.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { BLOCK_ELEMENTS } from \"@tslib/dom\";\n\nimport { CustomElementArray } from \"../editable/decorated\";\nimport { FrameElement } from \"../editable/frame-element\";\nimport { FrameEnd, FrameStart } from \"../editable/frame-handle\";\nimport { Mathjax } from \"../editable/mathjax-element.svelte\";\nimport { parsingInstructions } from \"./plain-text-input\";\n\nconst decoratedElements = new CustomElementArray();\n\nfunction registerMathjax() {\n    decoratedElements.push(Mathjax);\n    parsingInstructions.push(\"<style>anki-mathjax { white-space: pre; }</style>\");\n}\n\nfunction registerFrameElement() {\n    customElements.define(FrameElement.tagName, FrameElement);\n    customElements.define(FrameStart.tagName, FrameStart);\n    customElements.define(FrameEnd.tagName, FrameEnd);\n\n    /* This will ensure that they are not targeted by surrounding algorithms */\n    BLOCK_ELEMENTS.push(FrameStart.tagName.toUpperCase());\n    BLOCK_ELEMENTS.push(FrameEnd.tagName.toUpperCase());\n}\n\nregisterMathjax();\nregisterFrameElement();\n\nexport { decoratedElements };\n"
  },
  {
    "path": "ts/editor/destroyable.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport interface Destroyable {\n    destroy(): void;\n}\n\nexport function clearableArray<T>(): (T & Destroyable)[] {\n    const list: (T & Destroyable)[] = [];\n\n    return new Proxy(list, {\n        get: function(target: (T & Destroyable)[], prop: string | symbol) {\n            if (!(typeof prop === \"symbol\") && !isNaN(Number(prop)) && !target[prop]) {\n                const item = {} as T & Destroyable;\n\n                const destroy = (): void => {\n                    const index = list.indexOf(item);\n                    list.splice(index, 1);\n                };\n\n                target[prop] = new Proxy(item, {\n                    get: function(target: T & Destroyable, prop: string | symbol) {\n                        if (prop === \"destroy\") {\n                            return destroy;\n                        }\n\n                        return target[prop];\n                    },\n                });\n            }\n\n            return target[prop];\n        },\n    });\n}\n"
  },
  {
    "path": "ts/editor/editor-base.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@import \"../lib/sass/base\";\n\n$btn-disabled-opacity: 0.4;\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"../lib/sass/bootstrap-tooltip\";\n\nhtml,\nbody {\n    font-family: var(--bs-font-sans-serif);\n    overflow: hidden;\n}\n"
  },
  {
    "path": "ts/editor/editor-toolbar/AddonButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { toggleEditorButton } from \"../old-editor-adapter\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { on } from \"@tslib/events\";\n\n    const { buttons } = $props<{ buttons: string[] }>();\n\n    $effect(() => {\n        // Each time the buttons are changed...\n        buttons;\n\n        // Add event handlers to each button\n        const addonButtons = document.querySelectorAll(\".anki-addon-button\");\n        const cbs = [...addonButtons].map((button) =>\n            singleCallback(\n                on(button, \"click\", () => {\n                    const command = button.getAttribute(\"data-command\");\n                    if (command) {\n                        bridgeCommand(command);\n                    }\n                    const toggleable = button.getAttribute(\"data-cantoggle\");\n                    if (toggleable === \"1\") {\n                        toggleEditorButton(button as HTMLButtonElement);\n                    }\n\n                    return false;\n                }),\n                on(button as HTMLButtonElement, \"mousedown\", (evt) => {\n                    evt.preventDefault();\n                    evt.stopPropagation();\n                }),\n            ),\n        );\n\n        return singleCallback(...cbs);\n    });\n\n    const radius = \"5px\";\n    function getBorderRadius(index: number, length: number): string {\n        if (index === 0 && length === 1) {\n            return `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;\n        } else if (index === 0) {\n            return `--border-left-radius: ${radius}; --border-right-radius: 0; `;\n        } else if (index === length - 1) {\n            return `--border-left-radius: 0; --border-right-radius: ${radius}; `;\n        } else {\n            return \"--border-left-radius: 0; --border-right-radius: 0; \";\n        }\n    }\n</script>\n\n<ButtonGroup>\n    {#each buttons as button, index}\n        <div style={getBorderRadius(index, buttons.length)}>\n            {@html button}\n        </div>\n    {/each}\n</ButtonGroup>\n\n<style lang=\"scss\">\n    div {\n        display: contents;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/BlockButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getListItem } from \"@tslib/dom\";\n    import { preventDefault } from \"@tslib/events\";\n    import { getPlatformString, registerShortcut } from \"@tslib/shortcuts\";\n    import { onMount } from \"svelte\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonGroupItem, {\n        createProps,\n        setSlotHostContext,\n        updatePropsList,\n    } from \"$lib/components/ButtonGroupItem.svelte\";\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import {\n        indentIcon,\n        justifyCenterIcon,\n        justifyFullIcon,\n        justifyLeftIcon,\n        justifyRightIcon,\n        listOptionsIcon,\n        olIcon,\n        outdentIcon,\n        ulIcon,\n    } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n    import { execCommand } from \"$lib/domlib\";\n\n    import { context } from \"../NoteEditor.svelte\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n    import CommandIconButton from \"./CommandIconButton.svelte\";\n\n    export let api = {};\n\n    const outdentKeyCombination = \"Control+Shift+,\";\n    function outdentListItem() {\n        if (getListItem(document.activeElement!.shadowRoot!)) {\n            execCommand(\"outdent\");\n        } else {\n            alert(\"Indent/unindent currently only works with lists.\");\n        }\n    }\n\n    const indentKeyCombination = \"Control+Shift+.\";\n    function indentListItem() {\n        if (getListItem(document.activeElement!.shadowRoot!)) {\n            execCommand(\"indent\");\n        } else {\n            alert(\"Indent/unindent currently only works with lists.\");\n        }\n    }\n\n    onMount(() => {\n        registerShortcut((event: KeyboardEvent) => {\n            preventDefault(event);\n            indentListItem();\n        }, indentKeyCombination);\n        registerShortcut((event: KeyboardEvent) => {\n            preventDefault(event);\n            outdentListItem();\n        }, outdentKeyCombination);\n    });\n\n    const { focusedInput } = context.get();\n\n    $: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);\n\n    let showFloating = false;\n    $: if (disabled) {\n        showFloating = false;\n    }\n\n    const rtl = window.getComputedStyle(document.body).direction === \"rtl\";\n\n    const justificationKeys = [\n        \"justifyLeft\",\n        \"justifyCenter\",\n        \"justifyRight\",\n        \"justifyFull\",\n    ];\n\n    const listKeys = [\"insertUnorderedList\", \"insertOrderedList\"];\n</script>\n\n<ButtonGroup>\n    <DynamicallySlottable\n        slotHost={ButtonGroupItem}\n        {createProps}\n        {updatePropsList}\n        {setSlotHostContext}\n        {api}\n    >\n        <ButtonGroupItem>\n            <CommandIconButton\n                key=\"insertUnorderedList\"\n                tooltip={tr.editingUnorderedList()}\n                shortcut=\"Control+,\"\n                modeVariantKeys={listKeys}\n            >\n                <Icon icon={ulIcon} />\n            </CommandIconButton>\n        </ButtonGroupItem>\n\n        <ButtonGroupItem>\n            <CommandIconButton\n                key=\"insertOrderedList\"\n                tooltip={tr.editingOrderedList()}\n                shortcut=\"Control+.\"\n                modeVariantKeys={listKeys}\n            >\n                <Icon icon={olIcon} />\n            </CommandIconButton>\n        </ButtonGroupItem>\n\n        <ButtonGroupItem>\n            <WithFloating\n                show={showFloating}\n                inline\n                on:close={() => (showFloating = false)}\n                let:asReference\n            >\n                <span class=\"block-buttons\" use:asReference>\n                    <IconButton\n                        tooltip={tr.editingAlignment()}\n                        {disabled}\n                        on:click={() => (showFloating = !showFloating)}\n                    >\n                        <Icon icon={listOptionsIcon} />\n                    </IconButton>\n                </span>\n\n                <Popover slot=\"floating\" --popover-padding-inline=\"0\">\n                    <ButtonToolbar wrap={false}>\n                        <ButtonGroup>\n                            <DynamicallySlottable\n                                slotHost={ButtonGroupItem}\n                                {createProps}\n                                {updatePropsList}\n                                {setSlotHostContext}\n                                {api}\n                            >\n                                <ButtonGroupItem>\n                                    <CommandIconButton\n                                        key=\"justifyLeft\"\n                                        tooltip={tr.editingAlignLeft()}\n                                        modeVariantKeys={justificationKeys}\n                                    >\n                                        <Icon icon={justifyLeftIcon} />\n                                    </CommandIconButton>\n                                </ButtonGroupItem>\n\n                                <ButtonGroupItem>\n                                    <CommandIconButton\n                                        key=\"justifyCenter\"\n                                        tooltip={tr.editingCenter()}\n                                        modeVariantKeys={justificationKeys}\n                                    >\n                                        <Icon icon={justifyCenterIcon} />\n                                    </CommandIconButton>\n                                </ButtonGroupItem>\n\n                                <ButtonGroupItem>\n                                    <CommandIconButton\n                                        key=\"justifyRight\"\n                                        tooltip={tr.editingAlignRight()}\n                                        modeVariantKeys={justificationKeys}\n                                    >\n                                        <Icon icon={justifyRightIcon} />\n                                    </CommandIconButton>\n                                </ButtonGroupItem>\n\n                                <ButtonGroupItem>\n                                    <CommandIconButton\n                                        key=\"justifyFull\"\n                                        tooltip={tr.editingJustify()}\n                                        modeVariantKeys={justificationKeys}\n                                    >\n                                        <Icon icon={justifyFullIcon} />\n                                    </CommandIconButton>\n                                </ButtonGroupItem>\n                            </DynamicallySlottable>\n                        </ButtonGroup>\n\n                        <ButtonGroup>\n                            <IconButton\n                                tooltip=\"{tr.editingOutdent()} ({getPlatformString(\n                                    outdentKeyCombination,\n                                )})\"\n                                {disabled}\n                                flipX={rtl}\n                                on:click={outdentListItem}\n                                --border-left-radius=\"5px\"\n                                --border-right-radius=\"0px\"\n                            >\n                                <Icon icon={outdentIcon} />\n                            </IconButton>\n\n                            <IconButton\n                                tooltip=\"{tr.editingIndent()} ({getPlatformString(\n                                    indentKeyCombination,\n                                )})\"\n                                {disabled}\n                                flipX={rtl}\n                                on:click={indentListItem}\n                                --border-right-radius=\"5px\"\n                            >\n                                <Icon icon={indentIcon} />\n                            </IconButton>\n                        </ButtonGroup>\n                    </ButtonToolbar>\n                </Popover>\n            </WithFloating>\n        </ButtonGroupItem>\n    </DynamicallySlottable>\n</ButtonGroup>\n\n<style lang=\"scss\">\n    .block-buttons {\n        line-height: 1;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/BoldButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { boldIcon } from \"$lib/components/icons\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import TextAttributeButton from \"./TextAttributeButton.svelte\";\n\n    function matcher(element: HTMLElement | SVGElement, match: MatchType): void {\n        if (element.tagName === \"B\" || element.tagName === \"STRONG\") {\n            return match.remove();\n        }\n\n        const fontWeight = element.style.fontWeight;\n        if (fontWeight === \"bold\" || Number(fontWeight) >= 700) {\n            return match.clear((): void => {\n                if (\n                    removeStyleProperties(element, \"font-weight\") &&\n                    element.className.length === 0\n                ) {\n                    match.remove();\n                }\n            });\n        }\n    }\n</script>\n\n<TextAttributeButton\n    tagName=\"b\"\n    {matcher}\n    key=\"bold\"\n    tooltip={tr.editingBoldText()}\n    keyCombination=\"Control+B\"\n>\n    <Icon icon={boldIcon} />\n</TextAttributeButton>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/ColorPicker.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import { saveCustomColours } from \"@generated/backend\";\n\n    export let keyCombination: string | null = null;\n    export let value: string;\n\n    let inputRef: HTMLInputElement;\n</script>\n\n<input\n    bind:this={inputRef}\n    tabindex=\"-1\"\n    type=\"color\"\n    bind:value\n    on:input\n    on:change\n    on:click={() => saveCustomColours({})}\n/>\n\n{#if keyCombination}\n    <Shortcut {keyCombination} on:action={() => inputRef.click()} />\n{/if}\n\n<style lang=\"scss\">\n    input {\n        display: inline-block;\n        width: 100%;\n        height: 100%;\n        cursor: pointer;\n        opacity: 0;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/CommandIconButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithState from \"$lib/components/WithState.svelte\";\n    import { updateStateByKey } from \"$lib/components/WithState.svelte\";\n    import { execCommand, queryCommandState } from \"$lib/domlib\";\n\n    import { context as noteEditorContext } from \"../NoteEditor.svelte\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n\n    export let key: string;\n    export let tooltip: string;\n    export let shortcut: string | null = null;\n    export let modeVariantKeys: string[] = [key];\n\n    $: theTooltip = shortcut ? `${tooltip} (${getPlatformString(shortcut)})` : tooltip;\n\n    export let withoutState = false;\n\n    const { focusedInput } = noteEditorContext.get();\n\n    function action() {\n        execCommand(key);\n        $focusedInput?.focus();\n    }\n\n    $: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);\n</script>\n\n{#if withoutState}\n    <IconButton tooltip={theTooltip} {disabled} on:click={action}>\n        <slot />\n    </IconButton>\n\n    {#if shortcut}\n        <Shortcut keyCombination={shortcut} on:action={action} />\n    {/if}\n{:else}\n    <WithState {key} update={async () => queryCommandState(key)} let:state={active}>\n        <IconButton\n            tooltip={theTooltip}\n            {active}\n            {disabled}\n            on:click={(event) => {\n                action();\n                modeVariantKeys.map((key) => updateStateByKey(key, event));\n            }}\n        >\n            <slot />\n        </IconButton>\n\n        {#if shortcut}\n            <Shortcut\n                keyCombination={shortcut}\n                on:action={(event) => {\n                    action();\n                    modeVariantKeys.map((key) => updateStateByKey(key, event));\n                }}\n            />\n        {/if}\n    </WithState>\n{/if}\n"
  },
  {
    "path": "ts/editor/editor-toolbar/EditorToolbar.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n\n    import { resetAllState, updateAllState } from \"$lib/components/WithState.svelte\";\n    import type { DefaultSlotInterface } from \"$lib/sveltelib/dynamic-slotting\";\n\n    export function updateActiveButtons(event: Event) {\n        updateAllState(event);\n    }\n\n    export function clearActiveButtons() {\n        resetAllState(false);\n    }\n\n    export interface RemoveFormat {\n        name: string;\n        key: string;\n        show: boolean;\n        active: boolean;\n    }\n\n    export interface EditorToolbarAPI {\n        toolbar: DefaultSlotInterface;\n        notetypeButtons: DefaultSlotInterface;\n        inlineButtons: DefaultSlotInterface;\n        blockButtons: DefaultSlotInterface;\n        templateButtons: DefaultSlotInterface;\n        removeFormats: Writable<RemoveFormat[]>;\n    }\n\n    /* Our dynamic components */\n    import AddonButtons from \"./AddonButtons.svelte\";\n\n    export const editorToolbar = {\n        AddonButtons,\n    };\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n\n    const key = Symbol(\"editorToolbar\");\n    const [context, setContextProperty] = contextProperty<EditorToolbarAPI>(key);\n\n    export { context };\n</script>\n\n<script lang=\"ts\">\n    import { createEventDispatcher } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n\n    import BlockButtons from \"./BlockButtons.svelte\";\n    import ImageOcclusionButton from \"./ImageOcclusionButton.svelte\";\n    import InlineButtons from \"./InlineButtons.svelte\";\n    import NotetypeButtons from \"./NotetypeButtons.svelte\";\n    import OptionsButtons from \"./OptionsButtons.svelte\";\n    import RichTextClozeButtons from \"./RichTextClozeButtons.svelte\";\n    import TemplateButtons from \"./TemplateButtons.svelte\";\n\n    export let size: number;\n    export let wrap: boolean;\n\n    const toolbar = {} as DefaultSlotInterface;\n    const notetypeButtons = {} as DefaultSlotInterface;\n    const optionsButtons = {} as DefaultSlotInterface;\n    const inlineButtons = {} as DefaultSlotInterface;\n    const blockButtons = {} as DefaultSlotInterface;\n    const templateButtons = {} as DefaultSlotInterface;\n    const removeFormats = writable<RemoveFormat[]>([]);\n\n    let apiPartial: Partial<EditorToolbarAPI> = {};\n    export { apiPartial as api };\n\n    const api: EditorToolbarAPI = Object.assign(apiPartial, {\n        toolbar,\n        notetypeButtons,\n        inlineButtons,\n        blockButtons,\n        templateButtons,\n        removeFormats,\n    } as EditorToolbarAPI);\n\n    setContextProperty(api);\n\n    const dispatch = createEventDispatcher();\n\n    let clientHeight: number;\n    $: dispatch(\"heightChange\", { height: clientHeight });\n</script>\n\n<div class=\"editor-toolbar\" bind:clientHeight>\n    <ButtonToolbar {size} {wrap}>\n        <DynamicallySlottable slotHost={Item} api={toolbar}>\n            <Item id=\"notetype\">\n                <NotetypeButtons api={notetypeButtons}>\n                    <slot name=\"notetypeButtons\" />\n                </NotetypeButtons>\n            </Item>\n\n            <Item id=\"settings\">\n                <OptionsButtons api={optionsButtons} />\n            </Item>\n\n            <Item id=\"inlineFormatting\">\n                <InlineButtons api={inlineButtons} />\n            </Item>\n\n            <Item id=\"blockFormatting\">\n                <BlockButtons api={blockButtons} />\n            </Item>\n\n            <Item id=\"template\">\n                <TemplateButtons api={templateButtons} />\n            </Item>\n\n            <Item id=\"cloze\">\n                <RichTextClozeButtons />\n            </Item>\n\n            <Item id=\"image-occlusion-button\">\n                <ImageOcclusionButton />\n            </Item>\n        </DynamicallySlottable>\n    </ButtonToolbar>\n</div>\n\n<style lang=\"scss\">\n    .editor-toolbar {\n        padding: 0 0 4px;\n        border-bottom: 1px solid var(--border);\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/HighlightColorButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { onMount } from \"svelte\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { chevronDown } from \"$lib/components/icons\";\n    import { highlightColorIcon } from \"$lib/components/icons\";\n    import type { FormattingNode, MatchType } from \"$lib/domlib/surround\";\n\n    import { surrounder } from \"../rich-text-input\";\n    import ColorPicker from \"./ColorPicker.svelte\";\n    import { context as editorToolbarContext } from \"./EditorToolbar.svelte\";\n    import WithColorHelper from \"./WithColorHelper.svelte\";\n    import { saveCustomColours } from \"@generated/backend\";\n\n    export let color: string;\n\n    $: transformedColor = transformColor(color);\n\n    /**\n     * The DOM will transform colors such as \"#ff0000\" to \"rgb(256, 0, 0)\".\n     */\n    function transformColor(color: string): string {\n        const span = document.createElement(\"span\");\n        span.style.setProperty(\"background-color\", color);\n        return span.style.getPropertyValue(\"background-color\");\n    }\n\n    function matcher(\n        element: HTMLElement | SVGElement,\n        match: MatchType<string>,\n    ): void {\n        const value = element.style.getPropertyValue(\"background-color\");\n\n        if (value.length === 0) {\n            return;\n        }\n\n        match.setCache(value);\n        match.clear((): void => {\n            if (\n                removeStyleProperties(element, \"background-color\") &&\n                element.className.length === 0\n            ) {\n                match.remove();\n            }\n        });\n    }\n\n    function merger(\n        before: FormattingNode<string>,\n        after: FormattingNode<string>,\n    ): boolean {\n        return before.getCache(transformedColor) === after.getCache(transformedColor);\n    }\n\n    function formatter(node: FormattingNode<string>): boolean {\n        const extension = node.extensions.find(\n            (element: HTMLElement | SVGElement): boolean => element.tagName === \"SPAN\",\n        );\n        const color = node.getCache(transformedColor);\n\n        if (extension) {\n            extension.style.setProperty(\"background-color\", color);\n            return false;\n        }\n\n        const span = document.createElement(\"span\");\n        span.style.setProperty(\"background-color\", color);\n        node.range.toDOMRange().surroundContents(span);\n        return true;\n    }\n\n    const key = \"highlightColor\";\n\n    const format = {\n        matcher,\n        merger,\n        formatter,\n    };\n\n    const namedFormat = {\n        key,\n        name: tr.editingTextHighlightColor(),\n        show: true,\n        active: true,\n    };\n\n    const { removeFormats } = editorToolbarContext.get();\n    removeFormats.update((formats) => [...formats, namedFormat]);\n\n    function setTextColor(): void {\n        surrounder.overwriteSurround(key);\n    }\n\n    let disabled: boolean;\n\n    onMount(() =>\n        singleCallback(\n            surrounder.active.subscribe((value) => (disabled = !value)),\n            surrounder.registerFormat(key, format),\n        ),\n    );\n</script>\n\n<WithColorHelper {color} let:colorHelperIcon let:setColor>\n    <IconButton\n        tooltip={tr.editingTextHighlightColor()}\n        {disabled}\n        on:click={setTextColor}\n    >\n        <Icon icon={highlightColorIcon} />\n        <Icon icon={colorHelperIcon} />\n    </IconButton>\n\n    <IconButton\n        tooltip={tr.editingChangeColor()}\n        {disabled}\n        widthMultiplier={0.5}\n        iconSize={120}\n        --border-right-radius=\"5px\"\n    >\n        <Icon icon={chevronDown} />\n        <ColorPicker\n            value={color}\n            on:input={(event) => {\n                color = setColor(event);\n                bridgeCommand(`lastHighlightColor:${color}`);\n            }}\n            on:change={() => {\n                setTextColor();\n                saveCustomColours({});\n            }}\n        />\n    </IconButton>\n</WithColorHelper>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/ImageOcclusionButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonGroupItem, {\n        createProps,\n        setSlotHostContext,\n        updatePropsList,\n    } from \"$lib/components/ButtonGroupItem.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import { mdiTableRefresh, mdiViewDashboard } from \"$lib/components/icons\";\n\n    import {\n        ioImageLoadedStore,\n        ioMaskEditorVisible,\n    } from \"../../routes/image-occlusion/store\";\n    import { toggleMaskEditorKeyCombination } from \"../../routes/image-occlusion/tools/shortcuts\";\n\n    export let api = {};\n</script>\n\n<ButtonGroup>\n    <DynamicallySlottable\n        slotHost={ButtonGroupItem}\n        {createProps}\n        {updatePropsList}\n        {setSlotHostContext}\n        {api}\n    >\n        <ButtonGroupItem>\n            <IconButton\n                id=\"io-mask-btn\"\n                class={$ioMaskEditorVisible ? \"active-io-btn\" : \"\"}\n                on:click={() => {\n                    $ioMaskEditorVisible = !$ioMaskEditorVisible;\n                }}\n                tooltip=\"{tr.editingImageOcclusionToggleMaskEditor()} ({toggleMaskEditorKeyCombination})\"\n            >\n                <Icon icon={mdiViewDashboard} />\n            </IconButton>\n            <Shortcut\n                keyCombination={toggleMaskEditorKeyCombination}\n                on:action={() => {\n                    $ioMaskEditorVisible = !$ioMaskEditorVisible;\n                }}\n            />\n        </ButtonGroupItem>\n        <ButtonGroupItem>\n            <IconButton\n                id=\"io-reset-btn\"\n                disabled={!$ioImageLoadedStore}\n                on:click={() => {\n                    if (confirm(tr.editingImageOcclusionConfirmReset())) {\n                        globalThis.resetIOImageLoaded();\n                    } else {\n                        return;\n                    }\n                }}\n                tooltip={tr.editingImageOcclusionReset()}\n            >\n                <Icon icon={mdiTableRefresh} />\n            </IconButton>\n        </ButtonGroupItem>\n    </DynamicallySlottable>\n</ButtonGroup>\n\n<style>\n    :global(.active-io-btn) {\n        background: var(--button-primary-bg) !important;\n        color: white !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/InlineButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n\n    import BoldButton from \"./BoldButton.svelte\";\n    import HighlightColorButton from \"./HighlightColorButton.svelte\";\n    import ItalicButton from \"./ItalicButton.svelte\";\n    import RemoveFormatButton from \"./RemoveFormatButton.svelte\";\n    import SubscriptButton from \"./SubscriptButton.svelte\";\n    import SuperscriptButton from \"./SuperscriptButton.svelte\";\n    import TextColorButton from \"./TextColorButton.svelte\";\n    import UnderlineButton from \"./UnderlineButton.svelte\";\n\n    export let api = {};\n\n    let textColor: string = \"black\";\n    let highlightColor: string = \"black\";\n    export function setColorButtons([textClr, highlightClr]: [string, string]): void {\n        textColor = textClr;\n        highlightColor = highlightClr;\n    }\n\n    Object.assign(globalThis, { setColorButtons });\n</script>\n\n<DynamicallySlottable slotHost={Item} {api}>\n    <Item>\n        <ButtonGroup>\n            <BoldButton --border-left-radius=\"5px\" />\n            <ItalicButton />\n            <UnderlineButton --border-right-radius=\"5px\" />\n        </ButtonGroup>\n    </Item>\n\n    <Item>\n        <ButtonGroup>\n            <SuperscriptButton --border-left-radius=\"5px\" />\n            <SubscriptButton --border-right-radius=\"5px\" />\n        </ButtonGroup>\n    </Item>\n\n    <Item>\n        <ButtonGroup>\n            <TextColorButton color={textColor} />\n            <HighlightColorButton color={highlightColor} />\n        </ButtonGroup>\n    </Item>\n\n    <Item>\n        <ButtonGroup>\n            <RemoveFormatButton />\n        </ButtonGroup>\n    </Item>\n</DynamicallySlottable>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/ItalicButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { italicIcon } from \"$lib/components/icons\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import TextAttributeButton from \"./TextAttributeButton.svelte\";\n\n    function matcher(element: HTMLElement | SVGElement, match: MatchType): void {\n        if (element.tagName === \"I\" || element.tagName === \"EM\") {\n            return match.remove();\n        }\n\n        if ([\"italic\", \"oblique\"].includes(element.style.fontStyle)) {\n            return match.clear((): void => {\n                if (\n                    removeStyleProperties(element, \"font-style\") &&\n                    element.className.length === 0\n                ) {\n                    return match.remove();\n                }\n            });\n        }\n    }\n</script>\n\n<TextAttributeButton\n    tagName=\"i\"\n    {matcher}\n    key=\"italic\"\n    tooltip={tr.editingItalicText()}\n    keyCombination=\"Control+I\"\n>\n    <Icon icon={italicIcon} />\n</TextAttributeButton>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/LatexButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { wrapInternal } from \"@tslib/wrap\";\n\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { functionIcon } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    import { mathjaxConfig } from \"../../editable/mathjax-element.svelte\";\n    import { context as noteEditorContext } from \"../NoteEditor.svelte\";\n    import type { RichTextInputAPI } from \"../rich-text-input\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n\n    const { focusedInput } = noteEditorContext.get();\n    $: richTextAPI = $focusedInput as RichTextInputAPI;\n\n    async function surround(front: string, back: string): Promise<void> {\n        const element = await richTextAPI.element;\n        wrapInternal(element, front, back, false);\n    }\n\n    function onMathjaxInline(): void {\n        if (mathjaxConfig.enabled) {\n            surround(\"<anki-mathjax focusonmount>\", \"</anki-mathjax>\");\n        } else {\n            surround(\"\\\\(\", \"\\\\)\");\n        }\n    }\n\n    function onMathjaxBlock(): void {\n        if (mathjaxConfig.enabled) {\n            surround('<anki-mathjax block=\"true\" focusonmount>', \"</anki-matjax>\");\n        } else {\n            surround(\"\\\\[\", \"\\\\]\");\n        }\n    }\n\n    function onMathjaxChemistry(): void {\n        if (mathjaxConfig.enabled) {\n            surround('<anki-mathjax focusonmount=\"0,4\">\\\\ce{', \"}</anki-mathjax>\");\n        } else {\n            surround(\"\\\\(\\\\ce{\", \"}\\\\)\");\n        }\n    }\n\n    function onLatex(): void {\n        surround(\"[latex]\", \"[/latex]\");\n    }\n\n    function onLatexEquation(): void {\n        surround(\"[$]\", \"[/$]\");\n    }\n\n    function onLatexMathEnv(): void {\n        surround(\"[$$]\", \"[/$$]\");\n    }\n\n    type LatexItem = [() => void, string, string];\n\n    const dropdownItems: LatexItem[] = [\n        [onMathjaxInline, \"Control+M, M\", tr.editingMathjaxInline()],\n        [onMathjaxBlock, \"Control+M, E\", tr.editingMathjaxBlock()],\n        [onMathjaxChemistry, \"Control+M, C\", tr.editingMathjaxChemistry()],\n        [onLatex, \"Control+T, T\", tr.editingLatex()],\n        [onLatexEquation, \"Control+T, E\", tr.editingLatexEquation()],\n        [onLatexMathEnv, \"Control+T, M\", tr.editingLatexMathEnv()],\n    ];\n\n    $: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);\n\n    let showFloating = false;\n    $: if (disabled) {\n        showFloating = false;\n    }\n</script>\n\n<WithFloating\n    show={showFloating}\n    closeOnInsideClick\n    inline\n    on:close={() => (showFloating = false)}\n>\n    <IconButton\n        slot=\"reference\"\n        tooltip={tr.editingEquations()}\n        {disabled}\n        on:click={() => (showFloating = !showFloating)}\n    >\n        <Icon icon={functionIcon} />\n    </IconButton>\n\n    <Popover slot=\"floating\" --popover-padding-inline=\"0\">\n        {#each dropdownItems as [callback, keyCombination, label]}\n            <DropdownItem on:click={() => setTimeout(callback, 100)}>\n                <span>{label}</span>\n                <span class=\"ms-auto ps-2 shortcut\">\n                    {getPlatformString(keyCombination)}\n                </span>\n            </DropdownItem>\n        {/each}\n    </Popover>\n</WithFloating>\n\n{#each dropdownItems as [callback, keyCombination]}\n    <Shortcut {keyCombination} on:action={callback} />\n{/each}\n\n<style lang=\"scss\">\n    .shortcut {\n        font: Verdana;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/NotetypeButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonGroupItem, {\n        createProps,\n        setSlotHostContext,\n        updatePropsList,\n    } from \"$lib/components/ButtonGroupItem.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    export let api = {};\n\n    const keyCombination = \"Control+L\";\n</script>\n\n<ButtonGroup>\n    <DynamicallySlottable\n        slotHost={ButtonGroupItem}\n        {createProps}\n        {updatePropsList}\n        {setSlotHostContext}\n        {api}\n    >\n        <ButtonGroupItem>\n            <LabelButton\n                tooltip={tr.editingCustomizeFields()}\n                on:click={() => bridgeCommand(\"fields\")}\n            >\n                {tr.editingFields()}...\n            </LabelButton>\n        </ButtonGroupItem>\n\n        <ButtonGroupItem>\n            <LabelButton\n                tooltip=\"{tr.editingCustomizeCardTemplates()} ({getPlatformString(\n                    keyCombination,\n                )})\"\n                on:click={() => bridgeCommand(\"cards\")}\n            >\n                {tr.editingCards()}...\n            </LabelButton>\n            <Shortcut {keyCombination} on:action={() => bridgeCommand(\"cards\")} />\n        </ButtonGroupItem>\n\n        <slot />\n    </DynamicallySlottable>\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/OptionsButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n\n    import CheckBox from \"$lib/components/CheckBox.svelte\";\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { cogIcon } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    import { mathjaxConfig } from \"../../editable/mathjax-element.svelte\";\n    import { shrinkImagesByDefault } from \"../image-overlay/ImageOverlay.svelte\";\n    import { closeHTMLTags } from \"../plain-text-input/PlainTextInput.svelte\";\n\n    let showFloating = false;\n\n    function toggleShrinkImages(_evt: MouseEvent): void {\n        $shrinkImagesByDefault = !$shrinkImagesByDefault;\n        bridgeCommand(\"toggleShrinkImages\");\n        showFloating = false;\n    }\n\n    function toggleShowMathjax(_evt: MouseEvent): void {\n        mathjaxConfig.enabled = !mathjaxConfig.enabled;\n        bridgeCommand(\"toggleMathjax\");\n    }\n\n    function toggleCloseHTMLTags(_evt: MouseEvent): void {\n        $closeHTMLTags = !$closeHTMLTags;\n        bridgeCommand(\"toggleCloseHTMLTags\");\n        showFloating = false;\n    }\n</script>\n\n<WithFloating show={showFloating} inline on:close={() => (showFloating = false)}>\n    <IconButton\n        slot=\"reference\"\n        tooltip={tr.actionsOptions()}\n        --border-left-radius=\"5px\"\n        --border-right-radius=\"5px\"\n        --padding-inline=\"8px\"\n        on:click={() => (showFloating = !showFloating)}\n    >\n        <Icon icon={cogIcon} />\n    </IconButton>\n\n    <Popover slot=\"floating\" --popover-padding-inline=\"0\">\n        <DropdownItem on:click={toggleShrinkImages}>\n            <CheckBox value={$shrinkImagesByDefault} />\n            <span class=\"d-flex-inline ps-3\">{tr.editingShrinkImages()}</span>\n        </DropdownItem>\n        <DropdownItem on:click={toggleShowMathjax}>\n            <CheckBox value={mathjaxConfig.enabled} />\n            <span class=\"d-flex-inline ps-3\">{tr.editingMathjaxPreview()}</span>\n        </DropdownItem>\n        <DropdownItem on:click={toggleCloseHTMLTags}>\n            <CheckBox value={$closeHTMLTags} />\n            <span class=\"d-flex-inline ps-3\">{tr.editingCloseHtmlTags()}</span>\n        </DropdownItem>\n    </Popover>\n</WithFloating>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/OptionsButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonGroupItem, {\n        createProps,\n        setSlotHostContext,\n        updatePropsList,\n    } from \"$lib/components/ButtonGroupItem.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n\n    import OptionsButton from \"./OptionsButton.svelte\";\n\n    export let api = {};\n</script>\n\n<ButtonGroup>\n    <DynamicallySlottable\n        slotHost={ButtonGroupItem}\n        {createProps}\n        {updatePropsList}\n        {setSlotHostContext}\n        {api}\n    >\n        <ButtonGroupItem id=\"options\">\n            <OptionsButton />\n        </ButtonGroupItem>\n    </DynamicallySlottable>\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/RemoveFormatButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { altPressed, shiftPressed } from \"@tslib/keys\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { onMount } from \"svelte\";\n\n    import CheckBox from \"$lib/components/CheckBox.svelte\";\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { chevronDown } from \"$lib/components/icons\";\n    import { eraserIcon } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import { surrounder } from \"../rich-text-input\";\n    import type { RemoveFormat } from \"./EditorToolbar.svelte\";\n    import { context as editorToolbarContext } from \"./EditorToolbar.svelte\";\n\n    const { removeFormats } = editorToolbarContext.get();\n\n    function filterForKeys(formats: RemoveFormat[], value: boolean): string[] {\n        return formats\n            .filter((format) => format.active === value)\n            .map((format) => format.key);\n    }\n\n    let activeKeys: string[];\n    $: activeKeys = filterForKeys($removeFormats, true);\n\n    let inactiveKeys: string[];\n    $: inactiveKeys = filterForKeys($removeFormats, false);\n\n    let showFormats: RemoveFormat[];\n    $: showFormats = $removeFormats.filter(\n        (format: RemoveFormat): boolean => format.show,\n    );\n\n    function remove(): void {\n        surrounder.remove(activeKeys, inactiveKeys);\n    }\n\n    function onItemClick(event: MouseEvent, format: RemoveFormat): void {\n        if (altPressed(event)) {\n            const value = shiftPressed(event);\n\n            for (const format of showFormats) {\n                format.active = value;\n            }\n        }\n\n        format.active = !format.active;\n        $removeFormats = $removeFormats;\n    }\n\n    const keyCombination = \"Control+R\";\n\n    let disabled: boolean;\n    let showFloating = false;\n    $: if (disabled) {\n        showFloating = false;\n    }\n\n    onMount(() => {\n        const surroundElement = document.createElement(\"span\");\n\n        function matcher(\n            element: HTMLElement | SVGElement,\n            match: MatchType<never>,\n        ): void {\n            if (\n                element.tagName === \"SPAN\" &&\n                element.className.length === 0 &&\n                element.style.cssText.length === 0\n            ) {\n                match.remove();\n            }\n        }\n\n        const simpleSpans = {\n            matcher,\n            surroundElement,\n        };\n\n        const key = \"simple spans\";\n\n        removeFormats.update((formats: RemoveFormat[]): RemoveFormat[] => [\n            ...formats,\n            {\n                key,\n                name: key,\n                show: false,\n                active: true,\n            },\n        ]);\n\n        return singleCallback(\n            surrounder.active.subscribe((value) => (disabled = !value)),\n            surrounder.registerFormat(key, simpleSpans),\n        );\n    });\n</script>\n\n<IconButton\n    tooltip=\"{tr.editingRemoveFormatting()} ({getPlatformString(keyCombination)})\"\n    {disabled}\n    on:click={remove}\n    --border-left-radius=\"5px\"\n>\n    <Icon icon={eraserIcon} />\n</IconButton>\n\n<Shortcut {keyCombination} on:action={remove} />\n\n<WithFloating show={showFloating} inline on:close={() => (showFloating = false)}>\n    <IconButton\n        slot=\"reference\"\n        class=\"remove-format-button\"\n        tooltip={tr.editingSelectRemoveFormatting()}\n        {disabled}\n        widthMultiplier={0.5}\n        iconSize={120}\n        --border-right-radius=\"5px\"\n        on:click={() => (showFloating = !showFloating)}\n    >\n        <Icon icon={chevronDown} />\n    </IconButton>\n\n    <Popover slot=\"floating\" --popover-padding-inline=\"0\">\n        {#each showFormats as format (format.name)}\n            <DropdownItem on:click={(event) => onItemClick(event, format)}>\n                <CheckBox bind:value={format.active} />\n                <span class=\"d-flex-inline ps-3\">{format.name}</span>\n            </DropdownItem>\n        {/each}\n    </Popover>\n</WithFloating>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/RichTextClozeButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { wrapInternal } from \"@tslib/wrap\";\n\n    import ClozeButtons from \"../ClozeButtons.svelte\";\n    import { context as noteEditorContext } from \"../NoteEditor.svelte\";\n    import type { RichTextInputAPI } from \"../rich-text-input\";\n\n    const { focusedInput } = noteEditorContext.get();\n\n    $: richTextAPI = $focusedInput as RichTextInputAPI;\n\n    async function onSurround({ detail }): Promise<void> {\n        if (!richTextAPI.isClozeField) {\n            return;\n        }\n        const richText = await richTextAPI.element;\n        const { prefix, suffix } = detail;\n\n        wrapInternal(richText, prefix, suffix, false);\n    }\n</script>\n\n<ClozeButtons on:surround={onSurround} />\n"
  },
  {
    "path": "ts/editor/editor-toolbar/SubscriptButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { subscriptIcon } from \"$lib/components/icons\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import TextAttributeButton from \"./TextAttributeButton.svelte\";\n\n    export function matcher(element: HTMLElement | SVGElement, match: MatchType): void {\n        if (element.tagName === \"SUB\") {\n            return match.remove();\n        }\n\n        if (element.style.verticalAlign === \"sub\") {\n            return match.clear((): void => {\n                if (\n                    removeStyleProperties(element, \"vertical-align\") &&\n                    element.className.length === 0\n                ) {\n                    return match.remove();\n                }\n            });\n        }\n    }\n</script>\n\n<TextAttributeButton\n    tagName=\"sub\"\n    {matcher}\n    key=\"subscript\"\n    tooltip={tr.editingSubscript()}\n    keyCombination=\"Control+Shift+=\"\n    exclusiveNames={[\"superscript\"]}\n>\n    <Icon icon={subscriptIcon} />\n</TextAttributeButton>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/SuperscriptButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { superscriptIcon } from \"$lib/components/icons\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import TextAttributeButton from \"./TextAttributeButton.svelte\";\n\n    export function matcher(element: HTMLElement | SVGElement, match: MatchType): void {\n        if (element.tagName === \"SUP\") {\n            return match.remove();\n        }\n\n        if (element.style.verticalAlign === \"super\") {\n            return match.clear((): void => {\n                if (\n                    removeStyleProperties(element, \"vertical-align\") &&\n                    element.className.length === 0\n                ) {\n                    return match.remove();\n                }\n            });\n        }\n    }\n</script>\n\n<TextAttributeButton\n    tagName=\"sup\"\n    {matcher}\n    key=\"superscript\"\n    tooltip={tr.editingSuperscript()}\n    keyCombination=\"Control+=\"\n    exclusiveNames={[\"subscript\"]}\n>\n    <Icon icon={superscriptIcon} />\n</TextAttributeButton>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/TemplateButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { promiseWithResolver } from \"@tslib/promise\";\n    import { registerPackage } from \"@tslib/runtime-require\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonGroupItem, {\n        createProps,\n        setSlotHostContext,\n        updatePropsList,\n    } from \"$lib/components/ButtonGroupItem.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { micIcon, paperclipIcon } from \"$lib/components/icons\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    import { context } from \"../NoteEditor.svelte\";\n    import { setFormat } from \"../old-editor-adapter\";\n    import type { RichTextInputAPI } from \"../rich-text-input\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n    import LatexButton from \"./LatexButton.svelte\";\n\n    const { focusedInput } = context.get();\n\n    const attachmentCombination = \"F3\";\n\n    let mediaPromise: Promise<string>;\n    let resolve: (media: string) => void;\n\n    function resolveMedia(media: string): void {\n        resolve?.(media);\n    }\n\n    function attachMediaOnFocus(): void {\n        if (disabled) {\n            return;\n        }\n\n        [mediaPromise, resolve] = promiseWithResolver<string>();\n        ($focusedInput as RichTextInputAPI).editable.focusHandler.focus.on(\n            async () => setFormat(\"inserthtml\", await mediaPromise),\n            { once: true },\n        );\n\n        bridgeCommand(\"attach\");\n    }\n\n    registerPackage(\"anki/TemplateButtons\", {\n        resolveMedia,\n    });\n\n    const recordCombination = \"F5\";\n\n    function attachRecordingOnFocus(): void {\n        if (disabled) {\n            return;\n        }\n\n        [mediaPromise, resolve] = promiseWithResolver<string>();\n        ($focusedInput as RichTextInputAPI).editable.focusHandler.focus.on(\n            async () => setFormat(\"inserthtml\", await mediaPromise),\n            { once: true },\n        );\n\n        bridgeCommand(\"record\");\n    }\n\n    $: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);\n\n    export let api = {};\n</script>\n\n<ButtonGroup>\n    <DynamicallySlottable\n        slotHost={ButtonGroupItem}\n        {createProps}\n        {updatePropsList}\n        {setSlotHostContext}\n        {api}\n    >\n        <ButtonGroupItem>\n            <IconButton\n                tooltip=\"{tr.editingAttachPicturesaudiovideo()} ({getPlatformString(\n                    attachmentCombination,\n                )})\"\n                iconSize={70}\n                {disabled}\n                on:click={attachMediaOnFocus}\n            >\n                <Icon icon={paperclipIcon} />\n            </IconButton>\n            <Shortcut\n                keyCombination={attachmentCombination}\n                on:action={attachMediaOnFocus}\n            />\n        </ButtonGroupItem>\n\n        <ButtonGroupItem>\n            <IconButton\n                tooltip=\"{tr.editingRecordAudio()} ({getPlatformString(\n                    recordCombination,\n                )})\"\n                iconSize={70}\n                {disabled}\n                on:click={attachRecordingOnFocus}\n            >\n                <Icon icon={micIcon} />\n            </IconButton>\n            <Shortcut\n                keyCombination={recordCombination}\n                on:action={attachRecordingOnFocus}\n            />\n        </ButtonGroupItem>\n\n        <ButtonGroupItem>\n            <LatexButton />\n        </ButtonGroupItem>\n    </DynamicallySlottable>\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/TextAttributeButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { onMount } from \"svelte\";\n\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithState, { updateStateByKey } from \"$lib/components/WithState.svelte\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import { surrounder } from \"../rich-text-input\";\n    import { context as editorToolbarContext } from \"./EditorToolbar.svelte\";\n\n    export let tagName;\n    export let matcher: (element: HTMLElement | SVGElement, match: MatchType) => void;\n    export let key: string;\n    export let tooltip: string;\n    export let keyCombination: string;\n    export let exclusiveNames: string[] = [];\n\n    const surroundElement = document.createElement(tagName);\n\n    const format = {\n        surroundElement,\n        matcher,\n    };\n\n    const namedFormat = {\n        key,\n        name: tooltip,\n        show: true,\n        active: true,\n    };\n\n    const { removeFormats } = editorToolbarContext.get();\n    removeFormats.update((formats) => [...formats, namedFormat]);\n\n    async function updateStateFromActiveInput(): Promise<boolean> {\n        return disabled ? false : surrounder.isSurrounded(key);\n    }\n\n    function applyAttribute(): void {\n        surrounder.surround(key, exclusiveNames);\n    }\n\n    let disabled: boolean;\n\n    onMount(() =>\n        singleCallback(\n            surrounder.active.subscribe((value) => (disabled = !value)),\n            surrounder.registerFormat(key, format),\n        ),\n    );\n</script>\n\n<WithState {key} update={updateStateFromActiveInput} let:state={active} let:updateState>\n    <IconButton\n        tooltip=\"{tooltip} ({getPlatformString(keyCombination)})\"\n        {active}\n        {disabled}\n        on:click={(event) => {\n            applyAttribute();\n            updateState(event);\n            exclusiveNames.map((name) => updateStateByKey(name, event));\n        }}\n    >\n        <slot />\n    </IconButton>\n\n    <Shortcut\n        {keyCombination}\n        on:action={(event) => {\n            applyAttribute();\n            updateState(event);\n            exclusiveNames.map((name) => updateStateByKey(name, event));\n        }}\n    />\n</WithState>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/TextColorButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { onMount } from \"svelte\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { chevronDown } from \"$lib/components/icons\";\n    import { textColorIcon } from \"$lib/components/icons\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import type { FormattingNode, MatchType } from \"$lib/domlib/surround\";\n\n    import { withFontColor } from \"../helpers\";\n    import { surrounder } from \"../rich-text-input\";\n    import ColorPicker from \"./ColorPicker.svelte\";\n    import { context as editorToolbarContext } from \"./EditorToolbar.svelte\";\n    import WithColorHelper from \"./WithColorHelper.svelte\";\n    import { saveCustomColours } from \"@generated/backend\";\n\n    export let color: string;\n\n    $: transformedColor = transformColor(color);\n\n    /**\n     * The DOM will transform colors such as \"#ff0000\" to \"rgb(255, 0, 0)\".\n     */\n    function transformColor(color: string): string {\n        const span = document.createElement(\"span\");\n        span.style.setProperty(\"color\", color);\n        return span.style.getPropertyValue(\"color\");\n    }\n\n    function matcher(\n        element: HTMLElement | SVGElement,\n        match: MatchType<string>,\n    ): void {\n        if (\n            withFontColor(element, (color: string): void => {\n                if (color) {\n                    match.setCache(color);\n                    match.remove();\n                }\n            })\n        ) {\n            return;\n        }\n\n        const value = element.style.getPropertyValue(\"color\");\n\n        if (value.length === 0) {\n            return;\n        }\n\n        match.setCache(value);\n        match.clear((): void => {\n            if (\n                removeStyleProperties(element, \"color\") &&\n                element.className.length === 0\n            ) {\n                match.remove();\n            }\n        });\n    }\n\n    function merger(\n        before: FormattingNode<string>,\n        after: FormattingNode<string>,\n    ): boolean {\n        return before.getCache(transformedColor) === after.getCache(transformedColor);\n    }\n\n    function formatter(node: FormattingNode<string>): boolean {\n        const extension = node.extensions.find(\n            (element: HTMLElement | SVGElement): boolean => element.tagName === \"SPAN\",\n        );\n        const color = node.getCache(transformedColor);\n\n        if (extension) {\n            extension.style.setProperty(\"color\", color);\n            return false;\n        }\n\n        const span = document.createElement(\"span\");\n        span.style.setProperty(\"color\", color);\n        node.range.toDOMRange().surroundContents(span);\n        return true;\n    }\n\n    const key = \"textColor\";\n\n    const format = {\n        matcher,\n        merger,\n        formatter,\n    };\n\n    const namedFormat = {\n        key,\n        name: tr.editingTextColor(),\n        show: true,\n        active: true,\n    };\n\n    const { removeFormats } = editorToolbarContext.get();\n    removeFormats.update((formats) => [...formats, namedFormat]);\n\n    function setTextColor(): void {\n        surrounder.overwriteSurround(key);\n    }\n\n    const setCombination = \"F7\";\n    const pickCombination = \"F8\";\n\n    let disabled: boolean;\n\n    onMount(() =>\n        singleCallback(\n            surrounder.active.subscribe((value) => (disabled = !value)),\n            surrounder.registerFormat(key, format),\n        ),\n    );\n</script>\n\n<WithColorHelper {color} let:colorHelperIcon let:setColor>\n    <IconButton\n        tooltip=\"{tr.editingTextColor()} ({getPlatformString(setCombination)})\"\n        {disabled}\n        on:click={setTextColor}\n        --border-left-radius=\"5px\"\n    >\n        <Icon icon={textColorIcon} />\n        <Icon icon={colorHelperIcon} />\n    </IconButton>\n    <Shortcut keyCombination={setCombination} on:action={setTextColor} />\n\n    <IconButton\n        tooltip=\"{tr.editingChangeColor()} ({getPlatformString(pickCombination)})\"\n        {disabled}\n        widthMultiplier={0.5}\n        iconSize={120}\n    >\n        <Icon icon={chevronDown} />\n        <ColorPicker\n            keyCombination={pickCombination}\n            value={color}\n            on:input={(event) => {\n                color = setColor(event);\n                bridgeCommand(`lastTextColor:${color}`);\n            }}\n            on:change={() => {\n                // Delay added to work around intermittent failures on macOS/Qt6.5\n                setTimeout(() => {\n                    setTextColor();\n                }, 200);\n                saveCustomColours({});\n            }}\n        />\n    </IconButton>\n</WithColorHelper>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/UnderlineButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { underlineIcon } from \"$lib/components/icons\";\n    import type { MatchType } from \"$lib/domlib/surround\";\n\n    import TextAttributeButton from \"./TextAttributeButton.svelte\";\n\n    function matcher(element: HTMLElement | SVGElement, match: MatchType): void {\n        if (element.tagName === \"U\") {\n            return match.remove();\n        }\n    }\n</script>\n\n<TextAttributeButton\n    tagName=\"u\"\n    {matcher}\n    key=\"underline\"\n    tooltip={tr.editingUnderlineText()}\n    keyCombination=\"Control+U\"\n>\n    <Icon icon={underlineIcon} />\n</TextAttributeButton>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/WithColorHelper.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { colorHelperIcon } from \"$lib/components/icons\";\n\n    export let color: string;\n\n    function setColor({ currentTarget }: Event): string {\n        color = (currentTarget! as HTMLInputElement).value;\n        return color;\n    }\n</script>\n\n<div style=\"--color-helper-color: {color}\">\n    <slot {colorHelperIcon} {setColor} />\n</div>\n\n<style lang=\"scss\">\n    div {\n        display: contents;\n\n        :global(#mdi-color-helper) {\n            fill: var(--color-helper-color);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/editor-toolbar/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport EditorToolbar from \"./EditorToolbar.svelte\";\n\nexport type { EditorToolbarAPI } from \"./EditorToolbar.svelte\";\nexport default EditorToolbar;\nexport { editorToolbar } from \"./EditorToolbar.svelte\";\n"
  },
  {
    "path": "ts/editor/helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfunction isFontElement(element: Element): element is HTMLFontElement {\n    return element.tagName === \"FONT\";\n}\n\n/**\n * Avoid both HTMLFontElement and .color, as they are both deprecated\n */\nexport function withFontColor(\n    element: Element,\n    callback: (color: string) => void,\n): boolean {\n    if (isFontElement(element)) {\n        callback(element.color);\n        return true;\n    }\n\n    return false;\n}\n\nexport class Flag {\n    private flag: boolean;\n\n    constructor() {\n        this.flag = false;\n    }\n\n    setFlag(on: boolean): void {\n        this.flag = on;\n    }\n\n    /** Resets the flag to false and returns the previous value. */\n    checkAndReset(): boolean {\n        const val = this.flag;\n        this.flag = false;\n        return val;\n    }\n}\n"
  },
  {
    "path": "ts/editor/image-overlay/FloatButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import {\n        floatLeftIcon,\n        floatNoneIcon,\n        floatRightIcon,\n    } from \"$lib/components/icons\";\n\n    export let image: HTMLImageElement;\n\n    $: floatStyle = getComputedStyle(image).float;\n\n    const dispatch = createEventDispatcher();\n</script>\n\n<ButtonGroup size={1.6} wrap={false}>\n    <IconButton\n        tooltip={tr.editingFloatLeft()}\n        active={floatStyle === \"left\"}\n        on:click={() => {\n            image.style.float = \"left\";\n            setTimeout(() => dispatch(\"update\"));\n        }}\n        --border-left-radius=\"5px\"\n    >\n        <Icon icon={floatLeftIcon} />\n    </IconButton>\n\n    <IconButton\n        tooltip={tr.editingFloatNone()}\n        active={floatStyle === \"none\"}\n        on:click={() => {\n            // We shortly set to none, because simply unsetting float will not\n            // trigger floatStyle being reset\n            image.style.float = \"none\";\n            removeStyleProperties(image, \"float\");\n            setTimeout(() => dispatch(\"update\"));\n        }}\n    >\n        <Icon icon={floatNoneIcon} />\n    </IconButton>\n\n    <IconButton\n        tooltip={tr.editingFloatRight()}\n        active={floatStyle === \"right\"}\n        on:click={() => {\n            image.style.float = \"right\";\n            setTimeout(() => dispatch(\"update\"));\n        }}\n        --border-right-radius=\"5px\"\n    >\n        <Icon icon={floatRightIcon} />\n    </IconButton>\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/image-overlay/ImageOverlay.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\" context=\"module\">\n    import { writable } from \"svelte/store\";\n\n    export const shrinkImagesByDefault = writable(true);\n</script>\n\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { on } from \"@tslib/events\";\n    import { removeStyleProperties } from \"@tslib/styling\";\n    import type { Callback } from \"@tslib/typing\";\n    import { tick } from \"svelte\";\n\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n    import WithOverlay from \"$lib/components/WithOverlay.svelte\";\n\n    import type { EditingInputAPI } from \"../EditingArea.svelte\";\n    import HandleBackground from \"../HandleBackground.svelte\";\n    import HandleControl from \"../HandleControl.svelte\";\n    import HandleLabel from \"../HandleLabel.svelte\";\n    import { context } from \"../NoteEditor.svelte\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n    import FloatButtons from \"./FloatButtons.svelte\";\n    import SizeSelect from \"./SizeSelect.svelte\";\n\n    export let maxWidth: number;\n    export let maxHeight: number;\n\n    (<[string, number][]>[\n        [\"--editor-shrink-max-width\", maxWidth],\n        [\"--editor-shrink-max-height\", maxHeight],\n        [\"--editor-default-max-width\", maxWidth],\n        [\"--editor-default-max-height\", maxHeight],\n    ]).forEach(([prop, value]) =>\n        document.documentElement.style.setProperty(prop, `${value}px`),\n    );\n\n    $: document.documentElement.classList.toggle(\n        \"shrink-image\",\n        $shrinkImagesByDefault,\n    );\n\n    const { focusedInput } = context.get();\n\n    let cleanup: Callback;\n\n    async function initialize(input: EditingInputAPI | null): Promise<void> {\n        cleanup?.();\n\n        if (!input || !editingInputIsRichText(input)) {\n            return;\n        }\n\n        cleanup = on(await input.element, \"click\", maybeShowHandle);\n    }\n\n    $: initialize($focusedInput);\n\n    let activeImage: HTMLImageElement | null = null;\n\n    /**\n     * Returns the value if set, otherwise null.\n     */\n    function getBooleanDatasetAttribute(\n        element: HTMLElement | SVGElement,\n        attribute: string,\n    ): boolean | null {\n        return attribute in element.dataset\n            ? element.dataset[attribute] !== \"false\"\n            : null;\n    }\n\n    let isSizeConstrained = false;\n    $: {\n        if (activeImage) {\n            isSizeConstrained =\n                getBooleanDatasetAttribute(activeImage, \"editorShrink\") ??\n                $shrinkImagesByDefault;\n        }\n    }\n\n    async function resetHandle(): Promise<void> {\n        activeImage = null;\n        await tick();\n    }\n\n    let naturalWidth: number;\n    let naturalHeight: number;\n    let aspectRatio: number;\n\n    function updateDimensions() {\n        /* we do not want the actual width, but rather the intended display width */\n        const widthAttribute = activeImage!.getAttribute(\"width\");\n        customDimensions = false;\n\n        if (widthAttribute) {\n            actualWidth = widthAttribute;\n            customDimensions = true;\n        } else {\n            actualWidth = String(naturalWidth);\n        }\n\n        const heightAttribute = activeImage!.getAttribute(\"height\");\n        if (heightAttribute) {\n            actualHeight = heightAttribute;\n            customDimensions = true;\n        } else if (customDimensions) {\n            actualHeight = String(Math.trunc(Number(actualWidth) / aspectRatio));\n        } else {\n            actualHeight = String(naturalHeight);\n        }\n    }\n\n    async function maybeShowHandle(event: Event): Promise<void> {\n        if (event.target instanceof HTMLImageElement) {\n            const image = event.target;\n\n            if (!image.dataset.anki) {\n                activeImage = image;\n\n                naturalWidth = activeImage?.naturalWidth;\n                naturalHeight = activeImage?.naturalHeight;\n                aspectRatio =\n                    naturalWidth && naturalHeight ? naturalWidth / naturalHeight : NaN;\n\n                updateDimensions();\n            }\n        }\n    }\n\n    let customDimensions: boolean = false;\n    let actualWidth = \"\";\n    let actualHeight = \"\";\n\n    /* memoized position of image on resize start\n     * prevents frantic behavior when image shift into the next/previous line */\n    let getDragWidth: (event: PointerEvent) => number;\n    let getDragHeight: (event: PointerEvent) => number;\n\n    function setPointerCapture({ detail }: CustomEvent): void {\n        const pointerId = detail.originalEvent.pointerId;\n\n        if (pointerId !== 1) {\n            return;\n        }\n\n        const imageRect = activeImage!.getBoundingClientRect();\n\n        const imageLeft = imageRect!.left;\n        const imageRight = imageRect!.right;\n        const [multX, imageX] = detail.west ? [-1, imageRight] : [1, -imageLeft];\n\n        getDragWidth = ({ clientX }) => multX * clientX + imageX;\n\n        const imageTop = imageRect!.top;\n        const imageBottom = imageRect!.bottom;\n        const [multY, imageY] = detail.north ? [-1, imageBottom] : [1, -imageTop];\n\n        getDragHeight = ({ clientY }) => multY * clientY + imageY;\n\n        const target = detail.originalEvent.target as Element;\n        target.setPointerCapture(pointerId);\n    }\n\n    let minResizeWidth: number;\n    let minResizeHeight: number;\n    $: [minResizeWidth, minResizeHeight] =\n        aspectRatio > 1 ? [5 * aspectRatio, 5] : [5, 5 / aspectRatio];\n\n    async function resize(event: PointerEvent) {\n        const element = event.target! as Element;\n\n        if (!element.hasPointerCapture(event.pointerId)) {\n            return;\n        }\n\n        const dragWidth = getDragWidth(event);\n        const dragHeight = getDragHeight(event);\n\n        const widthIncrease = dragWidth / naturalWidth!;\n        const heightIncrease = dragHeight / naturalHeight!;\n\n        let width: number;\n\n        if (widthIncrease > heightIncrease) {\n            width = Math.max(Math.trunc(dragWidth), minResizeWidth);\n        } else {\n            const height = Math.max(Math.trunc(dragHeight), minResizeHeight);\n            width = Math.trunc(naturalWidth! * (height / naturalHeight!));\n        }\n\n        /**\n         * Image resizing add-ons previously used image.style.width/height to set the\n         * preferred dimension of an image. In these cases, if we'd only set\n         * image.[dimension], there would be no visible effect on the image.\n         * To avoid confusion with users we'll clear image.style.[dimension] (for now).\n         */\n        removeStyleProperties(activeImage!, \"width\", \"height\");\n        activeImage!.width = width;\n    }\n\n    function toggleActualSize(): void {\n        if (isSizeConstrained) {\n            activeImage!.dataset.editorShrink = \"false\";\n        } else {\n            activeImage!.dataset.editorShrink = \"true\";\n        }\n\n        isSizeConstrained = !isSizeConstrained;\n    }\n\n    function clearActualSize(): void {\n        activeImage!.removeAttribute(\"width\");\n    }\n\n    let shrinkingDisabled: boolean;\n    $: shrinkingDisabled =\n        Number(actualWidth) <= maxWidth && Number(actualHeight) <= maxHeight;\n\n    let restoringDisabled: boolean;\n    $: restoringDisabled = !(activeImage?.hasAttribute(\"width\") ?? true);\n\n    const widthObserver = new MutationObserver(() => {\n        restoringDisabled = !activeImage!.hasAttribute(\"width\");\n        updateDimensions();\n    });\n\n    $: activeImage\n        ? widthObserver.observe(activeImage, {\n              attributes: true,\n              attributeFilter: [\"width\"],\n          })\n        : widthObserver.disconnect();\n\n    let imageOverlay: HTMLElement;\n</script>\n\n<div bind:this={imageOverlay} class=\"image-overlay\">\n    {#if activeImage}\n        <WithOverlay reference={activeImage} inline let:position={positionOverlay}>\n            <WithFloating\n                reference={activeImage}\n                offset={20}\n                inline\n                hideIfReferenceHidden\n                portalTarget={document.body}\n                on:close={async ({ detail }) => {\n                    const { reason, originalEvent } = detail;\n\n                    if (reason === \"outsideClick\") {\n                        // If the click is still in the overlay, we do not want\n                        // to reset the handle either\n                        if (!originalEvent?.composedPath().includes(imageOverlay)) {\n                            await resetHandle();\n                        }\n                    } else {\n                        await resetHandle();\n                    }\n                }}\n            >\n                <Popover slot=\"floating\" let:position={positionFloating}>\n                    <ButtonToolbar>\n                        <FloatButtons\n                            image={activeImage}\n                            on:update={async () => {\n                                positionOverlay();\n                                positionFloating();\n                            }}\n                        />\n\n                        <SizeSelect\n                            {shrinkingDisabled}\n                            {restoringDisabled}\n                            {isSizeConstrained}\n                            on:imagetoggle={() => {\n                                toggleActualSize();\n                                positionOverlay();\n                            }}\n                            on:imageclear={() => {\n                                clearActualSize();\n                                positionOverlay();\n                            }}\n                        />\n                    </ButtonToolbar>\n                </Popover>\n            </WithFloating>\n\n            <svelte:fragment slot=\"overlay\" let:position={positionOverlay}>\n                <HandleBackground\n                    on:dblclick={() => {\n                        if (shrinkingDisabled) {\n                            return;\n                        }\n                        toggleActualSize();\n                        positionOverlay();\n                    }}\n                />\n\n                <HandleLabel>\n                    {#if isSizeConstrained && !shrinkingDisabled}\n                        <span>{`(${tr.editingDoubleClickToExpand()})`}</span>\n                    {:else}\n                        <span>{actualWidth}&times;{actualHeight}</span>\n                        {#if customDimensions}\n                            <span>\n                                (Original: {naturalWidth}&times;{naturalHeight})\n                            </span>\n                        {/if}\n                    {/if}\n                </HandleLabel>\n\n                <HandleControl\n                    active={!isSizeConstrained}\n                    activeSize={8}\n                    offsetX={5}\n                    offsetY={5}\n                    on:pointerclick={(event) => {\n                        if (!isSizeConstrained) {\n                            setPointerCapture(event);\n                        }\n                    }}\n                    on:pointermove={(event) => {\n                        resize(event);\n                    }}\n                />\n            </svelte:fragment>\n        </WithOverlay>\n    {/if}\n</div>\n"
  },
  {
    "path": "ts/editor/image-overlay/SizeSelect.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { directionKey } from \"@tslib/context-keys\";\n    import { createEventDispatcher, getContext } from \"svelte\";\n    import type { Readable } from \"svelte/store\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { sizeActual, sizeClear, sizeMinimized } from \"$lib/components/icons\";\n\n    export let isSizeConstrained: boolean;\n    export let shrinkingDisabled: boolean;\n    export let restoringDisabled: boolean;\n\n    $: icon = isSizeConstrained ? sizeMinimized : sizeActual;\n\n    const direction = getContext<Readable<\"ltr\" | \"rtl\">>(directionKey);\n    const dispatch = createEventDispatcher();\n</script>\n\n<ButtonGroup size={1.6}>\n    <IconButton\n        disabled={shrinkingDisabled}\n        flipX={$direction === \"rtl\"}\n        tooltip=\"{tr.editingActualSize()} ({tr.editingDoubleClickImage()})\"\n        on:click={() => dispatch(\"imagetoggle\")}\n        --border-left-radius=\"5px\"\n    >\n        <Icon {icon} />\n    </IconButton>\n\n    <IconButton\n        disabled={restoringDisabled}\n        tooltip={tr.editingRestoreOriginalSize()}\n        on:click={() => dispatch(\"imageclear\")}\n        --border-right-radius=\"5px\"\n    >\n        <Icon icon={sizeClear} />\n    </IconButton>\n</ButtonGroup>\n"
  },
  {
    "path": "ts/editor/image-overlay/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport ImageOverlay from \"./ImageOverlay.svelte\";\n\nexport default ImageOverlay;\n"
  },
  {
    "path": "ts/editor/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { globalExport } from \"@tslib/globals\";\n\nimport * as base from \"./base\";\n\nglobalExport(base);\n"
  },
  {
    "path": "ts/editor/legacy.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n@use \"sass:color\";\n@use \"../lib/sass/button-mixins\" as button;\n\n.linkb {\n    $size: var(--buttons-size);\n\n    @include button.base;\n    @include button.border-radius;\n\n    min-width: $size;\n    height: $size;\n    font-size: calc($size * 0.6);\n    position: relative;\n\n    img.topbut {\n        $padding: 4px;\n        $icon-size: calc(100% - 2 * $padding);\n\n        position: absolute;\n        height: $icon-size;\n        width: $icon-size;\n\n        // replace with inset once Qt5 support is dropped\n        top: $padding;\n        right: $padding;\n        bottom: $padding;\n        left: $padding;\n\n        .nightMode & {\n            filter: invert(1);\n        }\n    }\n}\n\nbutton {\n    @include button.base($active-class: active);\n}\n"
  },
  {
    "path": "ts/editor/mathjax-overlay/MathjaxButtons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { blockIcon, deleteIcon, inlineIcon } from \"$lib/components/icons\";\n\n    import ClozeButtons from \"../ClozeButtons.svelte\";\n\n    export let isBlock: boolean;\n    export let isClozeField: boolean;\n\n    const dispatch = createEventDispatcher();\n</script>\n\n<ButtonToolbar size={1.6} wrap={false}>\n    <ButtonGroup>\n        <IconButton\n            tooltip={tr.editingMathjaxInline()}\n            active={!isBlock}\n            on:click={() => dispatch(\"setinline\")}\n            --border-left-radius=\"5px\"\n        >\n            <Icon icon={inlineIcon} />\n        </IconButton>\n\n        <IconButton\n            tooltip={tr.editingMathjaxBlock()}\n            active={isBlock}\n            on:click={() => dispatch(\"setblock\")}\n            --border-right-radius=\"5px\"\n        >\n            <Icon icon={blockIcon} />\n        </IconButton>\n    </ButtonGroup>\n\n    {#if isClozeField}\n        <ClozeButtons on:surround alwaysEnabled={true} />\n    {/if}\n\n    <ButtonGroup>\n        <IconButton\n            tooltip={tr.actionsDelete()}\n            on:click={() => dispatch(\"delete\")}\n            --border-left-radius=\"5px\"\n            --border-right-radius=\"5px\"\n        >\n            <Icon icon={deleteIcon} />\n        </IconButton>\n    </ButtonGroup>\n</ButtonToolbar>\n"
  },
  {
    "path": "ts/editor/mathjax-overlay/MathjaxEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import { writable } from \"svelte/store\";\n\n    export let closeMathjaxEditor: (() => void) | null = null;\n\n    const closeSignalStore = writable<boolean>(false, (set) => {\n        closeMathjaxEditor = () => set(true);\n        return () => (closeMathjaxEditor = null);\n    });\n</script>\n\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { noop } from \"@tslib/functional\";\n    import { isArrowLeft, isArrowRight } from \"@tslib/keys\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import type CodeMirrorLib from \"codemirror\";\n    import { createEventDispatcher, onMount } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import { baseOptions, focusAndSetCaret, latex } from \"../code-mirror\";\n    import type { CodeMirrorAPI } from \"../CodeMirror.svelte\";\n    import CodeMirror from \"../CodeMirror.svelte\";\n\n    export let code: Writable<string>;\n    export let acceptShortcut: string;\n    export let newlineShortcut: string;\n\n    const configuration = {\n        ...Object.assign({}, baseOptions, {\n            extraKeys: {\n                ...(baseOptions.extraKeys as CodeMirrorLib.KeyMap),\n                [acceptShortcut]: noop,\n                [newlineShortcut]: noop,\n            },\n        }),\n        placeholder: tr.editingMathjaxPlaceholder({\n            accept: getPlatformString(acceptShortcut),\n            newline: getPlatformString(newlineShortcut),\n        }),\n        mode: latex,\n    };\n\n    /* These are not reactive, but only operate on initialization */\n    export let position: CodeMirrorLib.Position | undefined = undefined;\n    export let selectAll: boolean;\n\n    const dispatch = createEventDispatcher();\n\n    let codeMirror = {} as CodeMirrorAPI;\n\n    onMount(async () => {\n        const editor = await codeMirror.editor;\n\n        let direction: \"start\" | \"end\" | undefined = undefined;\n\n        editor.on(\n            \"keydown\",\n            (_instance: CodeMirrorLib.Editor, event: KeyboardEvent): void => {\n                if (event.key === \"Escape\") {\n                    dispatch(\"close\");\n                    event.stopPropagation();\n                } else if (isArrowLeft(event)) {\n                    direction = \"start\";\n                } else if (isArrowRight(event)) {\n                    direction = \"end\";\n                }\n            },\n        );\n\n        editor.on(\n            \"beforeSelectionChange\",\n            (\n                instance: CodeMirrorLib.Editor,\n                obj: CodeMirrorLib.EditorSelectionChange,\n            ): void => {\n                const { anchor } = obj.ranges[0];\n\n                if (anchor[\"hitSide\"]) {\n                    if (instance.getValue().length === 0) {\n                        if (direction) {\n                            dispatch(`moveout${direction}`);\n                        }\n                    } else if (anchor.line === 0 && anchor.ch === 0) {\n                        dispatch(\"moveoutstart\");\n                    } else {\n                        dispatch(\"moveoutend\");\n                    }\n                }\n\n                direction = undefined;\n            },\n        );\n\n        setTimeout(() => {\n            focusAndSetCaret(editor, position);\n\n            if (selectAll) {\n                editor.execCommand(\"selectAll\");\n            }\n        });\n    });\n\n    $: if ($closeSignalStore) {\n        dispatch(\"close\");\n        $closeSignalStore = false;\n    }\n</script>\n\n<div class=\"mathjax-editor\" class:light-theme={!$pageTheme.isDark}>\n    <CodeMirror\n        {code}\n        {configuration}\n        bind:api={codeMirror}\n        on:change={({ detail: mathjaxText }) => code.set(mathjaxText)}\n        on:blur\n    />\n</div>\n\n<slot editor={codeMirror} />\n\n<style lang=\"scss\">\n    .mathjax-editor {\n        margin: 0 1px;\n        overflow: hidden;\n\n        :global(.CodeMirror) {\n            max-width: 100ch;\n            min-width: 14rem;\n            margin-bottom: 0.25rem;\n        }\n\n        &.light-theme :global(.CodeMirror) {\n            border-width: 1px 0;\n            border-style: solid;\n            border-color: var(--border);\n        }\n\n        :global(.CodeMirror-placeholder) {\n            font-family: sans-serif;\n            font-size: max(12px, 55%);\n            text-align: center;\n            color: var(--fg-subtle);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/mathjax-overlay/MathjaxOverlay.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { hasBlockAttribute } from \"@tslib/dom\";\n    import { on } from \"@tslib/events\";\n    import { promiseWithResolver } from \"@tslib/promise\";\n    import type { Callback } from \"@tslib/typing\";\n    import { singleCallback } from \"@tslib/typing\";\n    import type CodeMirrorLib from \"codemirror\";\n    import { tick } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import Popover from \"$lib/components/Popover.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n    import WithOverlay from \"$lib/components/WithOverlay.svelte\";\n    import { placeCaretAfter } from \"$lib/domlib/place-caret\";\n    import { isComposing } from \"$lib/sveltelib/composition\";\n\n    import { escapeSomeEntities, unescapeSomeEntities } from \"../../editable/mathjax\";\n    import { Mathjax } from \"../../editable/mathjax-element.svelte\";\n    import type { EditingInputAPI } from \"../EditingArea.svelte\";\n    import HandleBackground from \"../HandleBackground.svelte\";\n    import { context } from \"../NoteEditor.svelte\";\n    import type { RichTextInputAPI } from \"../rich-text-input\";\n    import { editingInputIsRichText } from \"../rich-text-input\";\n    import MathjaxButtons from \"./MathjaxButtons.svelte\";\n    import MathjaxEditor from \"./MathjaxEditor.svelte\";\n\n    const { focusedInput } = context.get();\n\n    let cleanup: Callback;\n    let richTextInput: RichTextInputAPI | null = null;\n    let allowPromise = Promise.resolve();\n    // Whether the last focused input field corresponds to a cloze field.\n    let isClozeField: boolean = true;\n\n    async function initialize(input: EditingInputAPI | null): Promise<void> {\n        cleanup?.();\n\n        const isRichText = input && editingInputIsRichText(input);\n\n        // Setup the new field, so that clicking from one mathjax to another\n        // will immediately open the overlay\n        if (isRichText) {\n            const container = await input.element;\n\n            cleanup = singleCallback(\n                on(container, \"click\", showOverlayIfMathjaxClicked),\n                on(container, \"movecaretafter\" as any, showOnAutofocus),\n                on(container, \"selectall\" as any, showSelectAll),\n            );\n            isClozeField = input.isClozeField;\n        }\n\n        // Wait if the mathjax overlay is still active\n        await allowPromise;\n\n        if (!isRichText) {\n            richTextInput = null;\n            return;\n        }\n\n        richTextInput = input;\n    }\n\n    $: initialize($focusedInput);\n\n    let activeImage: HTMLImageElement | null = null;\n    let mathjaxElement: HTMLElement | null = null;\n\n    let allowResubscription: Callback;\n    let unsubscribe: Callback;\n\n    let selectAll = false;\n    let position: CodeMirrorLib.Position | undefined = undefined;\n\n    /**\n     * Will contain the Mathjax text with unescaped entities.\n     * This is the text displayed in the actual editor window.\n     */\n    const code = writable(\"\");\n\n    function showOverlay(image: HTMLImageElement, pos?: CodeMirrorLib.Position) {\n        if ($isComposing) {\n            // Should be canceled while an IME composition session is active\n            return;\n        }\n\n        const [promise, allowResolve] = promiseWithResolver<void>();\n\n        allowPromise = promise;\n        allowResubscription = singleCallback(\n            richTextInput!.preventResubscription(),\n            allowResolve,\n        );\n\n        position = pos;\n\n        /* Setting the activeImage and mathjaxElement to a non-nullish value is\n         * what triggers the Mathjax editor to show */\n        activeImage = image;\n        mathjaxElement = activeImage.closest(Mathjax.tagName)!;\n\n        code.set(unescapeSomeEntities(mathjaxElement.dataset.mathjax ?? \"\"));\n        unsubscribe = code.subscribe((value: string) => {\n            mathjaxElement!.dataset.mathjax = escapeSomeEntities(value);\n        });\n    }\n\n    function placeHandle(after: boolean): void {\n        richTextInput!.editable.focusHandler.flushCaret();\n\n        if (after) {\n            (mathjaxElement as any).placeCaretAfter();\n        } else {\n            (mathjaxElement as any).placeCaretBefore();\n        }\n    }\n\n    async function resetHandle(): Promise<void> {\n        selectAll = false;\n        position = undefined;\n\n        allowResubscription?.();\n\n        if (activeImage && mathjaxElement) {\n            clear();\n        }\n    }\n\n    function clear(): void {\n        unsubscribe();\n        activeImage = null;\n        mathjaxElement = null;\n    }\n\n    let errorMessage: string;\n    let cleanupImageError: Callback | null = null;\n\n    async function updateErrorMessage(): Promise<void> {\n        errorMessage = activeImage!.title;\n    }\n\n    async function updateImageErrorCallback(image: HTMLImageElement | null) {\n        cleanupImageError?.();\n        cleanupImageError = null;\n\n        if (!image) {\n            return;\n        }\n\n        cleanupImageError = on(image, \"resize\", updateErrorMessage);\n    }\n\n    $: updateImageErrorCallback(activeImage);\n\n    async function showOverlayIfMathjaxClicked({ target }: Event): Promise<void> {\n        if (target instanceof HTMLImageElement && target.dataset.anki === \"mathjax\") {\n            resetHandle();\n            showOverlay(target);\n        }\n    }\n\n    async function showOnAutofocus({\n        detail,\n    }: CustomEvent<{\n        image: HTMLImageElement;\n        position?: [number, number];\n    }>): Promise<void> {\n        let position: CodeMirrorLib.Position | undefined = undefined;\n\n        if (detail.position) {\n            const [line, ch] = detail.position;\n            position = { line, ch };\n        }\n\n        showOverlay(detail.image, position);\n    }\n\n    async function showSelectAll({\n        detail,\n    }: CustomEvent<HTMLImageElement>): Promise<void> {\n        selectAll = true;\n        showOverlay(detail);\n    }\n\n    let isBlock: boolean;\n    $: isBlock = mathjaxElement ? hasBlockAttribute(mathjaxElement) : false;\n\n    async function updateBlockAttribute(): Promise<void> {\n        mathjaxElement!.setAttribute(\"block\", String(isBlock));\n\n        // We assume that by the end of this tick, the image will have\n        // adjusted its styling to either block or inline\n        await tick();\n    }\n\n    const acceptShortcut = \"Enter\";\n    const newlineShortcut = \"Shift+Enter\";\n</script>\n\n<div class=\"mathjax-overlay\">\n    {#if activeImage && mathjaxElement}\n        <WithOverlay\n            reference={activeImage}\n            padding={isBlock ? 10 : 3}\n            keepOnKeyup\n            let:position={positionOverlay}\n        >\n            <WithFloating\n                reference={activeImage}\n                offset={20}\n                keepOnKeyup\n                portalTarget={document.body}\n                on:close={resetHandle}\n            >\n                <Popover slot=\"floating\" let:position={positionFloating}>\n                    <MathjaxEditor\n                        {acceptShortcut}\n                        {newlineShortcut}\n                        {code}\n                        {selectAll}\n                        {position}\n                        on:moveoutstart={() => {\n                            placeHandle(false);\n                            resetHandle();\n                        }}\n                        on:moveoutend={() => {\n                            placeHandle(true);\n                            resetHandle();\n                        }}\n                        on:close={() => {\n                            placeHandle(true);\n                            resetHandle();\n                        }}\n                        let:editor={mathjaxEditor}\n                    >\n                        <Shortcut\n                            keyCombination={acceptShortcut}\n                            on:action={() => {\n                                placeHandle(true);\n                                resetHandle();\n                            }}\n                        />\n\n                        <MathjaxButtons\n                            {isBlock}\n                            {isClozeField}\n                            on:setinline={async () => {\n                                isBlock = false;\n                                await updateBlockAttribute();\n                                positionOverlay();\n                                positionFloating();\n                            }}\n                            on:setblock={async () => {\n                                isBlock = true;\n                                await updateBlockAttribute();\n                                positionOverlay();\n                                positionFloating();\n                            }}\n                            on:delete={async () => {\n                                if (activeImage) {\n                                    placeCaretAfter(activeImage);\n                                    mathjaxElement?.remove();\n                                    clear();\n                                }\n                            }}\n                            on:surround={async ({ detail }) => {\n                                const editor = await mathjaxEditor.editor;\n                                const { prefix, suffix } = detail;\n\n                                editor.replaceSelection(\n                                    prefix + editor.getSelection() + suffix,\n                                );\n                            }}\n                        />\n                    </MathjaxEditor>\n                </Popover>\n            </WithFloating>\n\n            <svelte:fragment slot=\"overlay\">\n                <HandleBackground\n                    tooltip={errorMessage}\n                    --handle-background-color=\"var(--code-bg)\"\n                />\n            </svelte:fragment>\n        </WithOverlay>\n    {/if}\n</div>\n"
  },
  {
    "path": "ts/editor/mathjax-overlay/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport MathjaxOverlay from \"./MathjaxOverlay.svelte\";\n\nexport default MathjaxOverlay;\n"
  },
  {
    "path": "ts/editor/old-editor-adapter.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { updateAllState } from \"$lib/components/WithState.svelte\";\nimport { execCommand } from \"$lib/domlib\";\n\nimport { filterHTML } from \"../html-filter\";\n\nexport function pasteHTML(\n    html: string,\n    internal: boolean,\n    extendedMode: boolean,\n): void {\n    html = filterHTML(html, internal, extendedMode);\n\n    if (html !== \"\") {\n        setFormat(\"inserthtml\", html);\n    }\n}\n\nexport function setFormat(cmd: string, arg?: string, _nosave = false): void {\n    execCommand(cmd, false, arg);\n    updateAllState(new Event(cmd));\n}\n\nexport function toggleEditorButton(button: HTMLButtonElement): void {\n    button.classList.toggle(\"active\");\n}\n"
  },
  {
    "path": "ts/editor/plain-text-input/PlainTextInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import { registerPackage } from \"@tslib/runtime-require\";\n\n    import lifecycleHooks from \"$lib/sveltelib/lifecycle-hooks\";\n\n    import type { CodeMirrorAPI } from \"../CodeMirror.svelte\";\n    import type { EditingInputAPI, FocusableInputAPI } from \"../EditingArea.svelte\";\n\n    export interface PlainTextInputAPI extends EditingInputAPI {\n        name: \"plain-text\";\n        moveCaretToEnd(): void;\n        toggle(): boolean;\n        codeMirror: CodeMirrorAPI;\n    }\n\n    export const parsingInstructions: string[] = [];\n    export const closeHTMLTags = writable(true);\n\n    const [lifecycle, instances, setupLifecycleHooks] =\n        lifecycleHooks<PlainTextInputAPI>();\n\n    registerPackage(\"anki/PlainTextInput\", {\n        lifecycle,\n        instances,\n    });\n</script>\n\n<script lang=\"ts\">\n    import { singleCallback } from \"@tslib/typing\";\n    import { onMount, tick } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import { baseOptions, gutterOptions, htmlanki } from \"../code-mirror\";\n    import CodeMirror from \"../CodeMirror.svelte\";\n    import { context as editingAreaContext } from \"../EditingArea.svelte\";\n    import { Flag } from \"../helpers\";\n    import { context as noteEditorContext } from \"../NoteEditor.svelte\";\n    import removeProhibitedTags from \"./remove-prohibited\";\n    import { storedToUndecorated, undecoratedToStored } from \"./transform\";\n\n    export let hidden = false;\n    export let fieldCollapsed = false;\n    export const focusFlag = new Flag();\n\n    $: configuration = {\n        mode: htmlanki,\n        ...baseOptions,\n        ...gutterOptions,\n        ...{ autoCloseTags: $closeHTMLTags },\n    };\n\n    const { focusedInput } = noteEditorContext.get();\n    const { editingInputs, content } = editingAreaContext.get();\n    const code = writable($content);\n\n    let codeMirror = {} as CodeMirrorAPI;\n\n    async function focus(): Promise<void> {\n        const editor = await codeMirror.editor;\n        editor.focus();\n    }\n\n    async function moveCaretToEnd(): Promise<void> {\n        const editor = await codeMirror.editor;\n        editor.setCursor(editor.lineCount(), 0);\n    }\n\n    async function refocus(): Promise<void> {\n        const editor = (await codeMirror.editor) as any;\n        editor.display.input.blur();\n\n        focus();\n        moveCaretToEnd();\n    }\n\n    function toggle(): boolean {\n        hidden = !hidden;\n        return hidden;\n    }\n\n    async function getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null> {\n        const editor = (await codeMirror.editor) as any;\n\n        if (target === editor.display.input.textarea) {\n            return api;\n        }\n\n        return null;\n    }\n\n    export const api: PlainTextInputAPI = {\n        name: \"plain-text\",\n        focus,\n        focusable: !hidden,\n        moveCaretToEnd,\n        refocus,\n        toggle,\n        getInputAPI,\n        codeMirror,\n    };\n\n    /**\n     * Communicate to editing area that input is not focusable\n     */\n    function pushUpdate(isFocusable: boolean): void {\n        api.focusable = isFocusable;\n        $editingInputs = $editingInputs;\n    }\n\n    async function refresh(): Promise<void> {\n        const editor = await codeMirror.editor;\n        editor.refresh();\n    }\n\n    $: {\n        pushUpdate(!(hidden || fieldCollapsed));\n        tick().then(() => {\n            refresh();\n            if (focusFlag.checkAndReset()) {\n                refocus();\n            }\n        });\n    }\n\n    function onChange({ detail: html }: CustomEvent<string>): void {\n        code.set(removeProhibitedTags(html));\n    }\n\n    onMount(() => {\n        $editingInputs.push(api);\n        $editingInputs = $editingInputs;\n\n        return singleCallback(\n            content.subscribe((html: string): void =>\n                /* We call `removeProhibitedTags` here, because content might\n                 * have been changed outside the editor, and we need to parse\n                 * it to get the \"neutral\" value. Otherwise, there might be\n                 * conflicts with other editing inputs */\n                code.set(removeProhibitedTags(storedToUndecorated(html))),\n            ),\n            code.subscribe((html: string): void =>\n                content.set(undecoratedToStored(html)),\n            ),\n        );\n    });\n\n    setupLifecycleHooks(api);\n</script>\n\n<div\n    class=\"plain-text-input\"\n    class:light-theme={!$pageTheme.isDark}\n    on:focusin={() => ($focusedInput = api)}\n    {hidden}\n>\n    <CodeMirror\n        {configuration}\n        {code}\n        {hidden}\n        bind:api={codeMirror}\n        on:change={onChange}\n    />\n</div>\n\n<style lang=\"scss\">\n    .plain-text-input {\n        height: 100%;\n\n        :global(.CodeMirror) {\n            height: 100%;\n            background: var(--canvas-code);\n            padding-inline: 4px;\n        }\n\n        :global(.CodeMirror-lines) {\n            padding: 8px 0;\n        }\n\n        :global(.CodeMirror-gutters) {\n            background: var(--canvas-code);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/plain-text-input/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport PlainTextInput from \"./PlainTextInput.svelte\";\n\nexport type { PlainTextInputAPI } from \"./PlainTextInput.svelte\";\nexport default PlainTextInput;\nexport * from \"./PlainTextInput.svelte\";\n"
  },
  {
    "path": "ts/editor/plain-text-input/remove-prohibited.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { createDummyDoc } from \"@tslib/parsing\";\n\nconst parser = new DOMParser();\n\nfunction removeTag(element: HTMLElement, tagName: string): void {\n    for (const elem of element.getElementsByTagName(tagName)) {\n        elem.remove();\n    }\n}\n\nconst prohibitedTags = [\"script\", \"link\"];\n\n/**\n * The use cases for using those tags in the field html are slim to none.\n * We want to make it easier to possibly display cards in an iframe in the future.\n */\nfunction removeProhibitedTags(html: string): string {\n    const doc = parser.parseFromString(createDummyDoc(html), \"text/html\");\n    const body = doc.body;\n\n    for (const tag of prohibitedTags) {\n        removeTag(body, tag);\n    }\n\n    return doc.body.innerHTML;\n}\n\nexport default removeProhibitedTags;\n"
  },
  {
    "path": "ts/editor/plain-text-input/transform.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { decoratedElements } from \"../decorated-elements\";\n\nexport function storedToUndecorated(html: string): string {\n    return decoratedElements.toUndecorated(html);\n}\n\nexport function undecoratedToStored(html: string): string {\n    return decoratedElements.toStored(html);\n}\n"
  },
  {
    "path": "ts/editor/rich-text-input/CustomStyles.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    interface Identifiable {\n        id: string;\n    }\n\n    export interface StyleLinkType extends Identifiable {\n        type: \"link\";\n        href: string;\n    }\n\n    export interface StyleTagType extends Identifiable {\n        type: \"style\";\n    }\n\n    export type StyleType = StyleLinkType | StyleTagType;\n\n    type StyleHTMLTag = HTMLStyleElement | HTMLLinkElement;\n\n    export interface StyleObject {\n        element: StyleHTMLTag;\n    }\n\n    interface CustomStylesContext {\n        register: (id: string, object: StyleObject) => void;\n        deregister: (id: string) => void;\n    }\n\n    export function getCustomStylesContext(): CustomStylesContext {\n        return getContext(customStylesKey);\n    }\n\n    export const customStylesKey = Symbol(\"customStyles\");\n</script>\n\n<script lang=\"ts\">\n    import { getContext, setContext } from \"svelte\";\n\n    import StyleLink from \"./StyleLink.svelte\";\n    import StyleTag from \"./StyleTag.svelte\";\n\n    export let styles: StyleType[];\n    export const styleMap = new Map<string, StyleObject>();\n\n    const resolvers = new Map<string, (object: StyleObject) => void>();\n\n    function register(id: string, object: StyleObject): void {\n        styleMap.set(id, object);\n\n        if (resolvers.has(id)) {\n            resolvers.get(id)!(object);\n            resolvers.delete(id);\n        }\n    }\n\n    function deregister(id: string): void {\n        styleMap.delete(id);\n    }\n\n    setContext(customStylesKey, { register, deregister });\n\n    function waitForRegistration(id: string): Promise<StyleObject> {\n        let styleResolve: (element: StyleObject) => void;\n        const promise = new Promise<StyleObject>((resolve) => (styleResolve = resolve));\n\n        resolvers.set(id, styleResolve!);\n        return promise;\n    }\n\n    export function addStyleLink(id: string, href: string): Promise<StyleObject> {\n        styles.push({ id, type: \"link\", href });\n        styles = styles;\n\n        return waitForRegistration(id);\n    }\n\n    export function addStyleTag(id: string): Promise<StyleObject> {\n        styles.push({ id, type: \"style\" });\n        styles = styles;\n\n        return waitForRegistration(id);\n    }\n</script>\n\n{#each styles as style (style.id)}\n    {#if style.type === \"link\"}\n        <StyleLink id={style.id} href={style.href} />\n    {:else}\n        <StyleTag id={style.id} />\n    {/if}\n{/each}\n"
  },
  {
    "path": "ts/editor/rich-text-input/RichTextInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import { writable } from \"svelte/store\";\n\n    import type { InputHandlerAPI } from \"$lib/sveltelib/input-handler\";\n\n    import type { ContentEditableAPI } from \"../../editable/ContentEditable.svelte\";\n    import type { EditingInputAPI, FocusableInputAPI } from \"../EditingArea.svelte\";\n    import type { SurroundedAPI } from \"../surround\";\n\n    export interface RichTextInputAPI extends EditingInputAPI, SurroundedAPI {\n        name: \"rich-text\";\n        /** This is the contentEditable anki-editable element */\n        element: Promise<HTMLElement>;\n        moveCaretToEnd(): void;\n        toggle(): boolean;\n        preventResubscription(): () => void;\n        inputHandler: InputHandlerAPI;\n        /** The API exposed by the editable component */\n        editable: ContentEditableAPI;\n        customStyles: Promise<Record<string, any>>;\n        isClozeField: boolean;\n    }\n\n    function editingInputIsRichText(\n        editingInput: EditingInputAPI,\n    ): editingInput is RichTextInputAPI {\n        return editingInput.name === \"rich-text\";\n    }\n\n    import { registerPackage } from \"@tslib/runtime-require\";\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n    import lifecycleHooks from \"$lib/sveltelib/lifecycle-hooks\";\n\n    import { Surrounder } from \"../surround\";\n\n    const key = Symbol(\"richText\");\n    const [context, setContextProperty] = contextProperty<RichTextInputAPI>(key);\n    const [globalInputHandler, setupGlobalInputHandler] = useInputHandler();\n    const [lifecycle, instances, setupLifecycleHooks] =\n        lifecycleHooks<RichTextInputAPI>();\n    const apiStore = writable<SurroundedAPI | null>(null);\n    const surrounder = Surrounder.make<string>(apiStore);\n\n    registerPackage(\"anki/RichTextInput\", {\n        context,\n        surrounder,\n        lifecycle,\n        instances,\n    });\n\n    export {\n        context,\n        editingInputIsRichText,\n        globalInputHandler as inputHandler,\n        lifecycle,\n        surrounder,\n    };\n</script>\n\n<script lang=\"ts\">\n    import { directionKey, fontFamilyKey, fontSizeKey } from \"@tslib/context-keys\";\n    import { promiseWithResolver } from \"@tslib/promise\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { getAllContexts, getContext, mount, onMount, tick } from \"svelte\";\n    import type { Readable } from \"svelte/store\";\n\n    import { placeCaretAfterContent } from \"$lib/domlib/place-caret\";\n    import useDOMMirror from \"$lib/sveltelib/dom-mirror\";\n    import useInputHandler from \"$lib/sveltelib/input-handler\";\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import ContentEditable from \"../../editable/ContentEditable.svelte\";\n    import { context as editingAreaContext } from \"../EditingArea.svelte\";\n    import { Flag } from \"../helpers\";\n    import { context as noteEditorContext } from \"../NoteEditor.svelte\";\n    import getNormalizingNodeStore from \"./normalizing-node-store\";\n    import useRichTextResolve from \"./rich-text-resolve\";\n    import RichTextStyles from \"./RichTextStyles.svelte\";\n    import { fragmentToStored, storedToFragment } from \"./transform\";\n\n    export let hidden = false;\n    export const focusFlag = new Flag();\n    export let isClozeField: boolean;\n\n    const { focusedInput } = noteEditorContext.get();\n    const { content, editingInputs } = editingAreaContext.get();\n\n    const fontFamily = getContext<Readable<string>>(fontFamilyKey);\n    const fontSize = getContext<Readable<number>>(fontSizeKey);\n    const direction = getContext<Readable<\"ltr\" | \"rtl\">>(directionKey);\n\n    const nodes = getNormalizingNodeStore();\n    const [richTextPromise, resolve] = useRichTextResolve();\n    const { mirror, preventResubscription } = useDOMMirror();\n    const [inputHandler, setupInputHandler] = useInputHandler();\n    const [customStyles, stylesResolve] = promiseWithResolver<Record<string, any>>();\n\n    export function attachShadow(element: Element): void {\n        element.attachShadow({ mode: \"open\" });\n    }\n\n    async function moveCaretToEnd(): Promise<void> {\n        const richText = await richTextPromise;\n        if (richText.textContent?.length === 0) {\n            // Calling this method when richText is empty will cause the first keystroke of\n            // ibus-based input methods with candidates to go double. For example, if you\n            // type \"a\" it becomes \"aa\". This problem exists in many linux distributions.\n            // When richText is empty, there is no need to place the caret, just return.\n            return;\n        }\n\n        placeCaretAfterContent(richText);\n    }\n\n    async function focus(): Promise<void> {\n        const richText = await richTextPromise;\n        richText.blur();\n        richText.focus();\n    }\n\n    async function refocus(): Promise<void> {\n        const richText = await richTextPromise;\n        richText.blur();\n        richText.focus();\n        moveCaretToEnd();\n    }\n\n    function toggle(): boolean {\n        hidden = !hidden;\n        return hidden;\n    }\n\n    let richTextDiv: HTMLElement;\n\n    async function getInputAPI(target: EventTarget): Promise<FocusableInputAPI | null> {\n        if (target === richTextDiv) {\n            return api;\n        }\n\n        return null;\n    }\n\n    export const api: RichTextInputAPI = {\n        name: \"rich-text\",\n        element: richTextPromise,\n        focus,\n        refocus,\n        focusable: !hidden,\n        toggle,\n        getInputAPI,\n        moveCaretToEnd,\n        preventResubscription,\n        inputHandler,\n        editable: {} as ContentEditableAPI,\n        customStyles,\n        isClozeField,\n    };\n\n    const allContexts = getAllContexts();\n\n    function attachContentEditable(element: Element, { stylesDidLoad }): void {\n        (async () => {\n            await stylesDidLoad;\n\n            mount(ContentEditable, {\n                target: element.shadowRoot!,\n                props: {\n                    nodes,\n                    resolve,\n                    mirrors: [mirror],\n                    inputHandlers: [setupInputHandler, setupGlobalInputHandler],\n                    api: api.editable,\n                },\n                context: allContexts,\n            });\n        })();\n    }\n\n    function pushUpdate(isFocusable: boolean): void {\n        api.focusable = isFocusable;\n        $editingInputs = $editingInputs;\n    }\n\n    function setFocus(): void {\n        $focusedInput = api;\n        $apiStore = api;\n    }\n\n    function removeFocus(): void {\n        // We do not unset focusedInput here.\n        // If we did, UI components for the input would react the store\n        // being unset, even though most likely it will be set to some other\n        // field right away.\n\n        $apiStore = null;\n    }\n\n    $: {\n        pushUpdate(!hidden);\n        if (focusFlag.checkAndReset()) {\n            tick().then(refocus);\n        }\n    }\n\n    $: {\n        api.isClozeField = isClozeField;\n    }\n\n    onMount(() => {\n        $editingInputs.push(api);\n        $editingInputs = $editingInputs;\n\n        return singleCallback(\n            content.subscribe((html: string): void =>\n                nodes.setUnprocessed(storedToFragment(html)),\n            ),\n            nodes.subscribe((fragment: DocumentFragment): void =>\n                content.set(fragmentToStored(fragment)),\n            ),\n        );\n    });\n\n    setContextProperty(api);\n    setupLifecycleHooks(api);\n</script>\n\n<div class=\"rich-text-input\" on:focusin={setFocus} on:focusout={removeFocus} {hidden}>\n    <RichTextStyles\n        color={$pageTheme.isDark ? \"white\" : \"black\"}\n        fontFamily={$fontFamily}\n        fontSize={$fontSize}\n        direction={$direction}\n        callback={stylesResolve}\n        let:attachToShadow={attachStyles}\n        let:stylesDidLoad\n    >\n        <div class=\"rich-text-relative\">\n            <div\n                class=\"rich-text-editable\"\n                class:empty={$content.length === 0}\n                bind:this={richTextDiv}\n                use:attachShadow\n                use:attachStyles\n                use:attachContentEditable={{ stylesDidLoad }}\n                on:focusin\n                on:focusout\n            ></div>\n\n            {#await Promise.all([richTextPromise, stylesDidLoad]) then _}\n                <div class=\"rich-text-widgets\">\n                    <slot />\n                </div>\n            {/await}\n        </div>\n    </RichTextStyles>\n</div>\n\n<style lang=\"scss\">\n    .rich-text-input {\n        height: 100%;\n\n        background-color: var(--canvas-elevated);\n        padding: 6px;\n    }\n\n    .rich-text-relative {\n        position: relative;\n    }\n\n    .rich-text-editable.empty::before {\n        position: absolute;\n        color: var(--fg-subtle);\n        content: var(--description-content);\n        font-size: var(--description-font-size, 20px);\n        cursor: text;\n        max-width: 95%;\n        overflow-x: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n    }\n</style>\n"
  },
  {
    "path": "ts/editor/rich-text-input/RichTextStyles.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { promiseWithResolver } from \"@tslib/promise\";\n\n    import type { StyleLinkType, StyleObject } from \"./CustomStyles.svelte\";\n    import CustomStyles from \"./CustomStyles.svelte\";\n    import { mount } from \"svelte\";\n\n    export let callback: (styles: Record<string, any>) => void;\n\n    const [userBaseStyle, userBaseResolve] = promiseWithResolver<StyleObject>();\n    const [userBaseRule, userBaseRuleResolve] = promiseWithResolver<CSSStyleRule>();\n\n    const stylesDidLoad: Promise<unknown> = Promise.all([userBaseStyle, userBaseRule]);\n\n    userBaseStyle.then((baseStyle: StyleObject) => {\n        const sheet = baseStyle.element.sheet as CSSStyleSheet;\n        const baseIndex = sheet.insertRule(\"anki-editable {}\");\n        userBaseRuleResolve(sheet.cssRules[baseIndex] as CSSStyleRule);\n    });\n\n    export let color: string;\n    export let fontFamily: string;\n    export let fontSize: number;\n    export let direction: \"ltr\" | \"rtl\";\n\n    async function setStyling(property: string, value: unknown): Promise<void> {\n        const rule = await userBaseRule;\n        rule.style[property] = value;\n\n        // if we don't set the textContent of the underlying HTMLStyleElement, addons\n        // which extend the custom style and set textContent of their registered tags\n        // will cause the userBase style tag here to be ignored\n        const baseStyle = await userBaseStyle;\n        baseStyle.element.textContent = rule.cssText;\n    }\n\n    $: setStyling(\"color\", color);\n    $: setStyling(\"fontFamily\", fontFamily);\n    $: setStyling(\"fontSize\", fontSize + \"px\");\n    $: setStyling(\"direction\", direction);\n\n    const styles: StyleLinkType[] = [\n        {\n            id: \"rootStyle\",\n            type: \"link\",\n            href: \"./_anki/css/editable.css\",\n        },\n    ];\n\n    function attachToShadow(element: Element) {\n        const customStyles = mount(CustomStyles, {\n            target: element.shadowRoot!,\n            props: { styles },\n        });\n        customStyles.addStyleTag(\"userBase\").then((styleTag) => {\n            userBaseResolve(styleTag);\n            callback(customStyles);\n        });\n    }\n</script>\n\n<slot {attachToShadow} {stylesDidLoad} />\n"
  },
  {
    "path": "ts/editor/rich-text-input/StyleLink.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { onDestroy } from \"svelte\";\n\n    import { getCustomStylesContext } from \"./CustomStyles.svelte\";\n\n    export let id: string;\n    export let href: string;\n\n    const { register, deregister } = getCustomStylesContext();\n\n    function onLoad(event: Event): void {\n        const link = event.target! as HTMLLinkElement;\n        register(id, { element: link });\n    }\n\n    onDestroy(() => deregister(id));\n</script>\n\n<link {id} rel=\"stylesheet\" {href} on:load={onLoad} />\n"
  },
  {
    "path": "ts/editor/rich-text-input/StyleTag.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { onDestroy } from \"svelte\";\n\n    import { getCustomStylesContext } from \"./CustomStyles.svelte\";\n\n    export let id: string;\n\n    const { register, deregister } = getCustomStylesContext();\n\n    function onLoad(event: Event): void {\n        const style = event.target! as HTMLStyleElement;\n        register(id, { element: style });\n    }\n\n    onDestroy(() => deregister(id));\n</script>\n\n<!-- otherwise Svelte thinks it's a scoped style tag -->\n{#if true}\n    <style {id} on:load={onLoad}></style>\n{/if}\n"
  },
  {
    "path": "ts/editor/rich-text-input/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { default as RichTextInput } from \"./RichTextInput.svelte\";\n\nexport type { RichTextInputAPI } from \"./RichTextInput.svelte\";\nexport default RichTextInput;\nexport * from \"./RichTextInput.svelte\";\n"
  },
  {
    "path": "ts/editor/rich-text-input/normalizing-node-store.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { NodeStore } from \"$lib/sveltelib/node-store\";\nimport { nodeStore } from \"$lib/sveltelib/node-store\";\n\nimport type { DecoratedElement } from \"../../editable/decorated\";\nimport { decoratedElements } from \"../decorated-elements\";\n\nfunction normalizeFragment(fragment: DocumentFragment): void {\n    fragment.normalize();\n\n    for (const decorated of decoratedElements) {\n        for (\n            const element of fragment.querySelectorAll(\n                decorated.tagName,\n            ) as NodeListOf<DecoratedElement>\n        ) {\n            element.undecorate();\n        }\n    }\n}\n\nfunction getStore(): NodeStore<DocumentFragment> {\n    return nodeStore<DocumentFragment>(undefined, normalizeFragment);\n}\n\nexport default getStore;\n"
  },
  {
    "path": "ts/editor/rich-text-input/rich-text-resolve.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { bridgeCommand } from \"@tslib/bridgecommand\";\nimport { on } from \"@tslib/events\";\nimport { promiseWithResolver } from \"@tslib/promise\";\n\nfunction bridgeCopyPasteCommands(input: HTMLElement): { destroy(): void } {\n    function onPaste(event: Event): void {\n        event.preventDefault();\n        bridgeCommand(\"paste\");\n    }\n\n    function onCutOrCopy(): void {\n        bridgeCommand(\"cutOrCopy\");\n    }\n\n    const removePaste = on(input, \"paste\", onPaste);\n    const removeCopy = on(input, \"copy\", onCutOrCopy);\n    const removeCut = on(input, \"cut\", onCutOrCopy);\n\n    return {\n        destroy() {\n            removePaste();\n            removeCopy();\n            removeCut();\n        },\n    };\n}\n\nfunction useRichTextResolve(): [Promise<HTMLElement>, (input: HTMLElement) => void] {\n    const [promise, resolve] = promiseWithResolver<HTMLElement>();\n\n    function richTextResolve(input: HTMLElement): { destroy(): void } {\n        const destroy = bridgeCopyPasteCommands(input);\n        resolve(input);\n        return destroy;\n    }\n\n    return [promise, richTextResolve];\n}\n\nexport default useRichTextResolve;\n"
  },
  {
    "path": "ts/editor/rich-text-input/transform.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fragmentToString, nodeContainsInlineContent, nodeIsElement } from \"@tslib/dom\";\nimport { createDummyDoc } from \"@tslib/parsing\";\n\nimport { decoratedElements } from \"../decorated-elements\";\n\nfunction adjustInputHTML(html: string): string {\n    for (const component of decoratedElements) {\n        html = component.toUndecorated(html);\n    }\n\n    return html;\n}\n\nfunction adjustInputFragment(fragment: DocumentFragment): void {\n    if (nodeContainsInlineContent(fragment)) {\n        fragment.appendChild(document.createElement(\"br\"));\n    }\n}\n\nexport function storedToFragment(storedHTML: string): DocumentFragment {\n    /* We need .createContextualFragment so that customElements are initialized */\n    const fragment = document\n        .createRange()\n        .createContextualFragment(createDummyDoc(adjustInputHTML(storedHTML)));\n\n    adjustInputFragment(fragment);\n    return fragment;\n}\n\nfunction adjustOutputFragment(fragment: DocumentFragment): void {\n    if (\n        fragment.hasChildNodes()\n        && nodeIsElement(fragment.lastChild!)\n        && nodeContainsInlineContent(fragment)\n        && fragment.lastChild!.tagName === \"BR\"\n    ) {\n        fragment.lastChild!.remove();\n    }\n}\n\nfunction adjustOutputHTML(html: string): string {\n    for (const component of decoratedElements) {\n        html = component.toStored(html);\n    }\n\n    return html;\n}\n\nexport function fragmentToStored(fragment: DocumentFragment): string {\n    const clone = document.importNode(fragment, true);\n    adjustOutputFragment(clone);\n\n    const storedHTML = adjustOutputHTML(fragmentToString(clone));\n    return storedHTML;\n}\n"
  },
  {
    "path": "ts/editor/surround.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getRange, getSelection } from \"@tslib/cross-browser\";\nimport { asyncNoop } from \"@tslib/functional\";\nimport { registerPackage } from \"@tslib/runtime-require\";\nimport type { Readable } from \"svelte/store\";\nimport { derived, get } from \"svelte/store\";\n\nimport type { Matcher } from \"$lib/domlib/find-above\";\nimport { findClosest } from \"$lib/domlib/find-above\";\nimport type { SurroundFormat } from \"$lib/domlib/surround\";\nimport { boolMatcher, reformat, surround, unsurround } from \"$lib/domlib/surround\";\nimport type { TriggerItem } from \"$lib/sveltelib/handler-list\";\nimport type { InputHandlerAPI } from \"$lib/sveltelib/input-handler\";\n\nfunction isValid<T>(value: T | undefined): value is T {\n    return Boolean(value);\n}\n\nfunction isSurroundedInner(\n    range: AbstractRange,\n    base: HTMLElement,\n    matcher: Matcher,\n): boolean {\n    return Boolean(\n        findClosest(range.startContainer, base, matcher)\n            || findClosest(range.endContainer, base, matcher),\n    );\n}\n\nfunction surroundAndSelect<T>(\n    matches: boolean,\n    range: Range,\n    base: HTMLElement,\n    format: SurroundFormat<T>,\n    selection: Selection,\n): void {\n    const surroundedRange = matches\n        ? unsurround(range, base, format)\n        : surround(range, base, format);\n\n    selection.removeAllRanges();\n    selection.addRange(surroundedRange);\n}\n\nfunction removeFormats(\n    range: Range,\n    base: Element,\n    formats: SurroundFormat[],\n    reformats: SurroundFormat[] = [],\n): Range {\n    let surroundRange = range;\n\n    for (const format of formats) {\n        surroundRange = unsurround(surroundRange, base, format);\n    }\n\n    for (const format of reformats) {\n        surroundRange = reformat(surroundRange, base, format);\n    }\n\n    return surroundRange;\n}\n\nexport interface SurroundedAPI {\n    element: Promise<HTMLElement>;\n    inputHandler: InputHandlerAPI;\n}\n\n/**\n * After calling disable, using any of the surrounding methods will throw an\n * exception. Make sure to set the input before trying to use them again.\n */\nexport class Surrounder<T = unknown> {\n    #api?: SurroundedAPI;\n\n    #triggers: Map<string, TriggerItem<{ event: InputEvent; text: Text }>> = new Map();\n    #formats: Map<string, SurroundFormat<T>> = new Map();\n\n    active: Readable<boolean>;\n\n    private constructor(apiStore: Readable<SurroundedAPI | null>) {\n        this.active = derived(apiStore, (api) => Boolean(api));\n\n        apiStore.subscribe((api: SurroundedAPI | null): void => {\n            if (api) {\n                this.#api = api;\n\n                for (const key of this.#formats.keys()) {\n                    this.#triggers.set(\n                        key,\n                        api.inputHandler.insertText.trigger({ once: true }),\n                    );\n                }\n            } else {\n                this.#api = undefined;\n\n                for (const [key, trigger] of this.#triggers) {\n                    trigger.off();\n                    this.#triggers.delete(key);\n                }\n            }\n        });\n    }\n\n    static make<T>(apiStore: Readable<SurroundedAPI | null>): Surrounder<T> {\n        return new Surrounder(apiStore);\n    }\n\n    #getBaseElement(): Promise<HTMLElement> {\n        if (!this.#api) {\n            throw new Error(\"Surrounder: No api set\");\n        }\n\n        return this.#api.element;\n    }\n\n    #toggleTrigger<T>(\n        base: HTMLElement,\n        selection: Selection,\n        matcher: Matcher,\n        format: SurroundFormat<T>,\n        trigger: TriggerItem<{ event: InputEvent; text: Text }>,\n        exclusive: SurroundFormat<T>[] = [],\n    ): void {\n        if (get(trigger.active)) {\n            trigger.off();\n        } else {\n            trigger.on(async ({ text }) => {\n                const range = new Range();\n                range.selectNode(text);\n\n                const matches = Boolean(findClosest(text, base, matcher));\n                const clearedRange = removeFormats(range, base, exclusive);\n                surroundAndSelect(matches, clearedRange, base, format, selection);\n                selection.collapseToEnd();\n            });\n        }\n    }\n\n    #toggleTriggerOverwrite<T>(\n        base: HTMLElement,\n        selection: Selection,\n        format: SurroundFormat<T>,\n        trigger: TriggerItem<{ event: InputEvent; text: Text }>,\n        exclusive: SurroundFormat<T>[] = [],\n    ): void {\n        trigger.on(async ({ text }) => {\n            const range = new Range();\n            range.selectNode(text);\n\n            const clearedRange = removeFormats(range, base, exclusive);\n            const surroundedRange = surround(clearedRange, base, format);\n            selection.removeAllRanges();\n            selection.addRange(surroundedRange);\n            selection.collapseToEnd();\n        });\n    }\n\n    #toggleTriggerRemove<T>(\n        base: HTMLElement,\n        selection: Selection,\n        formats: {\n            format: SurroundFormat<T>;\n            trigger: TriggerItem<{ event: InputEvent; text: Text }>;\n        }[],\n        reformat: SurroundFormat<T>[] = [],\n    ): void {\n        const remainingFormats = formats\n            .filter(({ trigger }) => {\n                if (get(trigger.active)) {\n                    // Deactivate active triggers for active formats.\n                    trigger.off();\n                    return false;\n                }\n\n                // Otherwise you are within the format. This is why we activate\n                // the trigger, so that the active button is set to inactive.\n                // We still need to remove the format however.\n                trigger.on(asyncNoop);\n                return true;\n            })\n            .map(({ format }) => format);\n\n        // Use an anonymous insertText handler instead of some trigger associated with a name\n        this.#api!.inputHandler.insertText.on(\n            async ({ text }) => {\n                const range = new Range();\n                range.selectNode(text);\n\n                const clearedRange = removeFormats(\n                    range,\n                    base,\n                    remainingFormats,\n                    reformat,\n                );\n                selection.removeAllRanges();\n                selection.addRange(clearedRange);\n                selection.collapseToEnd();\n            },\n            { once: true },\n        );\n    }\n\n    /**\n     * Check if a surround format under the given key is registered.\n     */\n    hasFormat(key: string): boolean {\n        return this.#formats.has(key);\n    }\n\n    /**\n     * Register a surround format under a certain key.\n     * This name is then used with the surround functions to actually apply or\n     * remove the given format.\n     */\n    registerFormat(key: string, format: SurroundFormat<T>): () => void {\n        this.#formats.set(key, format);\n\n        if (this.#api) {\n            this.#triggers.set(\n                key,\n                this.#api.inputHandler.insertText.trigger({ once: true }),\n            );\n        }\n\n        return () => this.#formats.delete(key);\n    }\n\n    /**\n     * Update a surround format under a specific key.\n     */\n    updateFormat(\n        key: string,\n        update: (format: SurroundFormat<T>) => SurroundFormat<T>,\n    ): void {\n        this.#formats.set(key, update(this.#formats.get(key)!));\n    }\n\n    /**\n     * Use the surround command on the current range of the input.\n     * If the range is already surrounded, it will unsurround instead.\n     */\n    async surround(formatName: string, exclusiveNames: string[] = []): Promise<void> {\n        const base = await this.#getBaseElement();\n        const selection = getSelection(base)!;\n        const range = getRange(selection);\n        const format = this.#formats.get(formatName);\n        const trigger = this.#triggers.get(formatName);\n\n        if (!format || !range || !trigger) {\n            return;\n        }\n\n        const matcher = boolMatcher(format);\n\n        const exclusives = exclusiveNames\n            .map((name) => this.#formats.get(name))\n            .filter(isValid);\n\n        if (range.collapsed) {\n            return this.#toggleTrigger(\n                base,\n                selection,\n                matcher,\n                format,\n                trigger,\n                exclusives,\n            );\n        }\n\n        const clearedRange = removeFormats(range, base, exclusives);\n        const matches = isSurroundedInner(clearedRange, base, matcher);\n        surroundAndSelect(matches, clearedRange, base, format, selection);\n    }\n\n    /**\n     * Use the surround command on the current range of the input.\n     * If the range is already surrounded, it will overwrite the format.\n     * This might be better suited if the surrounding is parameterized (like\n     * text color).\n     */\n    async overwriteSurround(\n        formatName: string,\n        exclusiveNames: string[] = [],\n    ): Promise<void> {\n        const base = await this.#getBaseElement();\n        const selection = getSelection(base)!;\n        const range = getRange(selection);\n        const format = this.#formats.get(formatName);\n        const trigger = this.#triggers.get(formatName);\n\n        if (!format || !range || !trigger) {\n            return;\n        }\n\n        const exclusives = exclusiveNames\n            .map((name) => this.#formats.get(name))\n            .filter(isValid);\n\n        if (range.collapsed) {\n            return this.#toggleTriggerOverwrite(\n                base,\n                selection,\n                format,\n                trigger,\n                exclusives,\n            );\n        }\n\n        const clearedRange = removeFormats(range, base, exclusives);\n        const surroundedRange = surround(clearedRange, base, format);\n        selection.removeAllRanges();\n        selection.addRange(surroundedRange);\n    }\n\n    /**\n     * Check if the current selection is surrounded. A selection will count as\n     * provided if either the start or the end boundary point are within the\n     * provided format, OR if a surround trigger is active (surround on next\n     * text insert).\n     */\n    async isSurrounded(formatName: string): Promise<boolean> {\n        const base = await this.#getBaseElement();\n        const selection = getSelection(base)!;\n        const range = getRange(selection);\n        const format = this.#formats.get(formatName);\n        const trigger = this.#triggers.get(formatName);\n\n        if (!range || !format || !trigger) {\n            return false;\n        }\n\n        const isSurrounded = isSurroundedInner(range, base, boolMatcher(format));\n        return get(trigger.active) ? !isSurrounded : isSurrounded;\n    }\n\n    /**\n     * Clear/Reformat the provided formats in the current range.\n     */\n    async remove(formatNames: string[], reformatNames: string[] = []): Promise<void> {\n        const base = await this.#getBaseElement();\n        const selection = getSelection(base)!;\n        const range = getRange(selection);\n\n        if (!range) {\n            return;\n        }\n\n        const activeFormats = formatNames\n            .map((name: string) => ({\n                name,\n                format: this.#formats.get(name)!,\n                trigger: this.#triggers.get(name)!,\n            }))\n            .filter(({ format, trigger }): boolean => {\n                if (!format || !trigger) {\n                    return false;\n                }\n\n                // This is confusing: when nothing is selected, we only\n                // include currently-active buttons, as otherwise inactive\n                // buttons get toggled on. But when something is selected,\n                // we include everything, since we want to remove formatting\n                // that may be in part of the selection, but not at the start/end.\n\n                const isSurrounded = !range.collapsed || isSurroundedInner(\n                    range,\n                    base,\n                    boolMatcher(format),\n                );\n                return get(trigger.active) ? !isSurrounded : isSurrounded;\n            });\n\n        const reformats = reformatNames\n            .map((name) => this.#formats.get(name))\n            .filter(isValid);\n\n        if (range.collapsed) {\n            return this.#toggleTriggerRemove(base, selection, activeFormats, reformats);\n        }\n\n        const surroundedRange = removeFormats(\n            range,\n            base,\n            activeFormats.map(({ format }) => format),\n            reformats,\n        );\n        selection.removeAllRanges();\n        selection.addRange(surroundedRange);\n    }\n}\n\nregisterPackage(\"anki/surround\", {\n    Surrounder,\n});\n"
  },
  {
    "path": "ts/editor/types.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport type EditorOptions = {\n    fieldsCollapsed: boolean[];\n    fieldStates: {\n        richTextsHidden: boolean[];\n        plainTextsHidden: boolean[];\n        plainTextDefaults: boolean[];\n    };\n    modTimeOfNotetype: number;\n};\n\nexport type SessionOptions = {\n    [key: number]: EditorOptions;\n};\n\nexport type NotetypeIdAndModTime = {\n    id: number;\n    modTime: number;\n};\n\nexport enum EditorState {\n    Initial = -1,\n    Fields = 0,\n    ImageOcclusionPicker = 1,\n    ImageOcclusionMasks = 2,\n    ImageOcclusionFields = 3,\n}\n"
  },
  {
    "path": "ts/html-filter/element.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { isHTMLElement, isNightMode } from \"./helpers\";\nimport { removeNode as removeElement } from \"./node\";\nimport { filterStylingInternal, filterStylingLightMode, filterStylingNightMode } from \"./styling\";\n\ninterface TagsAllowed {\n    [tagName: string]: FilterMethod;\n}\n\ntype FilterMethod = (element: Element) => void;\n\nfunction filterAttributes(\n    attributePredicate: (attributeName: string) => boolean,\n    element: Element,\n): void {\n    for (const attr of [...element.attributes]) {\n        const attrName = attr.name.toUpperCase();\n\n        if (!attributePredicate(attrName)) {\n            element.removeAttributeNode(attr);\n        }\n    }\n}\n\nfunction allowNone(element: Element): void {\n    filterAttributes(() => false, element);\n}\n\nconst allow = (attrs: string[]): FilterMethod => (element: Element): void =>\n    filterAttributes(\n        (attributeName: string) => attrs.includes(attributeName),\n        element,\n    );\n\nfunction unwrapElement(element: Element): void {\n    element.replaceWith(...element.childNodes);\n}\n\nfunction filterSpan(element: Element): void {\n    const filterAttrs = allow([\"STYLE\"]);\n    filterAttrs(element);\n\n    const filterStyle = isNightMode() ? filterStylingNightMode : filterStylingLightMode;\n    filterStyle(element as HTMLSpanElement);\n}\n\nconst tagsAllowedBasic: TagsAllowed = {\n    BR: allowNone,\n    IMG: allow([\"SRC\", \"ALT\"]),\n    DIV: allowNone,\n    P: allowNone,\n    SUB: allowNone,\n    SUP: allowNone,\n    TITLE: removeElement,\n};\n\nconst tagsAllowedExtended: TagsAllowed = {\n    ...tagsAllowedBasic,\n    A: allow([\"HREF\"]),\n    B: allowNone,\n    BLOCKQUOTE: allowNone,\n    CODE: allowNone,\n    DD: allowNone,\n    DL: allowNone,\n    DT: allowNone,\n    EM: allowNone,\n    FONT: allow([\"COLOR\"]),\n    H1: allowNone,\n    H2: allowNone,\n    H3: allowNone,\n    I: allowNone,\n    LI: allowNone,\n    OL: allowNone,\n    PRE: allowNone,\n    RP: allowNone,\n    RT: allowNone,\n    RUBY: allowNone,\n    SPAN: filterSpan,\n    STRONG: allowNone,\n    TABLE: allowNone,\n    TD: allow([\"COLSPAN\", \"ROWSPAN\"]),\n    TH: allow([\"COLSPAN\", \"ROWSPAN\"]),\n    TR: allow([\"ROWSPAN\"]),\n    U: allowNone,\n    UL: allowNone,\n};\n\nconst filterElementTagsAllowed = (tagsAllowed: TagsAllowed) => (element: Element): void => {\n    const tagName = element.tagName;\n\n    if (Object.prototype.hasOwnProperty.call(tagsAllowed, tagName)) {\n        tagsAllowed[tagName](element);\n    } else if (element.innerHTML) {\n        unwrapElement(element);\n    } else {\n        removeElement(element);\n    }\n};\n\nexport const filterElementBasic = filterElementTagsAllowed(tagsAllowedBasic);\nexport const filterElementExtended = filterElementTagsAllowed(tagsAllowedExtended);\n\nexport function filterElementInternal(element: Element): void {\n    if (isHTMLElement(element)) {\n        filterStylingInternal(element);\n    }\n}\n"
  },
  {
    "path": "ts/html-filter/helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function isHTMLElement(elem: Element): elem is HTMLElement {\n    return elem instanceof HTMLElement;\n}\n\nexport function isNightMode(): boolean {\n    return document.body.classList.contains(\"nightMode\");\n}\n"
  },
  {
    "path": "ts/html-filter/index.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n// @vitest-environment jsdom\n\nimport { describe, expect, test } from \"vitest\";\n\nimport { filterHTML } from \".\";\n\ndescribe(\"filterHTML\", () => {\n    test(\"zero input creates zero output\", () => {\n        expect(filterHTML(\"\", true, false)).toBe(\"\");\n        expect(filterHTML(\"\", true, false)).toBe(\"\");\n        expect(filterHTML(\"\", false, false)).toBe(\"\");\n    });\n    test(\"internal filtering\", () => {\n        // font-size is filtered, weight is not\n        expect(\n            filterHTML(\n                \"<div style=\\\"font-weight: bold; font-size: 10px;\\\"></div>\",\n                true,\n                true,\n            ),\n        ).toBe(\"<div style=\\\"font-weight: bold;\\\"></div>\");\n    });\n    test(\"background color\", () => {\n        // transparent is stripped, other colors are not\n        expect(\n            filterHTML(\n                \"<span style=\\\"background-color: transparent;\\\"></span>\",\n                false,\n                true,\n            ),\n        ).toBe(\"<span style=\\\"\\\"></span>\");\n        expect(\n            filterHTML(\"<span style=\\\"background-color: blue;\\\"></span>\", false, true),\n        ).toBe(\"<span style=\\\"background-color: blue;\\\"></span>\");\n        // except if extended mode is off\n        expect(\n            filterHTML(\"<span style=\\\"background-color: blue;\\\">x</span>\", false, false),\n        ).toBe(\"x\");\n        // no filtering on internal paste\n        expect(\n            filterHTML(\n                \"<span style=\\\"background-color: transparent;\\\"></span>\",\n                true,\n                true,\n            ),\n        ).toBe(\"<span style=\\\"background-color: transparent;\\\"></span>\");\n    });\n});\n"
  },
  {
    "path": "ts/html-filter/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { filterElementBasic, filterElementExtended, filterElementInternal } from \"./element\";\nimport { filterNode } from \"./node\";\n\nenum FilterMode {\n    Basic,\n    Extended,\n    Internal,\n}\n\nconst filters: Record<FilterMode, (element: Element) => void> = {\n    [FilterMode.Basic]: filterElementBasic,\n    [FilterMode.Extended]: filterElementExtended,\n    [FilterMode.Internal]: filterElementInternal,\n};\n\nconst whitespace = /[\\n\\t ]+/g;\n\nfunction collapseWhitespace(value: string): string {\n    return value.replace(whitespace, \" \");\n}\n\nfunction trim(value: string): string {\n    return value.trim();\n}\n\nconst outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => string> = {\n    [FilterMode.Basic]: (outputHTML: string): string => trim(collapseWhitespace(outputHTML)),\n    [FilterMode.Extended]: trim,\n    [FilterMode.Internal]: trim,\n};\n\nexport function filterHTML(html: string, internal: boolean, extended: boolean): string {\n    const template = document.createElement(\"template\");\n    template.innerHTML = html;\n\n    const mode = getFilterMode(internal, extended);\n    const content = template.content;\n    const filter = filterNode(filters[mode]);\n\n    filter(content);\n\n    return outputHTMLProcessors[mode](template.innerHTML);\n}\n\nfunction getFilterMode(internal: boolean, extended: boolean): FilterMode {\n    if (internal) {\n        return FilterMode.Internal;\n    } else if (extended) {\n        return FilterMode.Extended;\n    } else {\n        return FilterMode.Basic;\n    }\n}\n"
  },
  {
    "path": "ts/html-filter/node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function removeNode(element: Node): void {\n    element.parentNode?.removeChild(element);\n}\n\nfunction iterateElement(\n    filter: (node: Node) => void,\n    fragment: DocumentFragment | Element,\n): void {\n    for (const child of [...fragment.childNodes]) {\n        filter(child);\n    }\n}\n\nexport const filterNode = (elementFilter: (element: Element) => void) => (node: Node): void => {\n    switch (node.nodeType) {\n        case Node.COMMENT_NODE:\n            removeNode(node);\n            break;\n\n        case Node.DOCUMENT_FRAGMENT_NODE:\n            iterateElement(filterNode(elementFilter), node as DocumentFragment);\n            break;\n\n        case Node.ELEMENT_NODE:\n            iterateElement(filterNode(elementFilter), node as Element);\n            elementFilter(node as Element);\n            break;\n\n        default:\n            // do nothing\n    }\n};\n"
  },
  {
    "path": "ts/html-filter/styling.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/** Keep property if true. */\ntype StylingPredicate = (property: string, value: string) => boolean;\n\nconst keep = (_key: string, _value: string) => true;\nconst discard = (_key: string, _value: string) => false;\n\n/** Return a function that filters out certain styles.\n   - If the style is listed in `exceptions`, the provided predicate is used.\n   - If the style is not listed, the default predicate is used instead. */\nfunction filterStyling(\n    defaultPredicate: StylingPredicate,\n    exceptions: Record<string, StylingPredicate>,\n): (element: HTMLElement) => void {\n    return (element: HTMLElement): void => {\n        // jsdom does not support @@iterator, so manually iterate\n        const toRemove: string[] = [];\n        for (let i = 0; i < element.style.length; i++) {\n            const key = element.style.item(i);\n            const value = element.style.getPropertyValue(key);\n            const predicate = exceptions[key] ?? defaultPredicate;\n            if (!predicate(key, value)) {\n                toRemove.push(key);\n            }\n        }\n        for (const key of toRemove) {\n            element.style.removeProperty(key);\n        }\n    };\n}\n\nconst nightModeExceptions = {\n    \"font-weight\": keep,\n    \"font-style\": keep,\n    \"text-decoration-line\": keep,\n};\n\nexport const filterStylingNightMode = filterStyling(discard, nightModeExceptions);\nexport const filterStylingLightMode = filterStyling(discard, {\n    color: keep,\n    \"background-color\": (_key: string, value: string) => value != \"transparent\",\n    ...nightModeExceptions,\n});\nexport const filterStylingInternal = filterStyling(keep, {\n    \"font-size\": discard,\n    \"font-family\": discard,\n    width: discard,\n    height: discard,\n    \"max-width\": discard,\n    \"max-height\": discard,\n});\n"
  },
  {
    "path": "ts/lib/components/Absolute.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let top: boolean = false;\n    export let bottom: boolean = false;\n    export let left: boolean = false;\n    export let right: boolean = false;\n</script>\n\n<div class=\"absolute\" class:top class:bottom class:left class:right>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .absolute {\n        position: absolute;\n        margin: var(--margin, 0);\n        z-index: 20;\n    }\n\n    .top {\n        top: 0;\n    }\n\n    .bottom {\n        bottom: 0;\n    }\n\n    .left {\n        left: 0;\n    }\n\n    .right {\n        right: 0;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/BackendProgressIndicator.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { OpChanges, Progress } from \"@generated/anki/collection_pb\";\n    import { runWithBackendProgress } from \"@tslib/progress\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    type ResultWithChanges = OpChanges | { changes?: OpChanges };\n\n    export let task: () => Promise<ResultWithChanges | undefined>;\n    export let result: ResultWithChanges | undefined;\n    export let error: Error | undefined;\n    let label: string = \"\";\n\n    function onUpdate(progress: Progress) {\n        if (\n            progress.value.value &&\n            progress.value.case !== \"none\" &&\n            label !== progress.value.value.toString()\n        ) {\n            label = progress.value.value.toString();\n        }\n    }\n    $: (async () => {\n        if (!result && !error) {\n            try {\n                result = await runWithBackendProgress(task, onUpdate);\n            } catch (err) {\n                if (err instanceof Error) {\n                    error = err;\n                } else {\n                    throw err;\n                }\n            }\n        }\n    })();\n</script>\n\n<!-- spinner taken from https://loading.io/css/; CC0 -->\n{#if !result}\n    <div class=\"progress\">\n        <div class=\"spinner\" class:nightMode={$pageTheme.isDark}>\n            <div></div>\n            <div></div>\n            <div></div>\n            <div></div>\n        </div>\n        <div id=\"label\">{label}</div>\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    .progress {\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        transform: translate(-50%, -50%);\n    }\n\n    .spinner {\n        display: block;\n        position: relative;\n        width: 80px;\n        height: 80px;\n        margin: 0 auto;\n\n        div {\n            display: block;\n            position: absolute;\n            width: 64px;\n            height: 64px;\n            margin: 8px;\n            border: 8px solid #000;\n            border-radius: 50%;\n            animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;\n            border-color: #000 transparent transparent transparent;\n        }\n        &.nightMode div {\n            border-top-color: #fff;\n        }\n        div:nth-child(1) {\n            animation-delay: -0.45s;\n        }\n        div:nth-child(2) {\n            animation-delay: -0.3s;\n        }\n        div:nth-child(3) {\n            animation-delay: -0.15s;\n        }\n    }\n\n    @keyframes spin {\n        0% {\n            transform: rotate(0deg);\n        }\n        100% {\n            transform: rotate(360deg);\n        }\n    }\n    #label {\n        text-align: center;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Badge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    import IconConstrain from \"./IconConstrain.svelte\";\n\n    let className = \"\";\n    export { className as class };\n    export let tooltip: string | undefined = undefined;\n\n    export let iconSize = 100;\n    export let widthMultiplier = 1;\n    export let flipX = false;\n\n    const dispatch = createEventDispatcher();\n\n    let spanRef: HTMLSpanElement;\n\n    onMount(() => {\n        dispatch(\"mount\", { span: spanRef });\n    });\n</script>\n\n<button\n    bind:this={spanRef}\n    title={tooltip}\n    class=\"badge {className}\"\n    on:click\n    on:mouseenter\n    on:mouseleave\n    tabindex=\"-1\"\n>\n    <IconConstrain {iconSize} {widthMultiplier} {flipX}>\n        <slot />\n    </IconConstrain>\n</button>\n\n<style>\n    .badge {\n        color: var(--badge-color, inherit);\n        border: none;\n        background: transparent;\n        padding: 0;\n        /* remove default macOS styling */\n        box-shadow: none;\n    }\n\n    .badge:hover,\n    .badge:active {\n        border: none;\n        background: transparent;\n        box-shadow: none;\n    }\n\n    .dropdown-toggle::after {\n        display: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/ButtonGroup.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    export let size: number | undefined = undefined;\n    export let wrap: boolean | undefined = undefined;\n\n    $: buttonSize = size ? `--buttons-size: ${size}rem; ` : \"\";\n    let buttonWrap: string;\n    $: if (wrap === undefined) {\n        buttonWrap = \"\";\n    } else {\n        buttonWrap = wrap ? `--buttons-wrap: wrap; ` : `--buttons-wrap: nowrap; `;\n    }\n\n    $: style = buttonSize + buttonWrap;\n</script>\n\n<div {id} class=\"button-group btn-group {className}\" {style} dir=\"ltr\" role=\"group\">\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .button-group {\n        display: flex;\n        flex-flow: row var(--buttons-wrap);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/ButtonGroupItem.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n    import { get, writable } from \"svelte/store\";\n\n    import contextProperty from \"$lib/sveltelib/context-property\";\n    import type {\n        GetSlotHostProps,\n        SlotHostProps,\n    } from \"$lib/sveltelib/dynamic-slotting\";\n\n    enum ButtonPosition {\n        Standalone,\n        InlineStart,\n        Center,\n        InlineEnd,\n    }\n\n    interface ButtonSlotHostProps extends SlotHostProps {\n        position: Writable<ButtonPosition>;\n    }\n\n    const key = Symbol(\"buttonGroup\");\n    const [context, setSlotHostContext] =\n        contextProperty<GetSlotHostProps<ButtonSlotHostProps>>(key);\n\n    export { setSlotHostContext };\n\n    export function createProps(): ButtonSlotHostProps {\n        return {\n            detach: writable(false),\n            position: writable(ButtonPosition.Standalone),\n        };\n    }\n\n    function nonDetached(props: ButtonSlotHostProps): boolean {\n        return !get(props.detach);\n    }\n\n    export function updatePropsList(\n        propsList: ButtonSlotHostProps[],\n    ): ButtonSlotHostProps[] {\n        const list = Array.from(propsList.filter(nonDetached).entries());\n\n        for (const [index, props] of list) {\n            const position = props.position;\n\n            if (list.length === 1) {\n                position.set(ButtonPosition.Standalone);\n            } else if (index === 0) {\n                position.set(ButtonPosition.InlineStart);\n            } else if (index === list.length - 1) {\n                position.set(ButtonPosition.InlineEnd);\n            } else {\n                position.set(ButtonPosition.Center);\n            }\n        }\n\n        return propsList;\n    }\n</script>\n\n<script lang=\"ts\">\n    export let id: string | undefined = undefined;\n    export let hostProps: ButtonSlotHostProps | undefined = undefined;\n\n    let style: string;\n\n    if (!context.available()) {\n        console.log(\"ButtonGroupItem: should always have a slotHostContext\");\n    }\n\n    const { detach, position } = hostProps ?? context.get().getProps();\n    const radius = \"5px\";\n\n    function updateButtonStyle(position: ButtonPosition) {\n        switch (position) {\n            case ButtonPosition.Standalone:\n                style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;\n                break;\n            case ButtonPosition.InlineStart:\n                style = `--border-left-radius: ${radius}; --border-right-radius: 0; `;\n                break;\n            case ButtonPosition.Center:\n                style = \"--border-left-radius: 0; --border-right-radius: 0; \";\n                break;\n            case ButtonPosition.InlineEnd:\n                style = `--border-left-radius: 0; --border-right-radius: ${radius}; `;\n                break;\n        }\n    }\n\n    $: updateButtonStyle($position);\n</script>\n\n<!-- div is necessary to preserve item position -->\n<div class=\"button-group-item\" {id} {style}>\n    {#if !$detach}\n        <slot />\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .button-group-item {\n        display: contents;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/ButtonToolbar.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    export let size: number | undefined = undefined;\n    export let wrap: boolean | undefined = undefined;\n\n    $: buttonSize = size ? `--buttons-size: ${size}rem; ` : \"\";\n    let buttonWrap: string;\n    $: if (wrap === undefined) {\n        buttonWrap = \"\";\n    } else {\n        buttonWrap = wrap ? `--buttons-wrap: wrap; ` : `--buttons-wrap: nowrap; `;\n    }\n\n    $: style = buttonSize + buttonWrap;\n</script>\n\n<div\n    {id}\n    class=\"button-toolbar btn-toolbar {className}\"\n    class:nightMode={$pageTheme.isDark}\n    style:--icon-align=\"baseline\"\n    {style}\n    role=\"toolbar\"\n    on:focusout\n>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .button-toolbar {\n        flex-wrap: var(--buttons-wrap);\n        padding-left: 0.15rem;\n\n        :global(.button-group) {\n            /* TODO replace with gap once available (blocked by Qt5 / Chromium 77) */\n            margin-right: 0.3rem;\n            margin-bottom: 0.15rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/CheckBox.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let value: boolean;\n</script>\n\n<label>\n    <input type=\"checkbox\" bind:checked={value} />\n    <slot />\n</label>\n\n<style lang=\"scss\">\n    label {\n        line-height: inherit;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Col.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { Breakpoint } from \"./types\";\n\n    let className: string = \"\";\n    export { className as class };\n\n    /* flex-basis: 100% if viewport < breakpoint otherwise\n     * as specified by --cols and --col-size */\n    export let breakpoint: Breakpoint = \"xs\";\n</script>\n\n<div\n    class=\"col {className}\"\n    class:col-xs={breakpoint === \"xs\"}\n    class:col-sm={breakpoint === \"sm\"}\n    class:col-md={breakpoint === \"md\"}\n    class:col-lg={breakpoint === \"lg\"}\n    class:col-xl={breakpoint === \"xl\"}\n    class:col-xxl={breakpoint === \"xxl\"}\n>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    @use \"../sass/breakpoints\" as bp;\n\n    .col {\n        display: flex;\n        flex-flow: row nowrap;\n        align-items: var(--col-align, flex-start);\n        justify-content: var(--col-justify, flex-start);\n        padding: 0 var(--gutter-inline, 0);\n        flex: 1 0 100%;\n    }\n\n    $calc: calc(100% / var(--cols, 1) * var(--col-size, 1));\n\n    @include bp.with-breakpoints(\n        \"col\",\n        (\n            \"flex-basis\": (\n                \"xs\": $calc,\n                \"sm\": $calc,\n                \"md\": $calc,\n                \"lg\": $calc,\n                \"xl\": $calc,\n                \"xxl\": $calc,\n            ),\n        )\n    );\n</style>\n"
  },
  {
    "path": "ts/lib/components/Collapsible.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { tick } from \"svelte\";\n    import { cubicIn, cubicOut } from \"svelte/easing\";\n    import { tweened } from \"svelte/motion\";\n\n    export let collapse = false;\n    export let toggleDisplay = false;\n    export let animated = !document.body.classList.contains(\"reduce-motion\");\n\n    let contentHeight = 0;\n\n    function dynamicDuration(height: number): number {\n        return 100 + Math.pow(height, 1 / 4) * 25;\n    }\n    $: duration = dynamicDuration(contentHeight);\n\n    const size = tweened<number | undefined>(undefined);\n\n    async function transition(collapse: boolean): Promise<void> {\n        if (collapse) {\n            contentHeight = collapsibleElement.clientHeight;\n            size.set(0, {\n                duration: duration,\n                easing: cubicOut,\n            });\n        } else {\n            /* Tell content to show and await response */\n            collapsed = false;\n            await tick();\n            /* Measure content height to tween to */\n            contentHeight = collapsibleElement.clientHeight;\n            size.set(1, {\n                duration: duration,\n                easing: cubicIn,\n            });\n        }\n    }\n\n    $: if (collapsibleElement) {\n        if (animated) {\n            transition(collapse);\n        } else {\n            collapsed = collapse;\n        }\n    }\n\n    let collapsibleElement: HTMLElement;\n\n    $: collapsed = ($size ?? 0) === 0;\n    $: expanded = $size === 1;\n    $: height = ($size ?? 0) * contentHeight;\n    $: transitioning = ($size ?? 0) > 0 && !(collapsed || expanded);\n    $: measuring = !(collapsed || transitioning || expanded);\n\n    let hidden = collapsed;\n\n    $: {\n        /* await changes dependent on collapsed state */\n        tick().then(() => (hidden = collapsed));\n    }\n</script>\n\n<div\n    bind:this={collapsibleElement}\n    class=\"collapsible\"\n    class:animated\n    class:expanded\n    class:full-hide={toggleDisplay}\n    class:measuring\n    class:transitioning\n    class:hidden\n    style:--height=\"{height}px\"\n>\n    <slot {collapsed} />\n</div>\n\n{#if animated && measuring}\n    <!-- Maintain document flow while collapsible height is measured -->\n    <div class=\"collapsible-placeholder\"></div>\n{/if}\n\n<style lang=\"scss\">\n    .collapsible {\n        &.animated {\n            &.measuring {\n                display: initial;\n                position: absolute;\n                opacity: 0;\n            }\n\n            &.transitioning {\n                overflow: hidden;\n                height: var(--height);\n                &.expanded {\n                    overflow: visible;\n                }\n                &.full-hide {\n                    display: initial;\n                }\n            }\n        }\n        &.full-hide {\n            &.hidden {\n                display: none;\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/ConfigInput.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n\n    export let grow = true;\n</script>\n\n<div\n    class=\"config-input position-relative justify-content-end\"\n    class:flex-grow-1={grow}\n>\n    <div class=\"revert\" class:rtl>\n        <slot name=\"revert\" />\n    </div>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .revert {\n        position: absolute;\n        right: -1.7em;\n        bottom: -1px;\n        color: var(--fg-faint);\n        &.rtl {\n            right: unset;\n            left: -1.7em;\n        }\n    }\n    .config-input {\n        &:hover,\n        &:focus-within {\n            .revert {\n                color: var(--fg-subtle);\n            }\n        }\n        .revert:hover {\n            color: var(--fg);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Container.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { Breakpoint } from \"./types\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    /* width: 100% if viewport < breakpoint otherwise with gutters */\n    export let breakpoint: Breakpoint | \"fluid\" = \"fluid\";\n</script>\n\n<div\n    {id}\n    class=\"container {className}\"\n    class:container-xs={breakpoint === \"xs\"}\n    class:container-sm={breakpoint === \"sm\"}\n    class:container-md={breakpoint === \"md\"}\n    class:container-lg={breakpoint === \"lg\"}\n    class:container-xl={breakpoint === \"xl\"}\n    class:container-xxl={breakpoint === \"xxl\"}\n    class:container-fluid={breakpoint === \"fluid\"}\n>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    @use \"../sass/breakpoints\";\n\n    .container {\n        display: flex;\n        flex-direction: var(--container-direction, column);\n\n        padding: var(--gutter-block, 0) var(--gutter-inline, 0);\n        margin: 0 auto;\n\n        &.container-fluid {\n            width: 100%;\n            height: 100%;\n\n            margin: 0;\n        }\n    }\n\n    @include breakpoints.with-breakpoints-upto(\n        \"container\",\n        (\n            \"max-width\": (\n                \"xs\": 360px,\n                \"sm\": 540px,\n                \"md\": 720px,\n                \"lg\": 960px,\n                \"xl\": 1140px,\n                \"xxl\": 1320px,\n            ),\n        )\n    );\n</style>\n"
  },
  {
    "path": "ts/lib/components/DropdownDivider.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<hr class=\"dropdown-divider\" />\n"
  },
  {
    "path": "ts/lib/components/DropdownItem.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let id: string | undefined = undefined;\n    export let role: string | undefined = undefined;\n    export let selected = false;\n    let className = \"\";\n    export { className as class };\n\n    export let buttonRef: HTMLButtonElement | undefined = undefined;\n\n    export let tooltip: string | undefined = undefined;\n\n    export let active = false;\n    export let disabled = false;\n\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n\n    $: if (buttonRef && active) {\n        buttonRef!.scrollIntoView({\n            behavior: \"smooth\",\n            block: \"nearest\",\n        });\n    }\n\n    export let tabbable = false;\n</script>\n\n<button\n    bind:this={buttonRef}\n    {id}\n    {role}\n    aria-selected={selected}\n    tabindex={tabbable ? 0 : -1}\n    class=\"dropdown-item {className}\"\n    class:active\n    class:rtl\n    title={tooltip}\n    {disabled}\n    on:mouseenter\n    on:focus\n    on:keydown\n    on:click\n    on:mousedown|preventDefault\n>\n    <slot />\n</button>\n\n<style lang=\"scss\">\n    button {\n        display: flex;\n        justify-content: start;\n        width: 100%;\n        padding: 0.25rem 1rem;\n        white-space: nowrap;\n        font-size: var(--dropdown-font-size, small);\n\n        background: none;\n        box-shadow: none !important;\n        border: none;\n        border-radius: 0;\n        color: var(--fg);\n\n        &:hover {\n            border: none;\n        }\n\n        &:hover:not([disabled]) {\n            background: var(--highlight-bg);\n            color: var(--highlight-fg);\n        }\n\n        &.focus {\n            // TODO this is subtly different from hovering with the mouse for some reason\n            @extend button, :hover;\n        }\n\n        &[disabled] {\n            cursor: default;\n            color: var(--fg-disabled);\n        }\n\n        /* selection highlight */\n        &:not(.rtl) {\n            border-left: 3px solid transparent;\n        }\n        &.rtl {\n            border-right: 3px solid transparent;\n        }\n        &.active {\n            &:not(.rtl) {\n                border-left-color: var(--border-focus);\n            }\n            &.rtl {\n                border-right-color: var(--border-focus);\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/DynamicallySlottable.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    /* import type { SlotHostProps } from \"$lib/sveltelib/dynamic-slotting\"; */\n    import dynamicSlotting, {\n        defaultInterface,\n        defaultProps,\n        setSlotHostContext as defaultContext,\n    } from \"$lib/sveltelib/dynamic-slotting\";\n\n    function id<T>(value: T): T {\n        return value;\n    }\n\n    /**\n     * This should be a Svelte component that accepts `id` and `hostProps`\n     * as their props, only mounts a div with display:contents, and retrieves\n     * its props via .getProps().\n     * For a minimal example, have a look at `Item.svelte`.\n     */\n    export let slotHost: any; // typeof Item | typeof ButtonGroupItem;\n\n    /**\n     * We cannot properly type these right now.\n     */\n    export let createProps: any /* <T extends SlotHostProps>() => T */ =\n        defaultProps as any;\n    export let updatePropsList: any /* <T extends SlotHostProps>(list: T[]) => T[] */ =\n        id;\n    export let setSlotHostContext: any = defaultContext;\n    export let createInterface = defaultInterface;\n\n    const { slotsInterface, resolveSlotContainer, dynamicSlotted } = dynamicSlotting(\n        createProps,\n        updatePropsList,\n        setSlotHostContext,\n        createInterface,\n    );\n\n    export let api: Partial<Record<string, unknown>>;\n\n    Object.assign(api, slotsInterface);\n</script>\n\n<div class=\"dynamically-slottable\" use:resolveSlotContainer>\n    <slot />\n\n    {#each $dynamicSlotted as { component, hostProps } (component.id)}\n        <svelte:component this={slotHost} id={component.id} {hostProps}>\n            <svelte:component this={component.component} {...component.props} />\n        </svelte:component>\n    {/each}\n</div>\n\n<style lang=\"scss\">\n    .dynamically-slottable {\n        display: contents;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/EnumSelector.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    export interface Choice<T> {\n        label: string;\n        value: T;\n    }\n</script>\n\n<script lang=\"ts\">\n    import Select from \"./Select.svelte\";\n\n    type T = $$Generic;\n\n    export let value: T;\n    export let choices: Choice<T>[] = [];\n    export let disabled: boolean = false;\n    export let disabledChoices: T[] = [];\n\n    $: label = choices.find((c) => c.value === value)?.label;\n    $: parser = (item) => ({\n        content: item.label,\n        value: item.value,\n        disabled: disabledChoices.includes(item.value),\n    });\n</script>\n\n<Select bind:value {label} {disabled} list={choices} {parser} />\n"
  },
  {
    "path": "ts/lib/components/EnumSelectorRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"./Col.svelte\";\n    import ConfigInput from \"./ConfigInput.svelte\";\n    import EnumSelector, { type Choice } from \"./EnumSelector.svelte\";\n    import RevertButton from \"./RevertButton.svelte\";\n    import Row from \"./Row.svelte\";\n    import type { Breakpoint } from \"./types\";\n\n    type T = $$Generic;\n\n    export let value: T;\n    export let defaultValue: T;\n    export let breakpoint: Breakpoint = \"md\";\n    export let choices: Choice<T>[];\n    export let disabled: boolean = false;\n    export let disabledChoices: T[] = [];\n</script>\n\n<Row --cols={13}>\n    <Col --col-size={7} {breakpoint}>\n        <slot />\n    </Col>\n    <Col --col-size={6} {breakpoint}>\n        <ConfigInput>\n            <EnumSelector bind:value {choices} {disabled} {disabledChoices} />\n            <RevertButton slot=\"revert\" bind:value {defaultValue} />\n        </ConfigInput>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/lib/components/ErrorPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let error: Error;\n</script>\n\n<div class=\"message\">\n    {error.message}\n</div>\n\n<style lang=\"scss\">\n    .message {\n        text-align: center;\n        margin: 50px 0 0;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/FloatingArrow.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<div class=\"arrow\"></div>\n\n<style lang=\"scss\">\n    @use \"../sass/elevation\" as elevation;\n\n    .arrow {\n        background-color: var(--canvas-elevated);\n        width: 10px;\n        height: 10px;\n        z-index: 60;\n\n        /* outer border */\n        border: 1px solid var(--border-subtle);\n\n        /* Rotate the box to indicate the different directions */\n        border-right: none;\n        border-bottom: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/HelpModal.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { renderMarkdown } from \"@tslib/helpers\";\n    import Carousel from \"bootstrap/js/dist/carousel\";\n    import Modal from \"bootstrap/js/dist/modal\";\n    import { createEventDispatcher, getContext, onDestroy, onMount } from \"svelte\";\n\n    import { infoCircle } from \"$lib/components/icons\";\n    import { registerModalClosingHandler } from \"$lib/sveltelib/modal-closing\";\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import Badge from \"./Badge.svelte\";\n    import Col from \"./Col.svelte\";\n    import { modalsKey } from \"./context-keys\";\n    import HelpSection from \"./HelpSection.svelte\";\n    import Icon from \"./Icon.svelte\";\n    import Row from \"./Row.svelte\";\n    import { type HelpItem, HelpItemScheduler } from \"./types\";\n\n    export let title: string;\n    export let url: string;\n    export let linkLabel: string | undefined = undefined;\n    export let startIndex = 0;\n    export let helpSections: HelpItem[];\n    export let fsrs = false;\n\n    export const modalKey: string = Math.random().toString(36).substring(2);\n\n    const modals = getContext<Map<string, Modal>>(modalsKey);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    let modalRef: HTMLDivElement;\n    let carouselRef: HTMLDivElement;\n\n    function onOkClicked(): void {\n        modal.hide();\n    }\n\n    const dispatch = createEventDispatcher();\n\n    const { set: setModalOpen, remove: removeModalClosingHandler } =\n        registerModalClosingHandler(onOkClicked);\n\n    function onShown() {\n        setModalOpen(true);\n    }\n\n    function onHidden() {\n        setModalOpen(false);\n    }\n\n    onMount(() => {\n        modalRef.addEventListener(\"shown.bs.modal\", onShown);\n        modalRef.addEventListener(\"hidden.bs.modal\", onHidden);\n        modal = new Modal(modalRef, { keyboard: false });\n        carousel = new Carousel(carouselRef, { interval: false, ride: false });\n        /* Bootstrap's Carousel.Event interface doesn't seem to work as a type here */\n        carouselRef.addEventListener(\"slide.bs.carousel\", (e: any) => {\n            activeIndex = e.to;\n        });\n        dispatch(\"mount\", { modal: modal, carousel: carousel });\n        modals.set(modalKey, modal);\n    });\n\n    onDestroy(() => {\n        removeModalClosingHandler();\n        modalRef.removeEventListener(\"shown.bs.modal\", onShown);\n        modalRef.removeEventListener(\"hidden.bs.modal\", onHidden);\n    });\n\n    let activeIndex = startIndex;\n</script>\n\n<Badge on:click={() => modal.show()} iconSize={125}>\n    <Icon icon={infoCircle} />\n</Badge>\n\n<div\n    bind:this={modalRef}\n    class=\"modal fade\"\n    tabindex=\"-1\"\n    aria-labelledby=\"modalLabel\"\n    aria-hidden=\"true\"\n>\n    <div class=\"modal-dialog modal-lg\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <div style=\"display: flex;\">\n                    <h1 class=\"modal-title\" id=\"modalLabel\">\n                        {title}\n                    </h1>\n                    <button\n                        type=\"button\"\n                        class=\"btn-close\"\n                        class:invert={$pageTheme.isDark}\n                        data-bs-dismiss=\"modal\"\n                        aria-label=\"Close\"\n                    ></button>\n                </div>\n                {#if url}\n                    <div class=\"chapter-redirect\">\n                        {@html renderMarkdown(\n                            tr.helpForMoreInfo({\n                                link: `<a href=\"${url}\" title=\"${tr.helpOpenManualChapter({ name: linkLabel ?? title })}\">${linkLabel ?? title}</a>`,\n                            }),\n                        )}\n                    </div>\n                {/if}\n            </div>\n            <div class=\"modal-body\">\n                <Row --cols={4}>\n                    <Col --col-size={1}>\n                        <nav>\n                            <div id=\"nav\">\n                                <ul>\n                                    {#each helpSections as item, i}\n                                        <li>\n                                            <button\n                                                on:click={() => {\n                                                    activeIndex = i;\n                                                    carousel.to(activeIndex);\n                                                }}\n                                                class:active={i == activeIndex}\n                                                class:d-none={fsrs\n                                                    ? item.sched ===\n                                                      HelpItemScheduler.SM2\n                                                    : item.sched ==\n                                                      HelpItemScheduler.FSRS}\n                                            >\n                                                {item.title}\n                                            </button>\n                                        </li>\n                                    {/each}\n                                </ul>\n                            </div>\n                        </nav>\n                    </Col>\n                    <Col --col-size={3}>\n                        <div\n                            id=\"helpSectionIndicators\"\n                            class=\"carousel slide\"\n                            bind:this={carouselRef}\n                        >\n                            <div class=\"carousel-inner\">\n                                {#each helpSections as item, i}\n                                    <div\n                                        class=\"carousel-item\"\n                                        class:active={i == startIndex}\n                                        class:d-none={fsrs\n                                            ? item.sched === HelpItemScheduler.SM2\n                                            : item.sched == HelpItemScheduler.FSRS}\n                                    >\n                                        <HelpSection {item} />\n                                    </div>\n                                {/each}\n                            </div>\n                        </div>\n                    </Col>\n                </Row>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-primary\" on:click={onOkClicked}>\n                    {tr.helpOk()}\n                </button>\n            </div>\n        </div>\n    </div>\n</div>\n\n<style lang=\"scss\">\n    #nav {\n        margin-bottom: 1.5rem;\n    }\n\n    .modal {\n        z-index: 1066;\n        background-color: rgba($color: black, $alpha: 0.5);\n    }\n\n    .modal-title {\n        margin-inline-end: 0.75rem;\n    }\n\n    .modal-content {\n        background-color: var(--canvas);\n        color: var(--fg);\n        border-radius: var(--border-radius-medium, 10px);\n    }\n\n    .invert {\n        filter: invert(1) grayscale(100%) brightness(200%);\n    }\n\n    ul {\n        list-style-type: none;\n        margin: 0;\n        padding: 0;\n    }\n\n    li button {\n        display: block;\n        padding: 0.5rem 0.75rem;\n        text-decoration: none;\n        text-align: start;\n        min-width: 250px;\n        background-color: var(--canvas);\n        border: 1px solid transparent;\n        cursor: pointer;\n        border-radius: 0;\n        &:hover {\n            background-color: var(--canvas-inset);\n        }\n        &.active {\n            border-inline-start: 4px solid var(--border-focus);\n        }\n    }\n\n    .modal-header {\n        flex-direction: column;\n        align-items: normal;\n        padding-bottom: 0;\n    }\n\n    .chapter-redirect {\n        width: 100%;\n        color: var(--fg-subtle);\n        font-size: small;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/HelpSection.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { renderMarkdown } from \"@tslib/helpers\";\n\n    import Row from \"./Row.svelte\";\n    import type { HelpItem } from \"./types\";\n    import { mdiEarth } from \"./icons\";\n    import Icon from \"./Icon.svelte\";\n\n    export let item: HelpItem;\n</script>\n\n<Row>\n    <h2>\n        {#if item.url}\n            {@html item.title}\n        {:else}\n            {@html item.title}\n        {/if}\n    </h2>\n    {#if item.help}\n        {#if item.global}\n            <div class=\"icon\">\n                <Icon icon={mdiEarth} />\n            </div>\n        {/if}\n        {@html renderMarkdown(item.help)}\n    {:else}\n        {@html renderMarkdown(\n            tr.helpNoExplanation({\n                link: \"[GitHub](https://github.com/ankitects/anki)\",\n            }),\n        )}\n    {/if}\n</Row>\n{#if item.url}\n    <hr />\n    <div class=\"chapter-redirect\">\n        {@html renderMarkdown(\n            tr.helpForMoreInfo({\n                link: `<a href=\"${item.url}\" title=\"${tr.helpOpenManualChapter({\n                    name: item.title,\n                })}\">${item.title}</a>`,\n            }),\n        )}\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    h2 {\n        margin-bottom: 1em;\n        width: 100%;\n    }\n\n    .chapter-redirect {\n        width: 100%;\n        color: var(--fg-subtle);\n        font-size: small;\n    }\n\n    .icon {\n        display: inline-block;\n        width: 1em;\n        fill: currentColor;\n        margin-right: 0.25em;\n        margin-bottom: 1.25em;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Icon.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { IconData } from \"./types\";\n\n    export let icon: IconData;\n\n    let component: any = null;\n    if (import.meta.env) {\n        // @ts-expect-error internal property\n        component = icon.component;\n    }\n</script>\n\n{#if component}\n    <svelte:component this={component} />\n{:else}\n    {@html icon.url}\n{/if}\n"
  },
  {
    "path": "ts/lib/components/IconButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import IconConstrain from \"./IconConstrain.svelte\";\n\n    export let id: string | undefined = undefined;\n    let className = \"\";\n    export { className as class };\n\n    export let tooltip: string | undefined = undefined;\n    export let primary = false;\n    export let active = false;\n    export let disabled = false;\n    export let tabbable = false;\n\n    export let iconSize = 75;\n    export let widthMultiplier = 1;\n    export let flipX = false;\n</script>\n\n<button\n    {id}\n    class=\"icon-button {className}\"\n    class:active\n    class:primary\n    title={tooltip}\n    {disabled}\n    tabindex={tabbable ? 0 : -1}\n    on:click\n    on:mousedown|preventDefault\n>\n    <IconConstrain {flipX} {widthMultiplier} {iconSize}>\n        <slot />\n    </IconConstrain>\n</button>\n\n<style lang=\"scss\">\n    @use \"../sass/button-mixins\" as button;\n\n    .icon-button {\n        @include button.base($active-class: active);\n        &.primary {\n            @include button.base($primary: true);\n        }\n        @include button.border-radius;\n\n        padding: 0 var(--padding-inline, 0);\n        font-size: var(--font-size);\n        height: var(--buttons-size);\n        min-width: calc(var(--buttons-size) * 0.75);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/IconConstrain.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let iconSize: number = 100;\n    export let widthMultiplier: number = 1;\n    export let flipX: boolean = false;\n</script>\n\n<span\n    class:flip-x={flipX}\n    style=\"--width-multiplier: {widthMultiplier}; --icon-size: {iconSize}%;\"\n>\n    <slot />\n</span>\n\n<style lang=\"scss\">\n    span {\n        display: inline-block;\n        position: relative;\n        vertical-align: var(--icon-align, middle);\n\n        /* constrain icon */\n        min-width: calc((var(--buttons-size, 22px) - 2px) * var(--width-multiplier));\n        height: calc(var(--buttons-size, 22px) - 2px);\n\n        & > :global(svg),\n        & > :global(img) {\n            position: absolute;\n            width: var(--icon-size);\n            height: var(--icon-size);\n            top: calc((100% - var(--icon-size)) / 2);\n            bottom: calc((100% - var(--icon-size)) / 2);\n            left: calc((100% - var(--icon-size)) / 2);\n            right: calc((100% - var(--icon-size)) / 2);\n\n            fill: currentColor;\n            vertical-align: unset;\n        }\n\n        &.flip-x > :global(svg),\n        &.flip-x > :global(img) {\n            transform: scaleX(-1);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Item.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { SlotHostProps } from \"$lib/sveltelib/dynamic-slotting\";\n    import { defaultSlotHostContext } from \"$lib/sveltelib/dynamic-slotting\";\n\n    export let id: string | undefined = undefined;\n    export let hostProps: SlotHostProps | undefined = undefined;\n\n    if (!defaultSlotHostContext.available()) {\n        console.log(\"Item: should always have a slotHostContext\");\n    }\n\n    const { detach } = hostProps ?? defaultSlotHostContext.get().getProps();\n</script>\n\n<!-- div is necessary to preserve item position -->\n<div class=\"item\" {id}>\n    {#if !$detach}\n        <slot />\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .item {\n        display: contents;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Label.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    let forId: string;\n    export { forId as for };\n    export let preventMouseClick = false;\n\n    const dispatch = createEventDispatcher();\n\n    let spanRef: HTMLSpanElement;\n\n    onMount(() => {\n        dispatch(\"mount\", { span: spanRef });\n    });\n</script>\n\n<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->\n<!-- svelte-ignore a11y-click-events-have-key-events -->\n<label\n    bind:this={spanRef}\n    for={forId}\n    on:click={(e) => {\n        if (preventMouseClick) {\n            e.preventDefault();\n        }\n    }}\n>\n    <slot />\n</label>\n\n<style lang=\"scss\">\n    label {\n        display: inline;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/LabelButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n    export let primary = false;\n\n    export let tooltip: string | undefined = undefined;\n    export let active = false;\n    export let disabled = false;\n    export let tabbable = false;\n    export let ellipsis = false;\n\n    let buttonRef: HTMLButtonElement;\n\n    const dispatch = createEventDispatcher();\n    onMount(() => dispatch(\"mount\", { button: buttonRef }));\n</script>\n\n<button\n    bind:this={buttonRef}\n    {id}\n    class=\"label-button {className}\"\n    class:active\n    class:primary\n    class:ellipsis\n    title={tooltip}\n    {disabled}\n    tabindex={tabbable ? 0 : -1}\n    on:click\n    on:mousedown|preventDefault\n>\n    <slot />\n</button>\n\n<style lang=\"scss\">\n    @use \"../sass/button-mixins\" as button;\n\n    .label-button {\n        @include button.base($active-class: active);\n        &.primary {\n            @include button.base($primary: true);\n        }\n        @include button.border-radius;\n\n        white-space: nowrap;\n        &.ellipsis {\n            overflow: hidden;\n            text-overflow: ellipsis;\n        }\n        padding: 0 calc(var(--buttons-size) / 3);\n        font-size: var(--font-size);\n        width: auto;\n        height: var(--buttons-size);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Popover.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { Placement } from \"@floating-ui/dom\";\n    import { createEventDispatcher, getContext, onMount } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n\n    import { floatingKey } from \"./context-keys\";\n\n    export let id = \"\";\n    export let scrollable = false;\n    let wrapper: HTMLDivElement;\n    let hidden = true;\n    let minHeight = 0;\n\n    let placement: Placement;\n\n    const dispatch = createEventDispatcher();\n\n    const placementStore = getContext<Writable<Promise<Placement>>>(floatingKey);\n\n    /* await computed placement of floating element to determine animation direction */\n    $: if ($placementStore !== undefined && hidden) {\n        $placementStore.then((computedPlacement) => {\n            if (placement != computedPlacement) {\n                placement = computedPlacement;\n                hidden = false;\n            }\n        });\n    }\n\n    onMount(async () => {\n        /* set min-height on wrapper to ensure correct\n           popover placement at animation start */\n        minHeight = wrapper.offsetHeight;\n    });\n    function revealed(el: HTMLElement) {\n        dispatch(\"revealed\", el);\n    }\n</script>\n\n<div\n    class=\"popover-wrapper d-flex\"\n    style:--min-height=\"{minHeight}px\"\n    bind:this={wrapper}\n>\n    <div\n        class=\"popover\"\n        class:scrollable\n        class:hidden\n        class:top={placement === \"top\"}\n        class:right={placement === \"right\"}\n        class:bottom={placement === \"bottom\"}\n        class:left={placement === \"left\"}\n        use:revealed\n        {id}\n        role=\"listbox\"\n    >\n        <slot />\n    </div>\n</div>\n\n<style lang=\"scss\">\n    @use \"../sass/elevation\" as elevation;\n\n    .popover-wrapper {\n        min-height: var(--min-height, 0);\n    }\n\n    .popover {\n        @include elevation.elevation(8);\n\n        align-self: flex-start;\n        border-radius: var(--border-radius);\n        background-color: var(--canvas-elevated);\n        border: 1px solid var(--border-subtle);\n\n        min-width: var(--popover-width, 1rem);\n        max-width: 95vw;\n\n        /* Needs this much space for FloatingArrow to be positioned */\n        padding: var(--popover-padding-block, 6px) var(--popover-padding-inline, 6px);\n\n        &.scrollable {\n            max-height: 400px;\n            overflow: hidden auto;\n        }\n\n        &.hidden {\n            visibility: hidden;\n        }\n\n        /* alignment determines slide animation direction */\n        &.top,\n        &.left {\n            align-self: flex-end;\n        }\n        &.bottom,\n        &.right {\n            align-self: flex-start;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Portal.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n\n<!-- Based on https://github.com/wobsoriano/svelte-portal; MIT -->\n\n<script lang=\"ts\">\n    import { mount, unmount, type Snippet } from \"svelte\";\n    import RenderChildren from \"./RenderChildren.svelte\";\n\n    const {\n        children,\n        target,\n    }: {\n        children: Snippet;\n        target: HTMLElement | null;\n    } = $props();\n\n    $effect(() => {\n        let app: Record<string, unknown>;\n\n        if (target) {\n            app = mount(RenderChildren, {\n                target: target,\n                props: {\n                    children,\n                    $$slots: { default: children },\n                },\n            });\n        }\n\n        return () => {\n            if (app) {\n                unmount(app);\n            }\n        };\n    });\n</script>\n\n{#if !target}\n    <!-- eslint-disable -->\n    <!-- svelte-ignore slot_element_deprecated -->\n    <slot />\n{/if}\n"
  },
  {
    "path": "ts/lib/components/RenderChildren.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n\n<slot />\n"
  },
  {
    "path": "ts/lib/components/RevertButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { cloneDeep, isEqual as isEqualLodash } from \"lodash-es\";\n\n    import { revertIcon } from \"$lib/components/icons\";\n\n    import Badge from \"./Badge.svelte\";\n    import DropdownItem from \"./DropdownItem.svelte\";\n    import Icon from \"./Icon.svelte\";\n    import Popover from \"./Popover.svelte\";\n    import WithFloating from \"./WithFloating.svelte\";\n\n    type T = unknown;\n\n    export let value: T;\n    export let defaultValue: T;\n\n    function isEqual(a: T, b: T): boolean {\n        if (typeof a === \"number\" && typeof b === \"number\") {\n            // round to .01 precision before comparing,\n            // so the values coming out of the UI match\n            // the originals\n            a = Math.round(a * 100) / 100;\n            b = Math.round(b * 100) / 100;\n        }\n\n        return isEqualLodash(a, b);\n    }\n\n    let modified: boolean;\n    $: modified = !isEqual(value, defaultValue);\n\n    let showFloating = false;\n\n    function revert(): void {\n        value = cloneDeep(defaultValue);\n        showFloating = false;\n    }\n</script>\n\n<WithFloating\n    show={showFloating}\n    closeOnInsideClick\n    inline\n    on:close={() => (showFloating = false)}\n    let:asReference\n>\n    <div class:hide={!modified} use:asReference>\n        <Badge\n            iconSize={85}\n            class=\"p-1\"\n            on:click={() => {\n                if (modified) {\n                    showFloating = !showFloating;\n                }\n            }}\n        >\n            <Icon icon={revertIcon} />\n        </Badge>\n    </div>\n\n    <Popover slot=\"floating\">\n        <DropdownItem on:click={() => revert()}>\n            {tr.deckConfigRevertButtonTooltip()}\n        </DropdownItem>\n    </Popover>\n</WithFloating>\n\n<style lang=\"scss\">\n    :global(.badge) {\n        cursor: pointer;\n    }\n\n    .hide :global(.badge) {\n        display: none;\n        cursor: initial;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Row.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    let className: string = \"\";\n    export { className as class };\n</script>\n\n<div class=\"row {className}\">\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .row {\n        display: flex;\n        flex-flow: row wrap;\n        align-content: stretch;\n        padding: var(--gutter-block, 0) 0;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/ScrollArea.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    let className: string = \"\";\n    export { className as class };\n    export let scrollX = false;\n    export let scrollY = false;\n    let scrollBarHeight = 0;\n    let measuring = true;\n\n    const scrollStates = {\n        top: false,\n        right: false,\n        bottom: false,\n        left: false,\n    };\n\n    function measureScrollbar(el: HTMLDivElement) {\n        scrollBarHeight = el.offsetHeight - el.clientHeight;\n        measuring = false;\n    }\n\n    const callback = (entries: IntersectionObserverEntry[]) => {\n        entries.forEach((entry) => {\n            scrollStates[entry.target.getAttribute(\"data-edge\")!] =\n                !entry.isIntersecting;\n        });\n    };\n\n    let observer: IntersectionObserver;\n    function initObserver(el: HTMLDivElement) {\n        observer = new IntersectionObserver(callback, { root: el });\n        for (const edge of el.getElementsByClassName(\"scroll-edge\")) {\n            observer.observe(edge);\n        }\n    }\n</script>\n\n<div class=\"scroll-area-relative\">\n    <div class=\"scroll-area-wrapper {className}\">\n        <div\n            class=\"scroll-area\"\n            class:measuring\n            class:scroll-x={scrollX}\n            class:scroll-y={scrollY}\n            style:--scrollbar-height=\"{scrollBarHeight}px\"\n            use:measureScrollbar\n            use:initObserver\n        >\n            <div class=\"d-flex flex-column flex-grow-1\">\n                <div class=\"scroll-edge\" data-edge=\"top\"></div>\n                <div class=\"d-flex flex-row flex-grow-1\">\n                    <div class=\"scroll-edge\" data-edge=\"left\"></div>\n                    <div class=\"scroll-content flex-grow-1\">\n                        <slot />\n                    </div>\n                    <div class=\"scroll-edge\" data-edge=\"right\"></div>\n                </div>\n                <div class=\"scroll-edge\" data-edge=\"bottom\"></div>\n            </div>\n        </div>\n\n        {#if scrollStates.top}\n            <div class=\"scroll-shadow top-0\"></div>\n        {/if}\n        {#if scrollStates.bottom}\n            <div class=\"scroll-shadow bottom-0\"></div>\n        {/if}\n        {#if scrollStates.left}\n            <div class=\"scroll-shadow start-0\"></div>\n        {/if}\n        {#if scrollStates.right}\n            <div class=\"scroll-shadow end-0\"></div>\n        {/if}\n    </div>\n</div>\n\n<style lang=\"scss\">\n    $shadow-top: inset 0 5px 5px -5px var(--shadow);\n    $shadow-bottom: inset 0 -5px 5px -5px var(--shadow);\n    $shadow-left: inset 5px 0 5px -5px var(--shadow);\n    $shadow-right: inset -5px 0 5px -5px var(--shadow);\n    .scroll-area-relative {\n        height: calc(var(--height) + var(--scrollbar-height));\n        flex-grow: 1;\n        position: relative;\n    }\n    .scroll-area {\n        position: absolute;\n        height: 100%;\n        width: 100%;\n        display: flex;\n        flex-direction: column;\n        overscroll-behavior: none;\n        overflow: auto;\n        &.scroll-x {\n            overflow-x: auto;\n            overflow-y: hidden;\n            overscroll-behavior-y: auto;\n        }\n        &.scroll-y {\n            overflow-y: auto;\n            overflow-x: hidden;\n            overscroll-behavior-x: none;\n        }\n        &.measuring {\n            visibility: hidden;\n            overflow: scroll;\n        }\n    }\n    .scroll-edge {\n        &[data-edge=\"top\"],\n        &[data-edge=\"bottom\"] {\n            height: 1px;\n        }\n        &[data-edge=\"left\"],\n        &[data-edge=\"right\"] {\n            width: 1px;\n        }\n    }\n    .scroll-shadow {\n        position: absolute;\n        pointer-events: none;\n        // z-index between LabelContainer (editor) and FloatingArrow\n        z-index: 55;\n        &.top-0,\n        &.bottom-0 {\n            left: 0;\n            right: 0;\n            height: 5px;\n        }\n        &.start-0,\n        &.end-0 {\n            top: 0;\n            bottom: 0;\n            width: 5px;\n        }\n        &.top-0 {\n            box-shadow: $shadow-top;\n        }\n        &.bottom-0 {\n            box-shadow: $shadow-bottom;\n        }\n        &.start-0 {\n            box-shadow: $shadow-left;\n        }\n        &.end-0 {\n            box-shadow: $shadow-right;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Select.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { altPressed, isArrowDown, isArrowUp } from \"@tslib/keys\";\n    import { createEventDispatcher, setContext } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import { chevronDown } from \"$lib/components/icons\";\n\n    import { selectKey } from \"./context-keys\";\n    import Icon from \"./Icon.svelte\";\n    import IconConstrain from \"./IconConstrain.svelte\";\n    import Popover from \"./Popover.svelte\";\n    import SelectOption from \"./SelectOption.svelte\";\n    import WithFloating from \"./WithFloating.svelte\";\n\n    // eslint-disable\n    type T = $$Generic;\n\n    let className = \"\";\n    export { className as class };\n\n    export let disabled = false;\n    export let label = \"<br>\";\n    export let value: T;\n\n    // E may need to derive content, but we default to them being the same for convenience of usage\n    type E = $$Generic;\n    type C = $$Generic;\n    let selected: number | undefined = undefined;\n    let initialSelected: number;\n    export let list: E[];\n    export let parser: (item: E) => { content: C; value?: T; disabled?: boolean } = (\n        item,\n    ) => {\n        return {\n            content: item as unknown as C,\n        };\n    };\n    $: parsed = list\n        .map(parser)\n        .map(({ content, value: initialValue, disabled = false }, i) => {\n            if ((initialValue === undefined && i === value) || initialValue === value) {\n                initialSelected = i;\n            }\n\n            return {\n                content,\n                parsedValue: initialValue === undefined ? (i as T) : initialValue,\n                disabled,\n            };\n        });\n    const buttons: HTMLButtonElement[] = Array(list.length);\n    const last = list.length - 1;\n    const ids = {\n        popover: \"popover\",\n        focused: \"focused\",\n    };\n\n    export let id: string | undefined = undefined;\n\n    const dispatch = createEventDispatcher();\n\n    function setValue(v: T) {\n        value = v;\n        dispatch(\"change\", { value });\n    }\n\n    export let element: HTMLElement | undefined = undefined;\n\n    export let tooltip: string | undefined = undefined;\n\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n    let hover = false;\n\n    let showFloating = false;\n    let clientWidth: number;\n\n    const selectStore = writable({ value, setValue });\n    $: $selectStore.value = value;\n    setContext(selectKey, selectStore);\n\n    function onKeyDown(event: KeyboardEvent) {\n        // In accordance with ARIA APG combobox (https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)\n        const arrowDown = isArrowDown(event);\n        const arrowUp = isArrowUp(event);\n        const alt = altPressed(event);\n        if (arrowDown || arrowUp || event.code === \"Space\") {\n            event.preventDefault();\n        }\n\n        if (\n            !showFloating &&\n            ((arrowDown && alt) ||\n                event.code === \"Enter\" ||\n                event.code === \"Space\" ||\n                arrowDown ||\n                event.code === \"Home\" ||\n                arrowUp ||\n                event.code === \"End\")\n        ) {\n            showFloating = true;\n            if (selected === undefined) {\n                selected = initialSelected;\n            }\n            return;\n        }\n        if (selected === undefined) {\n            return;\n        }\n\n        if (\n            event.code === \"Enter\" ||\n            event.code === \"Space\" ||\n            event.code === \"Tab\" ||\n            (arrowUp && alt)\n        ) {\n            showFloating = false;\n            setValue(parsed[selected].parsedValue);\n        } else if (arrowUp) {\n            if (selected < 0) {\n                selected = last + 1;\n            }\n            selectFocus(selected - 1);\n        } else if (arrowDown) {\n            selectFocus(selected + 1);\n        } else if (event.code === \"Escape\") {\n            // TODO This doesn't work as the window typically catches the Escape as well\n            // and closes the window\n            // - qt/aqt/browser/browser.py:377\n            showFloating = false;\n        } else if (event.code === \"Home\") {\n            selectFocus(0);\n        } else if (event.code === \"End\") {\n            selectFocus(last);\n        }\n    }\n\n    function revealed() {\n        clientWidth = element?.clientWidth ?? 150;\n        if (selected === undefined) {\n            return;\n        }\n        setTimeout(selectFocus, 0, selected);\n    }\n\n    /**\n     * Focus on an option.\n     * Values outside the range clip to either end\n     * @param num index number to focus on\n     */\n    function selectFocus(num: number) {\n        if (selected === -2) {\n            selected = -1;\n            return;\n        }\n        if (num < 0) {\n            num = 0;\n        } else if (num > last) {\n            num = last;\n        }\n\n        if (selected !== undefined && 0 <= selected && selected <= last) {\n            buttons[selected].classList.remove(\"focus\");\n        }\n\n        if (num >= 0) {\n            const el = buttons[num];\n            el.classList.add(\"focus\");\n            if (!isScrolledIntoView(el)) {\n                el.scrollIntoView();\n            }\n        }\n        selected = num;\n    }\n\n    function isScrolledIntoView(el: HTMLElement) {\n        // This could probably be a helper function of some sort, I don't know where to put it\n        const rect = el.getBoundingClientRect();\n        return rect.top >= 0 && rect.bottom <= window.innerHeight;\n    }\n</script>\n\n<WithFloating\n    show={showFloating}\n    offset={0}\n    shift={0}\n    hideArrow\n    inline\n    closeOnInsideClick\n    keepOnKeyup\n    on:close={() => (showFloating = false)}\n    let:asReference\n>\n    <!-- TODO implement aria-label with semantic label -->\n    <div\n        {id}\n        class=\"{className} select-container\"\n        class:rtl\n        class:hover\n        class:disabled\n        title={tooltip}\n        tabindex=\"0\"\n        role=\"combobox\"\n        aria-controls={ids.popover}\n        aria-expanded={showFloating}\n        aria-activedescendant={ids.focused}\n        on:keydown={onKeyDown}\n        on:mouseenter={() => (hover = true)}\n        on:mouseleave={() => (hover = false)}\n        on:click={() => {\n            if (selected === undefined) {\n                selected = initialSelected;\n            }\n            showFloating = !showFloating;\n        }}\n        bind:this={element}\n        use:asReference\n    >\n        <div class=\"inner\">\n            <div class=\"label\">{label}</div>\n        </div>\n        <div class=\"chevron\">\n            <IconConstrain iconSize={80}>\n                <Icon icon={chevronDown} />\n            </IconConstrain>\n        </div>\n    </div>\n    <Popover\n        slot=\"floating\"\n        scrollable\n        --popover-width=\"{clientWidth}px\"\n        id={ids.popover}\n        on:revealed={revealed}\n    >\n        {#each parsed as { content, parsedValue, disabled }, idx (idx)}\n            <SelectOption\n                value={parsedValue}\n                bind:element={buttons[idx]}\n                {disabled}\n                selected={idx === selected}\n                id={ids.focused}\n            >\n                {content}\n            </SelectOption>\n        {/each}\n    </Popover>\n</WithFloating>\n\n<style lang=\"scss\">\n    @use \"../sass/button-mixins\" as button;\n\n    $padding-inline: 0.5rem;\n\n    .select-container {\n        @include button.select($with-disabled: false);\n        line-height: 1.5;\n        height: 100%;\n        position: relative;\n        display: flex;\n        flex-flow: row;\n        justify-content: space-between;\n\n        .inner {\n            flex-grow: 1;\n            position: relative;\n            .label {\n                position: absolute;\n                top: 0;\n                right: $padding-inline;\n                bottom: 0;\n                left: $padding-inline;\n                white-space: nowrap;\n                overflow: hidden;\n                text-overflow: ellipsis;\n            }\n        }\n    }\n\n    .disabled {\n        pointer-events: none;\n        opacity: 0.5;\n    }\n\n    .chevron {\n        height: 100%;\n        align-self: flex-end;\n        border-left: 1px solid var(--border-subtle);\n    }\n\n    :global([dir=\"rtl\"]) {\n        .chevron {\n            border-left: none;\n            border-right: 1px solid var(--border-subtle);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/SelectOption.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { getContext } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n\n    import { selectKey } from \"./context-keys\";\n    import DropdownItem from \"./DropdownItem.svelte\";\n\n    type T = $$Generic;\n\n    export let selected = false;\n    export let disabled = false;\n    export let id: string;\n    export let value: T;\n\n    export let element: HTMLButtonElement;\n\n    const selectContext: Writable<{ value: T; setValue: Function }> =\n        getContext(selectKey);\n    const setValue = $selectContext.setValue;\n</script>\n\n<DropdownItem\n    {disabled}\n    {selected}\n    id={selected ? id : undefined}\n    active={value == $selectContext.value}\n    role=\"option\"\n    on:click={() => setValue(value)}\n    bind:buttonRef={element}\n>\n    <slot />\n</DropdownItem>\n"
  },
  {
    "path": "ts/lib/components/SettingTitle.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<!-- svelte-ignore a11y-no-static-element-interactions -->\n<!-- svelte-ignore a11y-click-events-have-key-events -->\n<div class=\"setting-title\" on:click>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    .setting-title {\n        cursor: help;\n        &:hover {\n            text-decoration: underline dotted var(--fg-subtle);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Shortcut.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { preventDefault } from \"@tslib/events\";\n    import { registerShortcut } from \"@tslib/shortcuts\";\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    export let keyCombination: string;\n    export let event: \"keydown\" | \"keyup\" | undefined = undefined;\n\n    const dispatch = createEventDispatcher();\n\n    onMount(() =>\n        registerShortcut(\n            (event: KeyboardEvent) => {\n                preventDefault(event);\n                dispatch(\"action\", { originalEvent: event });\n            },\n            keyCombination,\n            { event },\n        ),\n    );\n</script>\n"
  },
  {
    "path": "ts/lib/components/Spacer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<div></div>\n\n<style lang=\"scss\">\n    div {\n        width: var(--width, auto);\n        height: var(--height, auto);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/SpinBox.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { isDesktop } from \"@tslib/platform\";\n    import { tick } from \"svelte\";\n\n    import { chevronDown, chevronUp } from \"$lib/components/icons\";\n\n    import Icon from \"./Icon.svelte\";\n    import IconConstrain from \"./IconConstrain.svelte\";\n\n    export let value: number;\n    export let step = 1;\n    export let min = 1;\n    export let max = 9999;\n    /**\n     * Whether the value is shown as a percentage to the user.\n     * It's saved as a proportion.\n     */\n    export let percentage = false;\n\n    let input: HTMLInputElement;\n    export let focused = false;\n    let multiplier: number;\n    $: multiplier = percentage ? 100 : 1;\n\n    /** Set value to a new number, clamping it to a valid range, and\n        leaving it unchanged if `newValue` is NaN. */\n    function updateValue(newValue: number) {\n        if (Number.isNaN(newValue)) {\n            // avoid updating the value\n        } else {\n            value = Math.min(max, Math.max(min, newValue));\n        }\n        // Assigning to `value` will trigger the stringValue reactive statement below,\n        // but Svelte may not redraw the UI. For example, if '1' was shown, and the user\n        // enters '0', if the value gets clamped back to '1', Svelte will think the value hasn't\n        // changed, and will skip the UI update. So we manually update the DOM to ensure it stays\n        // in sync.\n        tick().then(() => {\n            input.value = stringValue;\n            updatePercentageText(stringValue);\n        });\n    }\n\n    /**\n     * The number of decimal places to record. May be different than the number of decimal places displayed for percentages.\n     * @param value The size of the step.\n     */\n    function decimalPlaces(value: number) {\n        if (Math.floor(value) === value) {\n            // If the step is an integer, do not show decimal places.\n            return 0;\n        }\n        const places = value.toString().split(\".\")[1].length || 0;\n        const displayedPlace = percentage ? places - 2 : places;\n        return Math.max(0, displayedPlace);\n    }\n\n    let stringValue: string;\n    $: stringValue = (value * multiplier).toFixed(decimalPlaces(step));\n\n    function update(this: HTMLInputElement): void {\n        updateValue(parseFloat(this.value) / multiplier);\n    }\n\n    function handleWheel(event: WheelEvent) {\n        if (focused) {\n            updateValue(value + (event.deltaY < 0 ? step : -step));\n            event.preventDefault();\n        }\n    }\n\n    function change(step: number): void {\n        updateValue(value + step);\n        if (pressed) {\n            setTimeout(() => change(step), timeout);\n        }\n    }\n\n    const progression = [1500, 1250, 1000, 750, 500, 250];\n\n    async function longPress(func: Function): Promise<void> {\n        pressed = true;\n        timeout = 128;\n        pressTimer = setTimeout(func, 250);\n\n        for (const delay of progression) {\n            timeout = await new Promise((resolve) =>\n                setTimeout(() => resolve(pressed ? timeout / 2 : 128), delay),\n            );\n        }\n    }\n\n    function updatePercentageText(value: string) {\n        // Separate the % from the padding text.\n        percentage_text = tr\n            .deckConfigPercentInput({ pct: value })\n            .replaceAll(\"%\", \"-%-\")\n            .split(\"-\");\n    }\n\n    function onInput() {\n        updatePercentageText(input.value);\n    }\n\n    // Invisible, used to shift the % sign the correct amount.\n    let percentage_text: string[];\n    $: updatePercentageText(stringValue);\n    // If the input box should be moved right for leading percentage symbol.\n    $: percentage_padding = percentage && !percentage_text[0] ? \"2.2ch\" : undefined;\n\n    let pressed = false;\n    let timeout: number;\n    let pressTimer: any;\n</script>\n\n<div class=\"spin-box\" on:wheel={handleWheel}>\n    <input\n        type=\"number\"\n        pattern=\"[0-9]*\"\n        inputmode=\"numeric\"\n        min={min * multiplier}\n        max={max * multiplier}\n        step={step * multiplier}\n        value={stringValue}\n        bind:this={input}\n        on:blur={update}\n        on:change={update}\n        on:input={onInput}\n        on:focusin={() => (focused = true)}\n        on:focusout={() => (focused = false)}\n        style:padding-left={percentage_padding}\n    />\n    {#if percentage}\n        <span class=\"suffix\">\n            {#each percentage_text as str}\n                {#if str == \"%\"}\n                    %\n                {:else}\n                    <span class=\"invisible\">{str}</span>\n                {/if}\n            {/each}\n        </span>\n    {/if}\n    {#if isDesktop()}\n        <!-- svelte-ignore a11y-click-events-have-key-events -->\n        <div\n            class=\"spinner decrement\"\n            class:active={value > min}\n            tabindex=\"-1\"\n            title={tr.actionsDecrementValue()}\n            role=\"button\"\n            on:click={() => {\n                input.focus();\n                if (value > min) {\n                    change(-step);\n                }\n            }}\n            on:mousedown={() =>\n                longPress(() => {\n                    if (value > min) {\n                        change(-step);\n                    }\n                })}\n            on:mouseup={() => {\n                clearTimeout(pressTimer);\n                pressed = false;\n            }}\n        >\n            <IconConstrain>\n                <Icon icon={chevronDown} />\n            </IconConstrain>\n        </div>\n        <!-- svelte-ignore a11y-click-events-have-key-events -->\n        <div\n            class=\"spinner increment\"\n            class:active={value < max}\n            tabindex=\"-1\"\n            title={tr.actionsIncrementValue()}\n            role=\"button\"\n            on:click={() => {\n                input.focus();\n                if (value < max) {\n                    change(step);\n                }\n            }}\n            on:mousedown={() =>\n                longPress(() => {\n                    if (value < max) {\n                        change(step);\n                    }\n                })}\n            on:mouseup={() => {\n                clearTimeout(pressTimer);\n                pressed = false;\n            }}\n        >\n            <IconConstrain>\n                <Icon icon={chevronUp} />\n            </IconConstrain>\n        </div>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .spin-box {\n        width: 100%;\n        background: var(--canvas-inset);\n        border: 1px solid var(--border);\n        border-radius: var(--border-radius);\n        overflow: hidden;\n        position: relative;\n        display: flex;\n        justify-content: space-between;\n\n        .suffix {\n            position: absolute;\n            pointer-events: none;\n            white-space: pre;\n            left: 0.5em;\n            top: 1px;\n\n            @supports (-webkit-touch-callout: none) {\n                /* CSS specific to iOS devices */\n                top: 3.5px;\n            }\n        }\n\n        .invisible {\n            color: transparent;\n            pointer-events: none;\n        }\n\n        input {\n            flex-grow: 1;\n            border: none;\n            outline: none;\n            background: transparent;\n            &::-webkit-inner-spin-button {\n                display: none;\n            }\n            padding-left: 0.5em;\n            padding-right: 0.5em;\n        }\n\n        &:hover,\n        &:focus-within {\n            .spinner {\n                opacity: 0.1;\n                &.active {\n                    opacity: 0.4;\n                    cursor: pointer;\n                    &:hover {\n                        opacity: 1;\n                    }\n                }\n            }\n        }\n    }\n    .spinner {\n        opacity: 0;\n        height: 100%;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/StickyContainer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Container from \"./Container.svelte\";\n    import type { Breakpoint } from \"./types\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    export let height: number = 0;\n    export let breakpoint: Breakpoint | \"fluid\" = \"fluid\";\n</script>\n\n<div {id} bind:offsetHeight={height} class=\"sticky-container {className}\">\n    <Container {breakpoint}>\n        <slot />\n    </Container>\n</div>\n\n<style lang=\"scss\">\n    .sticky-container {\n        position: sticky;\n        top: var(--sticky-top, 0);\n        bottom: 0;\n        left: 0;\n        right: 0;\n        z-index: var(--z-index, 50);\n\n        background: var(--sticky-bg, var(--canvas));\n        border-style: solid;\n        border-color: var(--sticky-border, var(--border));\n        border-width: var(--sticky-borders, 0);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/Switch.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    export let id: string | undefined;\n    export let value: boolean;\n    export let disabled = false;\n\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n</script>\n\n<div class=\"form-check form-switch\" class:rtl>\n    <input\n        {id}\n        type=\"checkbox\"\n        class=\"form-check-input\"\n        class:nightMode={$pageTheme.isDark}\n        bind:checked={value}\n        {disabled}\n    />\n</div>\n\n<style lang=\"scss\">\n    .form-switch {\n        /* bootstrap adds a default 2.5em left pad, which causes */\n        /* text to wrap prematurely */\n        padding-left: 0.5em;\n    }\n\n    .form-check-input {\n        -webkit-appearance: none;\n        appearance: none;\n        height: 1.5em;\n        /* otherwise the switch circle shows slightly off-centered */\n        margin-top: 0;\n\n        .form-switch & {\n            width: 3em;\n            margin-left: 1.5em;\n            cursor: pointer;\n        }\n    }\n\n    .nightMode:not(:checked) {\n        background-color: var(--canvas-elevated);\n        border-color: var(--border);\n    }\n\n    .form-switch.rtl {\n        padding-left: 0;\n        padding-right: 0.5em;\n        .form-check-input {\n            margin-left: 0;\n            margin-right: 1.5em;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/SwitchRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"./Col.svelte\";\n    import ConfigInput from \"./ConfigInput.svelte\";\n    import Label from \"./Label.svelte\";\n    import RevertButton from \"./RevertButton.svelte\";\n    import Row from \"./Row.svelte\";\n    import Switch from \"./Switch.svelte\";\n\n    export let value: boolean;\n    export let defaultValue: boolean;\n    export let disabled: boolean = false;\n\n    const id = Math.random().toString(36).substring(2);\n</script>\n\n<Row --cols={6}>\n    <Col --col-size={4}><Label for={id} preventMouseClick><slot /></Label></Col>\n    <Col --col-justify=\"flex-end\">\n        <ConfigInput grow={false}>\n            <Switch {id} bind:value {disabled} />\n            <RevertButton slot=\"revert\" bind:value {defaultValue} />\n        </ConfigInput>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/lib/components/TitledContainer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    export let title: string;\n</script>\n\n<div\n    {id}\n    class=\"container {className}\"\n    class:light={!$pageTheme.isDark}\n    class:dark={$pageTheme.isDark}\n    class:rtl\n    style:--gutter-block=\"2px\"\n    style:--container-margin=\"0\"\n>\n    <div class=\"position-relative\">\n        <h1>\n            {title}\n        </h1>\n        <div class=\"help-badge position-absolute\" class:rtl>\n            <slot name=\"tooltip\" />\n        </div>\n    </div>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    @use \"../sass/elevation\" as *;\n    .container {\n        width: 100%;\n        background: var(--canvas-elevated);\n        border: 1px solid var(--border-subtle);\n        border-radius: var(--border-radius-medium, 10px);\n\n        &.light {\n            @include elevation(3);\n        }\n        &.dark {\n            @include elevation(4);\n        }\n\n        padding: 1rem 1.75rem 0.75rem 1.25rem;\n        &.rtl {\n            padding: 1rem 1.25rem 0.75rem 1.75rem;\n        }\n        page-break-inside: avoid;\n    }\n    h1 {\n        border-bottom: 1px solid var(--border);\n        padding-bottom: 0.25em;\n    }\n    .help-badge {\n        right: 0;\n        top: 0;\n        color: #555;\n        &.rtl {\n            right: unset;\n            left: 0;\n        }\n    }\n\n    :global(.night-mode) .help-badge {\n        color: var(--fg);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/VirtualTable.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    let className: string = \"\";\n    export { className as class };\n\n    export let itemsCount: number = 0;\n    export let itemHeight: number;\n    export let bottomOffset: number = 0;\n\n    let container: HTMLElement;\n    let scrollTop: number = 0;\n\n    $: containerHeight = container\n        ? Math.floor(\n              (document.documentElement.clientHeight -\n                  container.offsetTop -\n                  bottomOffset) /\n                  itemHeight,\n          ) * itemHeight\n        : 0;\n    $: sliceLength = Math.ceil(containerHeight / itemHeight);\n    $: startIndex = Math.floor(scrollTop / itemHeight);\n    $: endIndex = Math.min(startIndex + sliceLength, itemsCount);\n    $: slice = new Array(endIndex - startIndex).fill(0).map((_, i) => startIndex + i);\n\n    window.addEventListener(\"resize\", () => {\n        containerHeight = containerHeight;\n    });\n</script>\n\n<div\n    class=\"outer\"\n    style=\"--container-height: {containerHeight}px\"\n    bind:this={container}\n    on:scroll={() => (scrollTop = container.scrollTop)}\n>\n    <table class=\"table {className}\" tabindex=\"-1\">\n        <thead>\n            <slot name=\"headers\" />\n        </thead>\n        <tbody>\n            {#if itemHeight * startIndex > 0}\n                <tr><td style=\"height: {itemHeight * startIndex}px;\"></td></tr>\n            {/if}\n\n            {#each slice as index (index)}\n                <slot name=\"row\" {index} />\n            {/each}\n\n            {#if itemHeight * itemsCount - itemHeight * endIndex > 0}\n                <tr>\n                    <td\n                        style=\"height: {itemHeight * itemsCount -\n                            itemHeight * endIndex}px;\"\n                    ></td>\n                </tr>\n            {/if}\n        </tbody>\n    </table>\n</div>\n\n<style lang=\"scss\">\n    .outer {\n        width: 100%;\n        overflow: auto;\n\n        max-height: var(--container-height);\n        margin: 0 auto;\n    }\n\n    .table {\n        border-collapse: collapse;\n        white-space: nowrap;\n\n        :global(th),\n        :global(td) {\n            text-overflow: ellipsis;\n            overflow: hidden;\n            border: 1px solid var(--border-subtle);\n            padding: 0.25rem 0.5rem;\n            max-width: 15em;\n        }\n\n        :global(th) {\n            background: var(--border);\n            text-align: center;\n        }\n\n        :global(thead) {\n            position: sticky;\n            top: -1px;\n            overflow-y: auto;\n            overflow-x: hidden;\n            z-index: 1;\n        }\n\n        :global(tbody) {\n            overflow-y: scroll;\n            overflow-x: hidden;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/WithContext.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { getContext } from \"svelte\";\n    import type { Readable } from \"svelte/store\";\n\n    type T = boolean;\n\n    export let key: Symbol | string;\n\n    const store = getContext<Readable<T>>(key);\n</script>\n\n<slot context={$store} />\n"
  },
  {
    "path": "ts/lib/components/WithFloating.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type {\n        FloatingElement,\n        Placement,\n        ReferenceElement,\n    } from \"@floating-ui/dom\";\n    import type { Callback } from \"@tslib/typing\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { createEventDispatcher, onDestroy, setContext } from \"svelte\";\n    import type { ActionReturn } from \"svelte/action\";\n    import { writable } from \"svelte/store\";\n\n    import isClosingClick from \"$lib/sveltelib/closing-click\";\n    import isClosingKeyup from \"$lib/sveltelib/closing-keyup\";\n    import type { EventPredicateResult } from \"$lib/sveltelib/event-predicate\";\n    import { documentClick, documentKeyup } from \"$lib/sveltelib/event-store\";\n    import { registerModalClosingHandler } from \"$lib/sveltelib/modal-closing\";\n    import Portal from \"./Portal.svelte\";\n    import type { PositioningCallback } from \"$lib/sveltelib/position/auto-update\";\n    import autoUpdate from \"$lib/sveltelib/position/auto-update\";\n    import type { PositionAlgorithm } from \"$lib/sveltelib/position/position-algorithm\";\n    import positionFloating from \"$lib/sveltelib/position/position-floating\";\n    import subscribeToUpdates from \"$lib/sveltelib/subscribe-updates\";\n\n    import { floatingKey } from \"./context-keys\";\n    import FloatingArrow from \"./FloatingArrow.svelte\";\n\n    export let portalTarget: HTMLElement | null = null;\n\n    let placement: Placement = \"bottom\";\n    export { placement as preferredPlacement };\n\n    /* Used by Popover to set animation direction depending on placement */\n    const placementPromise = writable<Promise<Placement> | undefined>();\n    setContext(floatingKey, placementPromise);\n\n    export let offset = 5;\n    /* 30px box shadow from elevation(8) */\n    export let shift = 30;\n    export let inline = false;\n    export let hideIfEscaped = false;\n    export let hideIfReferenceHidden = false;\n\n    /** This may be passed in for more fine-grained control */\n    export let show = true;\n\n    type CloseEventMap = {\n        close: Pick<EventPredicateResult, \"reason\"> & Partial<EventPredicateResult>;\n    };\n\n    const dispatch = createEventDispatcher<CloseEventMap>();\n\n    let arrow: HTMLElement;\n\n    const { set: setModalOpen, remove: removeModalClosingHandler } =\n        registerModalClosingHandler(() => dispatch(\"close\", { reason: \"esc\" }));\n\n    $: positionCurried = positionFloating({\n        placement,\n        offset,\n        shift,\n        inline,\n        arrow,\n        hideIfEscaped,\n        hideIfReferenceHidden,\n        hideCallback: (reason: string) => dispatch(\"close\", { reason }),\n    });\n\n    let autoAction: ActionReturn<any> = {};\n\n    $: {\n        positionCurried;\n        autoAction.update?.(positioningCallback);\n    }\n\n    export let closeOnInsideClick = false;\n    export let keepOnKeyup = false;\n    export let hideArrow = false;\n\n    export let reference: ReferenceElement | undefined = undefined;\n    let floating: FloatingElement;\n\n    function applyPosition(\n        reference: ReferenceElement,\n        floating: FloatingElement,\n        position: PositionAlgorithm,\n    ): Promise<Placement> {\n        const promise = position(reference, floating);\n        $placementPromise = promise;\n        return promise;\n    }\n\n    async function position(\n        callback: (\n            reference: ReferenceElement,\n            floating: FloatingElement,\n            position: PositionAlgorithm,\n        ) => Promise<Placement> = applyPosition,\n    ): Promise<Placement | void> {\n        if (reference && floating) {\n            return callback(reference, floating, positionCurried);\n        }\n    }\n\n    function asReference(referenceArgument: Element) {\n        reference = referenceArgument;\n    }\n\n    function positioningCallback(\n        reference: ReferenceElement,\n        callback: PositioningCallback,\n    ): Callback {\n        const innerFloating = floating;\n        return callback(reference, innerFloating, () => {\n            $placementPromise = positionCurried(reference, innerFloating);\n        });\n    }\n\n    let cleanup: Callback | null = null;\n\n    function updateFloating(\n        reference: ReferenceElement | undefined,\n        floating: FloatingElement,\n        isShowing: boolean,\n    ) {\n        cleanup?.();\n        cleanup = null;\n        setModalOpen(isShowing);\n        if (!reference || !floating || !isShowing) {\n            return;\n        }\n\n        autoAction = autoUpdate(reference, positioningCallback);\n\n        // For virtual references, we cannot provide any\n        // default closing behavior\n        if (!(reference instanceof EventTarget)) {\n            cleanup = autoAction.destroy!;\n            return;\n        }\n\n        const closingClick = isClosingClick(documentClick, {\n            reference,\n            floating,\n            inside: closeOnInsideClick,\n            outside: true,\n        });\n\n        const subscribers = [\n            subscribeToUpdates(closingClick, (event: EventPredicateResult) =>\n                dispatch(\"close\", event),\n            ),\n        ];\n\n        if (!keepOnKeyup) {\n            const closingKeyup = isClosingKeyup(documentKeyup, {\n                reference,\n                floating,\n            });\n\n            subscribers.push(\n                subscribeToUpdates(closingKeyup, (event: EventPredicateResult) =>\n                    dispatch(\"close\", event),\n                ),\n            );\n        }\n\n        cleanup = singleCallback(\n            ...subscribers,\n            autoAction.destroy!,\n            removeModalClosingHandler,\n        );\n    }\n\n    $: updateFloating(reference, floating, show);\n\n    onDestroy(() => cleanup?.());\n</script>\n\n<slot {position} {asReference} />\n\n{#if $$slots.reference}\n    {#if inline}\n        <span class=\"floating-reference\" use:asReference>\n            <slot name=\"reference\" />\n        </span>\n    {:else}\n        <div class=\"floating-reference\" use:asReference>\n            <slot name=\"reference\" />\n        </div>\n    {/if}\n{/if}\n\n<Portal target={portalTarget}>\n    <div bind:this={floating} class=\"floating\" class:show>\n        {#if show}\n            <slot name=\"floating\" {position} />\n        {/if}\n\n        <div bind:this={arrow} class=\"floating-arrow\" hidden={!show}>\n            {#if !hideArrow}\n                <FloatingArrow />\n            {/if}\n        </div>\n    </div>\n</Portal>\n\n<style lang=\"scss\">\n    span.floating-reference {\n        line-height: 1;\n    }\n    .floating {\n        position: absolute;\n        border-radius: 5px;\n\n        z-index: 890;\n\n        &-arrow {\n            position: absolute;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/WithOverlay.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type {\n        FloatingElement,\n        Placement,\n        ReferenceElement,\n    } from \"@floating-ui/dom\";\n    import type { Callback } from \"@tslib/typing\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { createEventDispatcher, setContext } from \"svelte\";\n    import type { ActionReturn } from \"svelte/action\";\n    import { writable } from \"svelte/store\";\n\n    import isClosingClick from \"$lib/sveltelib/closing-click\";\n    import isClosingKeyup from \"$lib/sveltelib/closing-keyup\";\n    import type { EventPredicateResult } from \"$lib/sveltelib/event-predicate\";\n    import { documentClick, documentKeyup } from \"$lib/sveltelib/event-store\";\n    import type { PositioningCallback } from \"$lib/sveltelib/position/auto-update\";\n    import autoUpdate from \"$lib/sveltelib/position/auto-update\";\n    import type { PositionAlgorithm } from \"$lib/sveltelib/position/position-algorithm\";\n    import positionOverlay from \"$lib/sveltelib/position/position-overlay\";\n    import subscribeToUpdates from \"$lib/sveltelib/subscribe-updates\";\n\n    import { overlayKey } from \"./context-keys\";\n\n    /* Used by Popover to set animation direction depending on placement */\n    const placementPromise = writable<Promise<Placement> | undefined>();\n    setContext(overlayKey, placementPromise);\n\n    export let padding = 0;\n    export let inline = false;\n\n    /** This may be passed in for more fine-grained control */\n    export let show = true;\n\n    const dispatch = createEventDispatcher();\n\n    $: positionCurried = positionOverlay({\n        padding,\n        inline,\n        hideCallback: (reason: string) => dispatch(\"close\", { reason }),\n    });\n\n    let autoAction: ActionReturn<any> = {};\n\n    $: {\n        positionCurried;\n        autoAction.update?.(positioningCallback);\n    }\n\n    export let closeOnInsideClick = false;\n    export let keepOnKeyup = false;\n\n    export let reference: HTMLElement | undefined = undefined;\n    let floating: FloatingElement;\n\n    function applyPosition(\n        reference: HTMLElement,\n        floating: FloatingElement,\n        position: PositionAlgorithm,\n    ): Promise<Placement> {\n        const promise = position(reference, floating);\n        $placementPromise = promise;\n        return promise;\n    }\n\n    async function position(\n        callback: (\n            reference: HTMLElement,\n            floating: FloatingElement,\n            position: PositionAlgorithm,\n        ) => Promise<Placement> = applyPosition,\n    ): Promise<Placement | void> {\n        if (reference && floating) {\n            return callback(reference, floating, positionCurried);\n        }\n    }\n\n    function asReference(referenceArgument: HTMLElement) {\n        reference = referenceArgument;\n    }\n\n    function positioningCallback(\n        reference: ReferenceElement,\n        callback: PositioningCallback,\n    ): Callback {\n        const innerFloating = floating;\n        return callback(reference, innerFloating, () => {\n            $placementPromise = positionCurried(reference, innerFloating);\n        });\n    }\n\n    let cleanup: Callback;\n\n    function updateFloating(\n        reference: HTMLElement | undefined,\n        floating: FloatingElement,\n        isShowing: boolean,\n    ) {\n        cleanup?.();\n\n        if (!reference || !floating || !isShowing) {\n            return;\n        }\n\n        const closingClick = isClosingClick(documentClick, {\n            reference,\n            floating,\n            inside: closeOnInsideClick,\n            outside: false,\n        });\n\n        const subscribers = [\n            subscribeToUpdates(closingClick, (event: EventPredicateResult) =>\n                dispatch(\"close\", event),\n            ),\n        ];\n\n        if (!keepOnKeyup) {\n            const closingKeyup = isClosingKeyup(documentKeyup, {\n                reference,\n                floating,\n            });\n\n            subscribers.push(\n                subscribeToUpdates(closingKeyup, (event: EventPredicateResult) =>\n                    dispatch(\"close\", event),\n                ),\n            );\n        }\n\n        autoAction = autoUpdate(reference, positioningCallback);\n        cleanup = singleCallback(...subscribers, autoAction.destroy!);\n    }\n\n    $: updateFloating(reference, floating, show);\n</script>\n\n<slot {position} {asReference} />\n\n{#if $$slots.reference}\n    {#if inline}\n        <span class=\"overlay-reference\" use:asReference>\n            <slot name=\"reference\" />\n        </span>\n    {:else}\n        <div class=\"overlay-reference\" use:asReference>\n            <slot name=\"reference\" />\n        </div>\n    {/if}\n{/if}\n\n<div bind:this={floating} class=\"overlay\" class:show>\n    {#if show}\n        <slot name=\"overlay\" {position} />\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .overlay {\n        position: absolute;\n        border-radius: var(--border-radius);\n\n        z-index: 40;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/WithState.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\" context=\"module\">\n    import { writable } from \"svelte/store\";\n\n    type KeyType = symbol | string;\n    type UpdaterMap = Map<KeyType, (event: Event) => Promise<boolean>>;\n    type StateMap = Map<KeyType, Promise<boolean>>;\n\n    const updaterMap: UpdaterMap = new Map();\n    const stateMap: StateMap = new Map();\n    const stateStore = writable(stateMap);\n\n    function updateAllStateWithCallback(\n        callback: (key: KeyType) => Promise<boolean>,\n    ): void {\n        stateStore.update((map: StateMap): StateMap => {\n            const newMap: StateMap = new Map();\n\n            for (const key of map.keys()) {\n                newMap.set(key, callback(key));\n            }\n\n            return newMap;\n        });\n    }\n\n    export function updateAllState(event: Event): void {\n        updateAllStateWithCallback(\n            (key: KeyType): Promise<boolean> => updaterMap.get(key)!(event),\n        );\n    }\n\n    export function resetAllState(state: boolean): void {\n        updateAllStateWithCallback((): Promise<boolean> => Promise.resolve(state));\n    }\n\n    export function updateStateByKey(key: KeyType, event: Event): void {\n        stateStore.update((map: StateMap): StateMap => {\n            map.set(key, updaterMap.get(key)!(event));\n            return map;\n        });\n    }\n</script>\n\n<script lang=\"ts\">\n    export let key: KeyType;\n    export let update: (event: Event) => Promise<boolean>;\n\n    let state: boolean = false;\n\n    updaterMap.set(key, update);\n\n    stateStore.subscribe((map: StateMap): (() => void) => {\n        if (map.has(key)) {\n            const stateValue = map.get(key)!;\n\n            if (stateValue instanceof Promise) {\n                stateValue.then((value: boolean): void => {\n                    state = value;\n                });\n            } else {\n                state = stateValue;\n            }\n        } else {\n            state = false;\n        }\n        return () => map.delete(key);\n    });\n\n    stateMap.set(key, Promise.resolve(state));\n\n    function updateState(event: Event): void {\n        updateStateByKey(key, event);\n    }\n</script>\n\n<slot {state} {updateState} />\n"
  },
  {
    "path": "ts/lib/components/WithTooltip.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Tooltip from \"bootstrap/js/dist/tooltip\";\n    import { onDestroy } from \"svelte\";\n\n    type TriggerType =\n        | \"hover focus\"\n        | \"click\"\n        | \"hover\"\n        | \"focus\"\n        | \"manual\"\n        | \"click hover\"\n        | \"click focus\"\n        | \"click hover focus\";\n\n    export let tooltip: string;\n    export let trigger: TriggerType = \"hover focus\";\n\n    export let placement: \"auto\" | \"top\" | \"bottom\" | \"left\" | \"right\" = \"top\";\n    export let html = true;\n    export let offset: Tooltip.Offset = [0, 0];\n    export let showDelay = 0;\n    export let hideDelay = 0;\n\n    let tooltipElement: HTMLElement;\n\n    let tooltipObject: Tooltip;\n    function createTooltip(element: HTMLElement): void {\n        tooltipElement = element;\n        element.title = tooltip;\n        tooltipObject = new Tooltip(element, {\n            placement,\n            html,\n            offset,\n            delay: { show: showDelay, hide: hideDelay },\n            trigger,\n        });\n    }\n\n    onDestroy(() => {\n        tooltipElement?.addEventListener(\"hidden.bs.tooltip\", () => {\n            tooltipObject?.dispose();\n        });\n        tooltipObject?.hide();\n    });\n</script>\n\n<slot {createTooltip} {tooltipObject} />\n\n<style lang=\"scss\">\n    /* tooltip is inserted under the body tag\n    /* long tooltips can cause x-overflow */\n    :global(body) {\n        overflow-x: hidden;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/components/context-keys.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport const touchDeviceKey = Symbol(\"touchDevice\");\nexport const sectionKey = Symbol(\"section\");\nexport const buttonGroupKey = Symbol(\"buttonGroup\");\nexport const dropdownKey = Symbol(\"dropdown\");\nexport const modalsKey = Symbol(\"modals\");\nexport const floatingKey = Symbol(\"floating\");\nexport const overlayKey = Symbol(\"overlay\");\nexport const selectKey = Symbol(\"select\");\nexport const showKey = Symbol(\"selectShow\");\nexport const focusIdKey = Symbol(\"selectFocusId\");\n"
  },
  {
    "path": "ts/lib/components/helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nexport function mergeTooltipAndShortcut(\n    tooltip: string | undefined,\n    shortcutLabel: string | undefined,\n): string | undefined {\n    if (!tooltip && !shortcutLabel) {\n        return undefined;\n    }\n\n    let buf = tooltip ?? \"\";\n    if (shortcutLabel) {\n        buf = `${buf} (${shortcutLabel})`;\n    }\n    return buf;\n}\n\nexport const withButton = (f: (button: HTMLButtonElement) => void) => ({ detail }: CustomEvent): void => {\n    f(detail.button);\n};\n\nexport const withSpan = (f: (span: HTMLSpanElement) => void) => ({ detail }: CustomEvent): void => {\n    f(detail.span);\n};\n"
  },
  {
    "path": "ts/lib/components/icons.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport Alert_ from \"@mdi/svg/svg/alert.svg?component\";\nimport alert_ from \"@mdi/svg/svg/alert.svg?url\";\nimport AlignHorizontalCenter_ from \"@mdi/svg/svg/align-horizontal-center.svg?component\";\nimport alignHorizontalCenter_ from \"@mdi/svg/svg/align-horizontal-center.svg?url\";\nimport AlignHorizontalLeft_ from \"@mdi/svg/svg/align-horizontal-left.svg?component\";\nimport alignHorizontalLeft_ from \"@mdi/svg/svg/align-horizontal-left.svg?url\";\nimport AlignHorizontalRight_ from \"@mdi/svg/svg/align-horizontal-right.svg?component\";\nimport alignHorizontalRight_ from \"@mdi/svg/svg/align-horizontal-right.svg?url\";\nimport AlignVerticalBottom_ from \"@mdi/svg/svg/align-vertical-bottom.svg?component\";\nimport alignVerticalBottom_ from \"@mdi/svg/svg/align-vertical-bottom.svg?url\";\nimport AlignVerticalCenter_ from \"@mdi/svg/svg/align-vertical-center.svg?component\";\nimport alignVerticalCenter_ from \"@mdi/svg/svg/align-vertical-center.svg?url\";\nimport AlignVerticalTop_ from \"@mdi/svg/svg/align-vertical-top.svg?component\";\nimport alignVerticalTop_ from \"@mdi/svg/svg/align-vertical-top.svg?url\";\nimport CheckCircle_ from \"@mdi/svg/svg/check-circle.svg?component\";\nimport checkCircle_ from \"@mdi/svg/svg/check-circle.svg?url\";\nimport ChevronDown_ from \"@mdi/svg/svg/chevron-down.svg?component\";\nimport chevronDown_ from \"@mdi/svg/svg/chevron-down.svg?url\";\nimport ChevronUp_ from \"@mdi/svg/svg/chevron-up.svg?component\";\nimport chevronUp_ from \"@mdi/svg/svg/chevron-up.svg?url\";\nimport CloseBox_ from \"@mdi/svg/svg/close-box.svg?component\";\nimport closeBox_ from \"@mdi/svg/svg/close-box.svg?url\";\nimport Close_ from \"@mdi/svg/svg/close.svg?component\";\nimport close_ from \"@mdi/svg/svg/close.svg?url\";\nimport CodeTags_ from \"@mdi/svg/svg/code-tags.svg?component\";\nimport PlainText_ from \"@mdi/svg/svg/code-tags.svg?component\";\nimport codeTags_ from \"@mdi/svg/svg/code-tags.svg?url\";\nimport plainText_ from \"@mdi/svg/svg/code-tags.svg?url\";\nimport Cog_ from \"@mdi/svg/svg/cog.svg?component\";\nimport cog_ from \"@mdi/svg/svg/cog.svg?url\";\nimport ColorHelper_ from \"@mdi/svg/svg/color-helper.svg?component\";\nimport colorHelper_ from \"@mdi/svg/svg/color-helper.svg?url\";\nimport Cloze_ from \"@mdi/svg/svg/contain.svg?component\";\nimport cloze_ from \"@mdi/svg/svg/contain.svg?url\";\nimport Copy_ from \"@mdi/svg/svg/content-copy.svg?component\";\nimport copy_ from \"@mdi/svg/svg/content-copy.svg?url\";\nimport CursorDefaultOutline_ from \"@mdi/svg/svg/cursor-default-outline.svg?component\";\nimport cursorDefaultOutline_ from \"@mdi/svg/svg/cursor-default-outline.svg?url\";\nimport DeleteOutline_ from \"@mdi/svg/svg/delete-outline.svg?component\";\nimport deleteOutline_ from \"@mdi/svg/svg/delete-outline.svg?url\";\nimport Delete_ from \"@mdi/svg/svg/delete.svg?component\";\nimport delete_ from \"@mdi/svg/svg/delete.svg?url\";\nimport Dots_ from \"@mdi/svg/svg/dots-vertical.svg?component\";\nimport dots_ from \"@mdi/svg/svg/dots-vertical.svg?url\";\nimport HorizontalHandle_ from \"@mdi/svg/svg/drag-horizontal.svg?component\";\nimport horizontalHandle_ from \"@mdi/svg/svg/drag-horizontal.svg?url\";\nimport VerticalHandle_ from \"@mdi/svg/svg/drag-vertical.svg?component\";\nimport verticalHandle_ from \"@mdi/svg/svg/drag-vertical.svg?url\";\nimport Earth_ from \"@mdi/svg/svg/earth.svg?component\";\nimport earth_ from \"@mdi/svg/svg/earth.svg?url\";\nimport EllipseOutline_ from \"@mdi/svg/svg/ellipse-outline.svg?component\";\nimport ellipseOutline_ from \"@mdi/svg/svg/ellipse-outline.svg?url\";\nimport Eye_ from \"@mdi/svg/svg/eye.svg?component\";\nimport eye_ from \"@mdi/svg/svg/eye.svg?url\";\nimport FormatAlignCenter_ from \"@mdi/svg/svg/format-align-center.svg?component\";\nimport formatAlignCenter_ from \"@mdi/svg/svg/format-align-center.svg?url\";\nimport FormatBold_ from \"@mdi/svg/svg/format-bold.svg?component\";\nimport formatBold_ from \"@mdi/svg/svg/format-bold.svg?url\";\nimport FormatColorFill_ from \"@mdi/svg/svg/format-color-fill.svg?component\";\nimport formatColorFill_ from \"@mdi/svg/svg/format-color-fill.svg?url\";\nimport HighlightColor_ from \"@mdi/svg/svg/format-color-highlight.svg?component\";\nimport highlightColor_ from \"@mdi/svg/svg/format-color-highlight.svg?url\";\nimport TextColor_ from \"@mdi/svg/svg/format-color-text.svg?component\";\nimport textColor_ from \"@mdi/svg/svg/format-color-text.svg?url\";\nimport FloatLeft_ from \"@mdi/svg/svg/format-float-left.svg?component\";\nimport floatLeft_ from \"@mdi/svg/svg/format-float-left.svg?url\";\nimport FloatNone_ from \"@mdi/svg/svg/format-float-none.svg?component\";\nimport floatNone_ from \"@mdi/svg/svg/format-float-none.svg?url\";\nimport FloatRight_ from \"@mdi/svg/svg/format-float-right.svg?component\";\nimport floatRight_ from \"@mdi/svg/svg/format-float-right.svg?url\";\nimport RichText_ from \"@mdi/svg/svg/format-font.svg?component\";\nimport richText_ from \"@mdi/svg/svg/format-font.svg?url\";\nimport FormatItalic_ from \"@mdi/svg/svg/format-italic.svg?component\";\nimport formatItalic_ from \"@mdi/svg/svg/format-italic.svg?url\";\nimport Subscript_ from \"@mdi/svg/svg/format-subscript.svg?component\";\nimport subscript_ from \"@mdi/svg/svg/format-subscript.svg?url\";\nimport Superscript_ from \"@mdi/svg/svg/format-superscript.svg?component\";\nimport superscript_ from \"@mdi/svg/svg/format-superscript.svg?url\";\nimport FormatUnderline_ from \"@mdi/svg/svg/format-underline.svg?component\";\nimport formatUnderline_ from \"@mdi/svg/svg/format-underline.svg?url\";\nimport Inline_ from \"@mdi/svg/svg/format-wrap-square.svg?component\";\nimport inline_ from \"@mdi/svg/svg/format-wrap-square.svg?url\";\nimport Block_ from \"@mdi/svg/svg/format-wrap-top-bottom.svg?component\";\nimport block_ from \"@mdi/svg/svg/format-wrap-top-bottom.svg?url\";\nimport Function_ from \"@mdi/svg/svg/function-variant.svg?component\";\nimport function_ from \"@mdi/svg/svg/function-variant.svg?url\";\nimport Group_ from \"@mdi/svg/svg/group.svg?component\";\nimport group_ from \"@mdi/svg/svg/group.svg?url\";\nimport InfoCircle_ from \"@mdi/svg/svg/help-circle.svg?component\";\nimport infoCircle_ from \"@mdi/svg/svg/help-circle.svg?url\";\nimport SizeClear_ from \"@mdi/svg/svg/image-remove.svg?component\";\nimport sizeClear_ from \"@mdi/svg/svg/image-remove.svg?url\";\nimport SizeActual_ from \"@mdi/svg/svg/image-size-select-actual.svg?component\";\nimport sizeActual_ from \"@mdi/svg/svg/image-size-select-actual.svg?url\";\nimport SizeMinimized_ from \"@mdi/svg/svg/image-size-select-large.svg?component\";\nimport sizeMinimized_ from \"@mdi/svg/svg/image-size-select-large.svg?url\";\nimport ZoomReset_ from \"@mdi/svg/svg/magnify-expand.svg?component\";\nimport zoomReset_ from \"@mdi/svg/svg/magnify-expand.svg?url\";\nimport ZoomOut_ from \"@mdi/svg/svg/magnify-minus-outline.svg?component\";\nimport zoomOut_ from \"@mdi/svg/svg/magnify-minus-outline.svg?url\";\nimport ZoomIn_ from \"@mdi/svg/svg/magnify-plus-outline.svg?component\";\nimport zoomIn_ from \"@mdi/svg/svg/magnify-plus-outline.svg?url\";\nimport MagnifyScan_ from \"@mdi/svg/svg/magnify-scan.svg?component\";\nimport magnifyScan_ from \"@mdi/svg/svg/magnify-scan.svg?url\";\nimport Magnify_ from \"@mdi/svg/svg/magnify.svg?component\";\nimport magnify_ from \"@mdi/svg/svg/magnify.svg?url\";\nimport Math_ from \"@mdi/svg/svg/math-integral-box.svg?component\";\nimport math_ from \"@mdi/svg/svg/math-integral-box.svg?url\";\nimport NewBox_ from \"@mdi/svg/svg/new-box.svg?component\";\nimport newBox_ from \"@mdi/svg/svg/new-box.svg?url\";\nimport Paperclip_ from \"@mdi/svg/svg/paperclip.svg?component\";\nimport paperclip_ from \"@mdi/svg/svg/paperclip.svg?url\";\nimport RectangleOutline_ from \"@mdi/svg/svg/rectangle-outline.svg?component\";\nimport rectangleOutline_ from \"@mdi/svg/svg/rectangle-outline.svg?url\";\nimport Redo_ from \"@mdi/svg/svg/redo.svg?component\";\nimport redo_ from \"@mdi/svg/svg/redo.svg?url\";\nimport Refresh_ from \"@mdi/svg/svg/refresh.svg?component\";\nimport refresh_ from \"@mdi/svg/svg/refresh.svg?url\";\nimport SelectAll_ from \"@mdi/svg/svg/select-all.svg?component\";\nimport selectAll_ from \"@mdi/svg/svg/select-all.svg?url\";\nimport Square_ from \"@mdi/svg/svg/square.svg?component\";\nimport square_ from \"@mdi/svg/svg/square.svg?url\";\nimport TableRefresh_ from \"@mdi/svg/svg/table-refresh.svg?component\";\nimport tableRefresh_ from \"@mdi/svg/svg/table-refresh.svg?url\";\nimport Tag_ from \"@mdi/svg/svg/tag-outline.svg?component\";\nimport tag_ from \"@mdi/svg/svg/tag-outline.svg?url\";\nimport AddTag_ from \"@mdi/svg/svg/tag-plus-outline.svg?component\";\nimport addTag_ from \"@mdi/svg/svg/tag-plus-outline.svg?url\";\nimport TextBox_ from \"@mdi/svg/svg/text-box.svg?component\";\nimport textBox_ from \"@mdi/svg/svg/text-box.svg?url\";\nimport Undo_ from \"@mdi/svg/svg/undo.svg?component\";\nimport undo_ from \"@mdi/svg/svg/undo.svg?url\";\nimport UnfoldMoreHorizontal_ from \"@mdi/svg/svg/unfold-more-horizontal.svg?component\";\nimport unfoldMoreHorizontal_ from \"@mdi/svg/svg/unfold-more-horizontal.svg?url\";\nimport Ungroup_ from \"@mdi/svg/svg/ungroup.svg?component\";\nimport ungroup_ from \"@mdi/svg/svg/ungroup.svg?url\";\nimport Update_ from \"@mdi/svg/svg/update.svg?component\";\nimport update_ from \"@mdi/svg/svg/update.svg?url\";\nimport VectorPolygonVariant_ from \"@mdi/svg/svg/vector-polygon-variant.svg?component\";\nimport vectorPolygonVariant_ from \"@mdi/svg/svg/vector-polygon-variant.svg?url\";\nimport ViewDashboard_ from \"@mdi/svg/svg/view-dashboard.svg?component\";\nimport viewDashboard_ from \"@mdi/svg/svg/view-dashboard.svg?url\";\nimport Revert_ from \"bootstrap-icons/icons/arrow-counterclockwise.svg?component\";\nimport revert_ from \"bootstrap-icons/icons/arrow-counterclockwise.svg?url\";\nimport ArrowLeft_ from \"bootstrap-icons/icons/arrow-left.svg?component\";\nimport arrowLeft_ from \"bootstrap-icons/icons/arrow-left.svg?url\";\nimport ArrowRight_ from \"bootstrap-icons/icons/arrow-right.svg?component\";\nimport arrowRight_ from \"bootstrap-icons/icons/arrow-right.svg?url\";\nimport Minus_ from \"bootstrap-icons/icons/dash-lg.svg?component\";\nimport minus_ from \"bootstrap-icons/icons/dash-lg.svg?url\";\nimport Eraser_ from \"bootstrap-icons/icons/eraser.svg?component\";\nimport eraser_ from \"bootstrap-icons/icons/eraser.svg?url\";\nimport Exclamation_ from \"bootstrap-icons/icons/exclamation-circle.svg?component\";\nimport exclamation_ from \"bootstrap-icons/icons/exclamation-circle.svg?url\";\nimport JustifyFull_ from \"bootstrap-icons/icons/justify.svg?component\";\nimport justifyFull_ from \"bootstrap-icons/icons/justify.svg?url\";\nimport Ol_ from \"bootstrap-icons/icons/list-ol.svg?component\";\nimport ol_ from \"bootstrap-icons/icons/list-ol.svg?url\";\nimport Ul_ from \"bootstrap-icons/icons/list-ul.svg?component\";\nimport ul_ from \"bootstrap-icons/icons/list-ul.svg?url\";\nimport Mic_ from \"bootstrap-icons/icons/mic.svg?component\";\nimport mic_ from \"bootstrap-icons/icons/mic.svg?url\";\nimport Plus_ from \"bootstrap-icons/icons/plus-lg.svg?component\";\nimport plus_ from \"bootstrap-icons/icons/plus-lg.svg?url\";\nimport JustifyCenter_ from \"bootstrap-icons/icons/text-center.svg?component\";\nimport justifyCenter_ from \"bootstrap-icons/icons/text-center.svg?url\";\nimport Indent_ from \"bootstrap-icons/icons/text-indent-left.svg?component\";\nimport indent_ from \"bootstrap-icons/icons/text-indent-left.svg?url\";\nimport Outdent_ from \"bootstrap-icons/icons/text-indent-right.svg?component\";\nimport outdent_ from \"bootstrap-icons/icons/text-indent-right.svg?url\";\nimport JustifyLeft_ from \"bootstrap-icons/icons/text-left.svg?component\";\nimport justifyLeft_ from \"bootstrap-icons/icons/text-left.svg?url\";\nimport ListOptions_ from \"bootstrap-icons/icons/text-paragraph.svg?component\";\nimport listOptions_ from \"bootstrap-icons/icons/text-paragraph.svg?url\";\nimport JustifyRight_ from \"bootstrap-icons/icons/text-right.svg?component\";\nimport justifyRight_ from \"bootstrap-icons/icons/text-right.svg?url\";\nimport Bold_ from \"bootstrap-icons/icons/type-bold.svg?component\";\nimport bold_ from \"bootstrap-icons/icons/type-bold.svg?url\";\nimport Italic_ from \"bootstrap-icons/icons/type-italic.svg?component\";\nimport italic_ from \"bootstrap-icons/icons/type-italic.svg?url\";\nimport Underline_ from \"bootstrap-icons/icons/type-underline.svg?component\";\nimport underline_ from \"bootstrap-icons/icons/type-underline.svg?url\";\n\nimport IncrementCloze_ from \"../../icons/contain-plus.svg?component\";\nimport incrementCloze_ from \"../../icons/contain-plus.svg?url\";\nimport StickyHollow_ from \"../../icons/sticky-pin-hollow.svg?component\";\nimport stickyHollow_ from \"../../icons/sticky-pin-hollow.svg?url\";\nimport StickySolid_ from \"../../icons/sticky-pin-solid.svg?component\";\nimport stickySolid_ from \"../../icons/sticky-pin-solid.svg?url\";\n\nexport const checkCircle = { url: checkCircle_, component: CheckCircle_ };\nexport const chevronDown = { url: chevronDown_, component: ChevronDown_ };\nexport const chevronUp = { url: chevronUp_, component: ChevronUp_ };\nexport const closeBox = { url: closeBox_, component: CloseBox_ };\nexport const dotsIcon = { url: dots_, component: Dots_ };\nexport const horizontalHandle = { url: horizontalHandle_, component: HorizontalHandle_ };\nexport const verticalHandle = { url: verticalHandle_, component: VerticalHandle_ };\nexport const infoCircle = { url: infoCircle_, component: InfoCircle_ };\nexport const magnifyIcon = { url: magnify_, component: Magnify_ };\nexport const newBox = { url: newBox_, component: NewBox_ };\nexport const tagIcon = { url: tag_, component: Tag_ };\nexport const addTagIcon = { url: addTag_, component: AddTag_ };\nexport const updateIcon = { url: update_, component: Update_ };\nexport const revertIcon = { url: revert_, component: Revert_ };\nexport const arrowLeftIcon = { url: arrowLeft_, component: ArrowLeft_ };\nexport const arrowRightIcon = { url: arrowRight_, component: ArrowRight_ };\nexport const minusIcon = { url: minus_, component: Minus_ };\nexport const exclamationIcon = { url: exclamation_, component: Exclamation_ };\nexport const plusIcon = { url: plus_, component: Plus_ };\nexport const alertIcon = { url: alert_, component: Alert_ };\nexport const plainTextIcon = { url: plainText_, component: PlainText_ };\nexport const clozeIcon = { url: cloze_, component: Cloze_ };\nexport const richTextIcon = { url: richText_, component: RichText_ };\nexport const stickyIconHollow = { url: stickyHollow_, component: StickyHollow_ };\nexport const stickyIconSolid = { url: stickySolid_, component: StickySolid_ };\nexport const mathIcon = { url: math_, component: Math_ };\nexport const floatLeftIcon = { url: floatLeft_, component: FloatLeft_ };\nexport const floatNoneIcon = { url: floatNone_, component: FloatNone_ };\nexport const floatRightIcon = { url: floatRight_, component: FloatRight_ };\nexport const sizeClear = { url: sizeClear_, component: SizeClear_ };\nexport const sizeActual = { url: sizeActual_, component: SizeActual_ };\nexport const sizeMinimized = { url: sizeMinimized_, component: SizeMinimized_ };\nexport const cogIcon = { url: cog_, component: Cog_ };\nexport const colorHelperIcon = { url: colorHelper_, component: ColorHelper_ };\nexport const highlightColorIcon = { url: highlightColor_, component: HighlightColor_ };\nexport const textColorIcon = { url: textColor_, component: TextColor_ };\nexport const subscriptIcon = { url: subscript_, component: Subscript_ };\nexport const superscriptIcon = { url: superscript_, component: Superscript_ };\nexport const functionIcon = { url: function_, component: Function_ };\nexport const paperclipIcon = { url: paperclip_, component: Paperclip_ };\nexport const mdiRefresh = { url: refresh_, component: Refresh_ };\nexport const mdiTableRefresh = { url: tableRefresh_, component: TableRefresh_ };\nexport const mdiViewDashboard = { url: viewDashboard_, component: ViewDashboard_ };\nexport const eraserIcon = { url: eraser_, component: Eraser_ };\nexport const justifyFullIcon = { url: justifyFull_, component: JustifyFull_ };\nexport const olIcon = { url: ol_, component: Ol_ };\nexport const ulIcon = { url: ul_, component: Ul_ };\nexport const micIcon = { url: mic_, component: Mic_ };\nexport const justifyCenterIcon = { url: justifyCenter_, component: JustifyCenter_ };\nexport const indentIcon = { url: indent_, component: Indent_ };\nexport const outdentIcon = { url: outdent_, component: Outdent_ };\nexport const justifyLeftIcon = { url: justifyLeft_, component: JustifyLeft_ };\nexport const listOptionsIcon = { url: listOptions_, component: ListOptions_ };\nexport const justifyRightIcon = { url: justifyRight_, component: JustifyRight_ };\nexport const boldIcon = { url: bold_, component: Bold_ };\nexport const italicIcon = { url: italic_, component: Italic_ };\nexport const underlineIcon = { url: underline_, component: Underline_ };\nexport const deleteIcon = { url: delete_, component: Delete_ };\nexport const inlineIcon = { url: inline_, component: Inline_ };\nexport const blockIcon = { url: block_, component: Block_ };\nexport const mdiAlignHorizontalCenter = { url: alignHorizontalCenter_, component: AlignHorizontalCenter_ };\nexport const mdiAlignHorizontalLeft = { url: alignHorizontalLeft_, component: AlignHorizontalLeft_ };\nexport const mdiAlignHorizontalRight = { url: alignHorizontalRight_, component: AlignHorizontalRight_ };\nexport const mdiAlignVerticalBottom = { url: alignVerticalBottom_, component: AlignVerticalBottom_ };\nexport const mdiAlignVerticalCenter = { url: alignVerticalCenter_, component: AlignVerticalCenter_ };\nexport const mdiAlignVerticalTop = { url: alignVerticalTop_, component: AlignVerticalTop_ };\nexport const mdiClose = { url: close_, component: Close_ };\nexport const mdiCodeTags = { url: codeTags_, component: CodeTags_ };\nexport const mdiCopy = { url: copy_, component: Copy_ };\nexport const mdiCursorDefaultOutline = { url: cursorDefaultOutline_, component: CursorDefaultOutline_ };\nexport const mdiDeleteOutline = { url: deleteOutline_, component: DeleteOutline_ };\nexport const mdiEllipseOutline = { url: ellipseOutline_, component: EllipseOutline_ };\nexport const mdiEye = { url: eye_, component: Eye_ };\nexport const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ };\nexport const mdiFormatBold = { url: formatBold_, component: FormatBold_ };\nexport const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ };\nexport const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ };\nexport const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ };\nexport const mdiGroup = { url: group_, component: Group_ };\nexport const mdiZoomReset = { url: zoomReset_, component: ZoomReset_ };\nexport const mdiZoomOut = { url: zoomOut_, component: ZoomOut_ };\nexport const mdiZoomIn = { url: zoomIn_, component: ZoomIn_ };\nexport const mdiMagnifyScan = { url: magnifyScan_, component: MagnifyScan_ };\nexport const mdiRectangleOutline = { url: rectangleOutline_, component: RectangleOutline_ };\nexport const mdiRedo = { url: redo_, component: Redo_ };\nexport const mdiSelectAll = { url: selectAll_, component: SelectAll_ };\nexport const mdiSquare = { url: square_, component: Square_ };\nexport const mdiTextBox = { url: textBox_, component: TextBox_ };\nexport const mdiUndo = { url: undo_, component: Undo_ };\nexport const mdiUnfoldMoreHorizontal = { url: unfoldMoreHorizontal_, component: UnfoldMoreHorizontal_ };\nexport const mdiUngroup = { url: ungroup_, component: Ungroup_ };\nexport const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ };\nexport const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };\nexport const mdiEarth = { url: earth_, component: Earth_ };\n"
  },
  {
    "path": "ts/lib/components/resizable.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\nexport interface Resizer {\n    start(): void;\n\n    /**\n     * @returns Actually applied resize. If the resizedWidth is too small,\n     * no resize can be applied anymore.\n     */\n    resize(increment: number): number;\n    setSize(size: number): void;\n    stop(fullWidth: number, amount: number): void;\n}\n\ninterface ResizedStores {\n    resizesDimension: Writable<boolean>;\n    resizedDimension: Writable<number>;\n}\n\ntype ResizableResult = [\n    ResizedStores,\n    (element: HTMLElement, getter: (element: HTMLElement) => number) => void,\n    Resizer,\n];\n\nexport function resizable(\n    baseSize: number,\n    resizes: Writable<boolean>,\n    paneSize: Writable<number>,\n): ResizableResult {\n    const resizesDimension = writable(false);\n    const resizedDimension = writable(0);\n\n    let pane: HTMLElement;\n    let getter: (element: HTMLElement) => number;\n\n    let dimension = 0;\n\n    function resizeAction(\n        element: HTMLElement,\n        getValue: (element: HTMLElement) => number,\n    ): void {\n        pane = element;\n        getter = getValue;\n    }\n\n    function start() {\n        resizes.set(true);\n        resizesDimension.set(true);\n\n        dimension = getter(pane);\n        resizedDimension.set(dimension);\n    }\n\n    function resize(increment = 0): number {\n        if (dimension + increment < 0) {\n            const applied = -dimension;\n            dimension = 0;\n            resizedDimension.set(dimension);\n            return applied;\n        }\n\n        dimension += increment;\n        resizedDimension.set(dimension);\n        return increment;\n    }\n\n    function setSize(size = 0): void {\n        paneSize.set(size);\n    }\n\n    function stop(fullDimension: number, amount: number): void {\n        paneSize.set((dimension / fullDimension) * amount * baseSize);\n        resizesDimension.set(false);\n        resizes.set(false);\n    }\n\n    return [\n        { resizesDimension, resizedDimension },\n        resizeAction,\n        { start, resize, setSize, stop },\n    ];\n}\n"
  },
  {
    "path": "ts/lib/components/types.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;\nexport type Breakpoint = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxl\";\n\nexport type HelpItem = {\n    title: string;\n    help?: string;\n    url?: string;\n    sched?: HelpItemScheduler;\n    global?: boolean;\n};\n\nexport enum HelpItemScheduler {\n    SM2 = 0,\n    FSRS = 1,\n}\n\nexport type IconData = {\n    url: string;\n};\n"
  },
  {
    "path": "ts/lib/domlib/content-editable.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * Trivial wrapper to silence Svelte deprecation warnings\n */\nexport function execCommand(\n    command: string,\n    showUI?: boolean | undefined,\n    value?: string | undefined,\n): void {\n    document.execCommand(command, showUI, value);\n}\n\n/**\n * Trivial wrappers to silence Svelte deprecation warnings\n */\nexport function queryCommandState(command: string): boolean {\n    return document.queryCommandState(command);\n}\n"
  },
  {
    "path": "ts/lib/domlib/find-above.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { nodeIsElement } from \"@tslib/dom\";\n\nexport type Matcher = (element: Element) => boolean;\n\nfunction findParent(current: Node, base: Element): Element | null {\n    if (current === base) {\n        return null;\n    }\n\n    return current.parentElement;\n}\n\n/**\n * Similar to element.closest(), but allows you to pass in a predicate\n * function, instead of a selector\n *\n * @remarks\n * Unlike element.closest, this will not match against `node`, but will start\n * at `node.parentElement`.\n */\nexport function findClosest(\n    node: Node,\n    base: Element,\n    matcher: Matcher,\n): Element | null {\n    if (nodeIsElement(node) && matcher(node)) {\n        return node;\n    }\n\n    let current = findParent(node, base);\n\n    while (current) {\n        if (matcher(current)) {\n            return current;\n        }\n\n        current = findParent(current, base);\n    }\n\n    return null;\n}\n\n/**\n * Similar to `findClosest`, but will go as far as possible.\n */\nexport function findFarthest(\n    node: Node,\n    base: Element,\n    matcher: Matcher,\n): Element | null {\n    let farthest: Element | null = null;\n    let current: Node | null = node;\n\n    while (current) {\n        const next = findClosest(current, base, matcher);\n\n        if (next) {\n            farthest = next;\n            current = findParent(next, base);\n        } else {\n            break;\n        }\n    }\n\n    return farthest;\n}\n"
  },
  {
    "path": "ts/lib/domlib/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport * from \"./content-editable\";\nexport * from \"./location\";\nexport * from \"./move-nodes\";\nexport * from \"./place-caret\";\nexport * from \"./surround\";\n"
  },
  {
    "path": "ts/lib/domlib/location/document.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getSelection } from \"@tslib/cross-browser\";\n\nimport { findNodeFromCoordinates } from \"./node\";\nimport type { SelectionLocation, SelectionLocationContent } from \"./selection\";\nimport { getSelectionLocation } from \"./selection\";\n\nfunction unselect(selection: Selection): void {\n    selection.empty();\n}\n\nfunction setSelectionToLocationContent(\n    node: Node,\n    selection: Selection,\n    range: Range,\n    location: SelectionLocationContent,\n) {\n    const focusLocation = location.focus;\n    const focusOffset = focusLocation.offset;\n    const focusNode = findNodeFromCoordinates(node, focusLocation.coordinates);\n\n    if (location.direction === \"forward\") {\n        range.setEnd(focusNode!, focusOffset!);\n        selection.addRange(range);\n    } /* location.direction === \"backward\" */ else {\n        selection.addRange(range);\n        selection.extend(focusNode!, focusOffset!);\n    }\n}\n\nexport function saveSelection(base: Node): SelectionLocation | null {\n    return getSelectionLocation(base);\n}\n\nexport function restoreSelection(base: Node, location: SelectionLocation): void {\n    const selection = getSelection(base)!;\n    unselect(selection);\n\n    const range = new Range();\n    const anchorNode = findNodeFromCoordinates(base, location.anchor.coordinates);\n    range.setStart(anchorNode!, location.anchor.offset!);\n\n    if (location.collapsed) {\n        range.collapse(true);\n        selection.addRange(range);\n    } else {\n        setSelectionToLocationContent(\n            base,\n            selection,\n            range,\n            location,\n        );\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/location/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { registerPackage } from \"@tslib/runtime-require\";\n\nimport { restoreSelection, saveSelection } from \"./document\";\nimport { Position } from \"./location\";\nimport { findNodeFromCoordinates, getNodeCoordinates } from \"./node\";\nimport { getRangeCoordinates } from \"./range\";\n\nregisterPackage(\"anki/location\", {\n    Position,\n    restoreSelection,\n    saveSelection,\n});\n\nexport { findNodeFromCoordinates, getNodeCoordinates, getRangeCoordinates, Position, restoreSelection, saveSelection };\nexport type { RangeCoordinates } from \"./range\";\nexport type { SelectionLocation } from \"./selection\";\n"
  },
  {
    "path": "ts/lib/domlib/location/location.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport interface CaretLocation {\n    coordinates: number[];\n    offset: number;\n}\n\nexport enum Position {\n    Before = -1,\n    Equal,\n    After,\n}\n\n/**\n * @returns: Whether first is positioned {before,equal to,after} second\n */\nexport function compareLocations(\n    first: CaretLocation,\n    second: CaretLocation,\n): Position {\n    const smallerLength = Math.min(first.coordinates.length, second.coordinates.length);\n\n    for (let i = 0; i <= smallerLength; i++) {\n        if (first.coordinates.length === i) {\n            if (second.coordinates.length === i) {\n                if (first.offset < second.offset) {\n                    return Position.Before;\n                } else if (first.offset > second.offset) {\n                    return Position.After;\n                } else {\n                    return Position.Equal;\n                }\n            }\n            return Position.Before;\n        } else if (second.coordinates.length === i) {\n            return Position.After;\n        } else if (first.coordinates[i] < second.coordinates[i]) {\n            return Position.Before;\n        } else if (first.coordinates[i] > second.coordinates[i]) {\n            return Position.After;\n        }\n    }\n\n    throw new Error(\"compareLocations: Should never happen\");\n}\n"
  },
  {
    "path": "ts/lib/domlib/location/node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nfunction getNodeCoordinatesRecursive(\n    node: Node,\n    base: Node,\n    coordinates: number[],\n): number[] {\n    /* parentNode: Element | Document | DocumentFragment */\n    if (!node.parentNode || node === base) {\n        return coordinates;\n    } else {\n        const parent = node.parentNode;\n        const newCoordinates = [\n            Array.prototype.indexOf.call(node.parentNode.childNodes, node),\n            ...coordinates,\n        ];\n        return getNodeCoordinatesRecursive(parent, base, newCoordinates);\n    }\n}\n\nexport function getNodeCoordinates(node: Node, base: Node): number[] {\n    return getNodeCoordinatesRecursive(node, base, []);\n}\n\nexport function findNodeFromCoordinates(\n    base: Node,\n    coordinates: number[],\n): Node | null {\n    if (coordinates.length === 0) {\n        return base;\n    } else if (!base.childNodes[coordinates[0]]) {\n        return null;\n    } else {\n        const [firstCoordinate, ...restCoordinates] = coordinates;\n        return findNodeFromCoordinates(\n            base.childNodes[firstCoordinate],\n            restCoordinates,\n        );\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/location/range.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { CaretLocation } from \"./location\";\nimport { getNodeCoordinates } from \"./node\";\n\ninterface RangeCoordinatesCollapsed {\n    readonly start: CaretLocation;\n    readonly collapsed: true;\n}\n\nexport interface RangeCoordinatesContent {\n    readonly start: CaretLocation;\n    readonly end: CaretLocation;\n    readonly collapsed: false;\n}\n\nexport type RangeCoordinates = RangeCoordinatesCollapsed | RangeCoordinatesContent;\n\nexport function getRangeCoordinates(range: Range, base: Node): RangeCoordinates {\n    const startCoordinates = getNodeCoordinates(base, range.startContainer);\n    const start = { coordinates: startCoordinates, offset: range.startOffset };\n    const collapsed = range.collapsed;\n\n    if (collapsed) {\n        return { start, collapsed };\n    }\n\n    const endCoordinates = getNodeCoordinates(base, range.endContainer);\n    const end = { coordinates: endCoordinates, offset: range.endOffset };\n\n    return { start, end, collapsed };\n}\n"
  },
  {
    "path": "ts/lib/domlib/location/selection.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getRange, getSelection } from \"@tslib/cross-browser\";\n\nimport type { CaretLocation } from \"./location\";\nimport { compareLocations, Position } from \"./location\";\nimport { getNodeCoordinates } from \"./node\";\n\nexport interface SelectionLocationCollapsed {\n    readonly anchor: CaretLocation;\n    readonly collapsed: true;\n}\n\nexport interface SelectionLocationContent {\n    readonly anchor: CaretLocation;\n    readonly focus: CaretLocation;\n    readonly collapsed: false;\n    readonly direction: \"forward\" | \"backward\";\n}\n\nexport type SelectionLocation = SelectionLocationCollapsed | SelectionLocationContent;\n\nexport function getSelectionLocation(base: Node): SelectionLocation | null {\n    const selection = getSelection(base)!;\n    const range = getRange(selection);\n\n    if (!range) {\n        return null;\n    }\n\n    const collapsed = range.collapsed;\n    const anchorCoordinates = getNodeCoordinates(selection.anchorNode!, base);\n    const anchor = { coordinates: anchorCoordinates, offset: selection.anchorOffset };\n\n    if (collapsed) {\n        return { anchor, collapsed };\n    }\n\n    const focusCoordinates = getNodeCoordinates(selection.focusNode!, base);\n    const focus = { coordinates: focusCoordinates, offset: selection.focusOffset };\n    const order = compareLocations(anchor, focus);\n\n    const direction = order === Position.After ? \"backward\" : \"forward\";\n\n    return {\n        anchor,\n        focus,\n        collapsed,\n        direction,\n    };\n}\n"
  },
  {
    "path": "ts/lib/domlib/move-nodes.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { nodeIsElement, nodeIsText } from \"@tslib/dom\";\n\nimport { placeCaretAfter } from \"./place-caret\";\n\nexport function moveChildOutOfElement(\n    element: Element,\n    child: Node,\n    placement: \"beforebegin\" | \"afterend\",\n): Node {\n    if (child.isConnected) {\n        child.parentNode!.removeChild(child);\n    }\n\n    let referenceNode: Node;\n\n    if (nodeIsElement(child)) {\n        referenceNode = element.insertAdjacentElement(placement, child)!;\n    } else if (nodeIsText(child)) {\n        element.insertAdjacentText(placement, child.wholeText);\n        referenceNode = placement === \"beforebegin\"\n            ? element.previousSibling!\n            : element.nextSibling!;\n    } else {\n        throw \"moveChildOutOfElement: unsupported\";\n    }\n\n    return referenceNode;\n}\n\nexport function moveNodesInsertedOutside(element: Element, allowedChild: Node): void {\n    if (element.childNodes.length === 1) {\n        return;\n    }\n\n    const childNodes = [...element.childNodes];\n    const allowedIndex = childNodes.findIndex((child) => child === allowedChild);\n\n    const beforeChildren = childNodes.slice(0, allowedIndex);\n    const afterChildren = childNodes.slice(allowedIndex + 1);\n\n    // Special treatment for pressing return after mathjax block\n    if (\n        afterChildren.length === 2\n        && afterChildren.every((child) => (child as Element).tagName === \"BR\")\n    ) {\n        const first = afterChildren.pop();\n        element.removeChild(first!);\n    }\n\n    let lastNode: Node | null = null;\n\n    for (const node of beforeChildren) {\n        lastNode = moveChildOutOfElement(element, node, \"beforebegin\");\n    }\n\n    for (const node of afterChildren) {\n        lastNode = moveChildOutOfElement(element, node, \"afterend\");\n    }\n\n    if (lastNode) {\n        placeCaretAfter(lastNode);\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/place-caret.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getSelection } from \"@tslib/cross-browser\";\n\nfunction placeCaret(node: Node, range: Range): void {\n    const selection = getSelection(node)!;\n    selection.removeAllRanges();\n    selection.addRange(range);\n}\n\nexport function placeCaretBefore(node: Node): void {\n    const range = new Range();\n    range.setStartBefore(node);\n    range.collapse(true);\n\n    placeCaret(node, range);\n}\n\nexport function placeCaretAfter(node: Node): void {\n    const range = new Range();\n    range.setStartAfter(node);\n    range.collapse(true);\n\n    placeCaret(node, range);\n}\n\nexport function placeCaretAfterContent(node: Node): void {\n    const range = new Range();\n    range.selectNodeContents(node);\n    range.collapse(false);\n\n    placeCaret(node, range);\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/apply/format.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { SurroundFormat } from \"../surround-format\";\nimport type { FormattingNode } from \"../tree\";\n\nexport class ApplyFormat<T> {\n    constructor(protected readonly format: SurroundFormat<T>) {}\n\n    applyFormat(node: FormattingNode<T>): boolean {\n        if (this.format.surroundElement) {\n            node.range\n                .toDOMRange()\n                .surroundContents(this.format.surroundElement.cloneNode(false));\n            return true;\n        } else if (this.format.formatter) {\n            return this.format.formatter(node);\n        }\n\n        return false;\n    }\n}\n\nexport class UnsurroundApplyFormat<T> extends ApplyFormat<T> {\n    applyFormat(node: FormattingNode<T>): boolean {\n        if (node.insideRange) {\n            return false;\n        }\n\n        return super.applyFormat(node);\n    }\n}\n\nexport class ReformatApplyFormat<T> extends ApplyFormat<T> {\n    applyFormat(node: FormattingNode<T>): boolean {\n        if (!node.hasMatch) {\n            return false;\n        }\n\n        return super.applyFormat(node);\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/apply/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { TreeNode } from \"../tree\";\nimport { FormattingNode } from \"../tree\";\nimport type { ApplyFormat } from \"./format\";\n\nfunction iterate<T>(node: TreeNode, format: ApplyFormat<T>, leftShift: number): number {\n    let innerShift = 0;\n\n    for (const child of node.children) {\n        innerShift += iterate(child, format, innerShift);\n    }\n\n    return node instanceof FormattingNode\n        ? applyFormat(node, format, leftShift, innerShift)\n        : 0;\n}\n\n/**\n * @returns Inner shift.\n */\nfunction applyFormat<T>(\n    node: FormattingNode<T>,\n    format: ApplyFormat<T>,\n    leftShift: number,\n    innerShift: number,\n): number {\n    node.range.startIndex += leftShift;\n    node.range.endIndex += leftShift + innerShift;\n\n    return format.applyFormat(node)\n        ? node.range.startIndex - node.range.endIndex + 1\n        : 0;\n}\n\nexport function apply<T>(nodes: TreeNode[], format: ApplyFormat<T>): void {\n    let innerShift = 0;\n\n    for (const node of nodes) {\n        innerShift += iterate(node, format, innerShift);\n    }\n}\n\nexport { ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from \"./format\";\n"
  },
  {
    "path": "ts/lib/domlib/surround/build/add-merge.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { TreeNode } from \"../tree\";\nimport { FormattingNode } from \"../tree\";\nimport type { BuildFormat } from \"./format\";\n\nfunction mergeAppendNode<T>(\n    initial: TreeNode[],\n    last: FormattingNode<T>,\n    format: BuildFormat<T>,\n): TreeNode[] {\n    const minimized: TreeNode[] = [last];\n\n    for (let i = initial.length - 1; i >= 0; i--) {\n        const next = initial[i];\n\n        let merged: FormattingNode<T> | null;\n        if (next instanceof FormattingNode && (merged = format.tryMerge(next, last))) {\n            minimized[0] = merged;\n        } else {\n            minimized.unshift(...initial.slice(0, i + 1));\n            break;\n        }\n    }\n\n    return minimized;\n}\n\n/**\n * Tries to merge `last`, into the end of `initial`.\n */\nexport function appendNode<T>(\n    initial: TreeNode[],\n    last: TreeNode,\n    format: BuildFormat<T>,\n): TreeNode[] {\n    if (last instanceof FormattingNode) {\n        return mergeAppendNode(initial, last, format);\n    } else {\n        return [...initial, last];\n    }\n}\n\nfunction mergeInsertNode<T>(\n    first: FormattingNode<T>,\n    tail: TreeNode[],\n    format: BuildFormat<T>,\n): TreeNode[] {\n    const minimized: TreeNode[] = [first];\n\n    for (let i = 0; i <= tail.length; i++) {\n        const next = tail[i];\n\n        let merged: FormattingNode<T> | null;\n        if (next instanceof FormattingNode && (merged = format.tryMerge(first, next))) {\n            minimized[0] = merged;\n        } else {\n            minimized.push(...tail.slice(i));\n            break;\n        }\n    }\n\n    return minimized;\n}\n\n/**\n * Tries to merge `first`, into the start of `tail`.\n */\nexport function insertNode<T>(\n    first: TreeNode,\n    tail: TreeNode[],\n    format: BuildFormat<T>,\n): TreeNode[] {\n    if (first instanceof FormattingNode) {\n        return mergeInsertNode(first, tail, format);\n    } else {\n        return [first, ...tail];\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/build/build-tree.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { elementIsEmpty, nodeIsElement, nodeIsText } from \"@tslib/dom\";\n\nimport type { Match } from \"../match-type\";\nimport type { TreeNode } from \"../tree\";\nimport { BlockNode, ElementNode, FormattingNode } from \"../tree\";\nimport { appendNode } from \"./add-merge\";\nimport type { BuildFormat } from \"./format\";\n\nfunction buildFromElement<T>(\n    element: Element,\n    format: BuildFormat<T>,\n    matchAncestors: Match<T>[],\n): TreeNode[] {\n    const match = format.createMatch(element);\n\n    if (match.matches) {\n        matchAncestors = [...matchAncestors, match];\n    }\n\n    let children: TreeNode[] = [];\n    for (const child of [...element.childNodes]) {\n        const nodes = buildFromNode(child, format, matchAncestors);\n\n        for (const node of nodes) {\n            children = appendNode(children, node, format);\n        }\n    }\n\n    if (match.shouldRemove()) {\n        const parent = element.parentElement!;\n        const childIndex = Array.prototype.indexOf.call(parent.childNodes, element);\n\n        for (const child of children) {\n            if (child instanceof FormattingNode) {\n                if (child.hasMatchHoles) {\n                    child.matchLeaves.push(match);\n                    child.hasMatchHoles = false;\n                }\n\n                child.range.parent = parent;\n                child.range.startIndex += childIndex;\n                child.range.endIndex += childIndex;\n            }\n        }\n\n        element.replaceWith(...element.childNodes);\n        return children;\n    }\n\n    const matchNode = ElementNode.make(\n        element,\n        children.every((node: TreeNode): boolean => node.insideRange),\n    );\n\n    if (children.length === 0) {\n        // This means there are no non-negligible children\n        return [];\n    } else if (children.length === 1) {\n        const [only] = children;\n\n        if (\n            // blocking\n            only instanceof BlockNode\n            // ascension\n            || (only instanceof FormattingNode && format.tryAscend(only, matchNode))\n        ) {\n            return [only];\n        }\n    }\n\n    matchNode.replaceChildren(...children);\n    return [matchNode];\n}\n\nfunction buildFromText<T>(\n    text: Text,\n    format: BuildFormat<T>,\n    matchAncestors: Match<T>[],\n): FormattingNode<T> | BlockNode {\n    const insideRange = format.isInsideRange(text);\n\n    if (!insideRange && matchAncestors.length === 0) {\n        return BlockNode.make();\n    }\n\n    return FormattingNode.fromText(text, insideRange, matchAncestors);\n}\n\nfunction elementIsNegligible(element: Element): boolean {\n    return elementIsEmpty(element);\n}\n\nfunction textIsNegligible(text: Text): boolean {\n    return text.length === 0;\n}\n\n/**\n * Builds a formatting tree starting at node.\n *\n * @returns root of the formatting tree\n */\nexport function buildFromNode<T>(\n    node: Node,\n    format: BuildFormat<T>,\n    matchAncestors: Match<T>[],\n): TreeNode[] {\n    if (nodeIsText(node) && !textIsNegligible(node)) {\n        return [buildFromText(node, format, matchAncestors)];\n    } else if (nodeIsElement(node) && !elementIsNegligible(node)) {\n        return buildFromElement(node, format, matchAncestors);\n    } else {\n        return [];\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/build/extend-merge.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { BuildFormat } from \"../build\";\nimport type { TreeNode } from \"../tree\";\nimport { FormattingNode } from \"../tree\";\nimport { appendNode, insertNode } from \"./add-merge\";\nimport { buildFromNode } from \"./build-tree\";\n\nfunction mergePreviousTrees<T>(forest: TreeNode[], format: BuildFormat<T>): TreeNode[] {\n    const [first, ...tail] = forest;\n\n    if (!(first instanceof FormattingNode)) {\n        return forest;\n    }\n\n    let merged: TreeNode[] = [first];\n    let sibling = first.range.firstChild.previousSibling;\n\n    while (sibling && merged.length === 1) {\n        const nodes = buildFromNode(sibling, format, []);\n\n        for (const node of nodes) {\n            merged = insertNode(node, merged, format);\n        }\n\n        sibling = sibling.previousSibling;\n    }\n\n    return [...merged, ...tail];\n}\n\nfunction mergeNextTrees<T>(forest: TreeNode[], format: BuildFormat<T>): TreeNode[] {\n    const initial = forest.slice(0, -1);\n    const last = forest[forest.length - 1];\n\n    if (!(last instanceof FormattingNode)) {\n        return forest;\n    }\n\n    let merged: TreeNode[] = [last];\n    let sibling = last.range.lastChild.nextSibling;\n\n    while (sibling && merged.length === 1) {\n        const nodes = buildFromNode(sibling, format, []);\n\n        for (const node of nodes) {\n            merged = appendNode(merged, node, format);\n        }\n\n        sibling = sibling.nextSibling;\n    }\n\n    return [...initial, ...merged];\n}\n\nexport function extendAndMerge<T>(\n    forest: TreeNode[],\n    format: BuildFormat<T>,\n): TreeNode[] {\n    const merged = mergeNextTrees(mergePreviousTrees(forest, format), format);\n\n    if (merged.length === 1) {\n        const [only] = merged;\n\n        if (only instanceof FormattingNode) {\n            const elementNode = only.getExtension();\n\n            if (elementNode && format.tryAscend(only, elementNode)) {\n                return extendAndMerge(merged, format);\n            }\n        }\n    }\n\n    return merged;\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/build/format.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { elementIsBlock } from \"@tslib/dom\";\n\nimport { Position } from \"../../location\";\nimport { Match } from \"../match-type\";\nimport type { SplitRange } from \"../split-text\";\nimport type { SurroundFormat } from \"../surround-format\";\nimport type { ElementNode } from \"../tree\";\nimport { FormattingNode } from \"../tree\";\n\nfunction nodeWithinRange(node: Node, range: Range): boolean {\n    const nodeRange = new Range();\n    nodeRange.selectNodeContents(node);\n\n    return (\n        range.compareBoundaryPoints(Range.START_TO_START, nodeRange)\n            !== Position.After\n        && range.compareBoundaryPoints(Range.END_TO_END, nodeRange) !== Position.Before\n    );\n}\n\n/**\n * Takes user-provided functions as input, to modify certain parts of the algorithm.\n */\nexport class BuildFormat<T> {\n    constructor(\n        public readonly format: SurroundFormat<T>,\n        public readonly base: Element,\n        public readonly range: Range,\n        public readonly splitRange: SplitRange,\n    ) {}\n\n    createMatch(element: Element): Match<T> {\n        const match = new Match<T>();\n        this.format.matcher(element as HTMLElement | SVGElement, match);\n        return match;\n    }\n\n    tryMerge(\n        before: FormattingNode<T>,\n        after: FormattingNode<T>,\n    ): FormattingNode<T> | null {\n        if (!this.format.merger || this.format.merger(before, after)) {\n            return FormattingNode.merge(before, after);\n        }\n\n        return null;\n    }\n\n    tryAscend(node: FormattingNode<T>, elementNode: ElementNode): boolean {\n        if (!elementIsBlock(elementNode.element) && elementNode.element !== this.base) {\n            node.ascendAbove(elementNode);\n            return true;\n        }\n\n        return false;\n    }\n\n    isInsideRange(node: Node): boolean {\n        return nodeWithinRange(node, this.range);\n    }\n\n    announceElementRemoval(element: Element): void {\n        this.splitRange.adjustRange(element);\n    }\n\n    recreateRange(): Range {\n        return this.splitRange.toDOMRange();\n    }\n}\n\nexport class UnsurroundBuildFormat<T> extends BuildFormat<T> {\n    tryMerge(\n        before: FormattingNode<T>,\n        after: FormattingNode<T>,\n    ): FormattingNode<T> | null {\n        if (before.insideRange !== after.insideRange) {\n            return null;\n        }\n\n        return super.tryMerge(before, after);\n    }\n}\n\nexport class ReformatBuildFormat<T> extends BuildFormat<T> {\n    tryMerge(\n        before: FormattingNode<T>,\n        after: FormattingNode<T>,\n    ): FormattingNode<T> | null {\n        if (before.hasMatch !== after.hasMatch) {\n            return null;\n        }\n\n        return super.tryMerge(before, after);\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/build/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { TreeNode } from \"../tree\";\nimport { buildFromNode } from \"./build-tree\";\nimport { extendAndMerge } from \"./extend-merge\";\nimport type { BuildFormat } from \"./format\";\n\n/**\n * Builds a TreeNode forest structure from an input node.\n *\n * @remarks\n * This will remove matching elements from the DOM. This is necessary to make\n * some normalizations.\n *\n * @param node: This node should have no matching ancestors.\n */\nexport function build<T>(node: Node, build: BuildFormat<T>): TreeNode[] {\n    return extendAndMerge(buildFromNode(node, build, []), build);\n}\n\nexport { BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from \"./format\";\n"
  },
  {
    "path": "ts/lib/domlib/surround/flat-range.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { nodeIsComment, nodeIsElement, nodeIsText } from \"@tslib/dom\";\nimport { ascend } from \"@tslib/node\";\n\n/**\n * Represents a subset of DOM ranges which can be called with `.surroundContents()`.\n */\nexport class FlatRange {\n    private constructor(\n        public parent: Node,\n        public startIndex: number,\n        public endIndex: number,\n    ) {}\n\n    /**\n     * The new flat range does not represent the range itself but\n     * rather a possible new node that surrounds the boundary points\n     * (node, start) till (node, end).\n     *\n     * @remarks\n     * Indices should be >= 0 and startIndex <= endIndex.\n     */\n    static make(node: Node, startIndex: number, endIndex = startIndex + 1): FlatRange {\n        return new FlatRange(node, startIndex, endIndex);\n    }\n\n    /**\n     * @remarks\n     * Must be sibling flat ranges.\n     */\n    static merge(before: FlatRange, after: FlatRange): FlatRange {\n        return FlatRange.make(before.parent, before.startIndex, after.endIndex);\n    }\n\n    /**\n     * @remarks\n     */\n    static fromNode(node: Node): FlatRange {\n        const parent = ascend(node);\n        const index = Array.prototype.indexOf.call(parent.childNodes, node);\n\n        return FlatRange.make(parent, index);\n    }\n\n    get firstChild(): ChildNode {\n        return this.parent.childNodes[this.startIndex];\n    }\n\n    get lastChild(): ChildNode {\n        return this.parent.childNodes[this.endIndex - 1];\n    }\n\n    /**\n     * @see `fromNode`\n     */\n    select(node: Node): void {\n        this.parent = ascend(node);\n        this.startIndex = Array.prototype.indexOf.call(this.parent.childNodes, node);\n        this.endIndex = this.startIndex + 1;\n    }\n\n    toDOMRange(): Range {\n        const range = new Range();\n        range.setStart(this.parent, this.startIndex);\n        range.setEnd(this.parent, this.endIndex);\n\n        if (range.collapsed) {\n            // If the range is collapsed to a single element, move the range inside the element.\n            // This prevents putting the surround above the base element.\n            const selected = range.commonAncestorContainer.childNodes[range.startOffset];\n\n            if (nodeIsElement(selected)) {\n                range.selectNode(selected);\n            }\n        }\n\n        return range;\n    }\n\n    [Symbol.iterator](): Iterator<ChildNode, null, unknown> {\n        const parent = this.parent;\n        const end = this.endIndex;\n        let step = this.startIndex;\n\n        return {\n            next(): IteratorResult<ChildNode, null> {\n                if (step >= end) {\n                    return { value: null, done: true };\n                }\n\n                return { value: parent.childNodes[step++], done: false };\n            },\n        };\n    }\n\n    /**\n     * @returns Amount of contained nodes\n     */\n    get length(): number {\n        return this.endIndex - this.startIndex;\n    }\n\n    toString(): string {\n        let output = \"\";\n\n        for (const node of [...this]) {\n            if (nodeIsText(node)) {\n                output += node.data;\n            } else if (nodeIsComment(node)) {\n                output += `<!--${node.data}-->`;\n            } else if (nodeIsElement(node)) {\n                output += node.outerHTML;\n            }\n        }\n\n        return output;\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport type { MatchType } from \"./match-type\";\nexport { boolMatcher } from \"./match-type\";\nexport { reformat, surround, unsurround } from \"./surround\";\nexport type { SurroundFormat } from \"./surround-format\";\nexport type { FormattingNode } from \"./tree\";\n"
  },
  {
    "path": "ts/lib/domlib/surround/match-type.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { SurroundFormat } from \"./surround-format\";\n\nexport interface MatchType<T = never> {\n    /**\n     * The element represented by the match will be removed from the document.\n     */\n    remove(): void;\n    /**\n     * If the element has some styling applied that matches the format, but\n     * might contain some styling above that, you should use clear and do the\n     * modifying in the callback.\n     *\n     * @remarks\n     * You can still call `match.remove()` in the callback\n     *\n     * @example\n     * If you want to match bold elements, `<span class=\"myclass\" style=\"font-weight:bold\"/>\n     * should match via `clear`, but should not be removed, because it still\n     * has a class applied, even if the `style` attribute is removed.\n     */\n    clear(callback: () => void): void;\n    /**\n     * Used to sustain a value that is needed to recreate the surrounding.\n     * Can be retrieved from the FormattingNode interface via `.getCache`.\n     */\n    setCache(value: T): void;\n}\n\ntype Callback = () => void;\n\nexport class Match<T> implements MatchType<T> {\n    private _shouldRemove = false;\n    remove(): void {\n        this._shouldRemove = true;\n    }\n\n    private _callback: Callback | null = null;\n    clear(callback: Callback): void {\n        this._callback = callback;\n    }\n\n    get matches(): boolean {\n        return Boolean(this._callback) || this._shouldRemove;\n    }\n\n    /**\n     * @internal\n     */\n    shouldRemove(): boolean {\n        this._callback?.();\n        this._callback = null;\n        return this._shouldRemove;\n    }\n\n    cache: T | null = null;\n    setCache(value: T): void {\n        this.cache = value;\n    }\n}\n\nclass FakeMatch implements MatchType<never> {\n    public value = false;\n\n    remove(): void {\n        this.value = true;\n    }\n\n    clear(): void {\n        this.value = true;\n    }\n\n    setCache(): void {\n        // noop\n    }\n}\n\n/**\n * Turns the format.matcher into a function that can be used with `findAbove`.\n */\nexport function boolMatcher<T>(\n    format: SurroundFormat<T>,\n): (element: Element) => boolean {\n    return function(element: Element): boolean {\n        const fake = new FakeMatch();\n        format.matcher(element as HTMLElement | SVGElement, fake);\n        return fake.value;\n    };\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/split-text.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { nodeIsText } from \"@tslib/dom\";\n\n/**\n * @link https://dom.spec.whatwg.org/#concept-node-length\n */\nfunction length(node: Node): number {\n    if (node instanceof CharacterData) {\n        return node.length;\n    } else if (\n        node.nodeType === Node.DOCUMENT_TYPE_NODE\n        || node.nodeType === Node.ATTRIBUTE_NODE\n    ) {\n        return 0;\n    }\n\n    return node.childNodes.length;\n}\n\n/**\n * Wrapper around DOM ranges that are passed into evaluation and are adjusted,\n * if its start or end nodes are to be removed\n */\nexport class SplitRange {\n    constructor(protected start: Node, protected end: Node) {}\n\n    private adjustStart(): void {\n        if (this.start.firstChild) {\n            this.start = this.start.firstChild;\n        } else if (this.start.nextSibling) {\n            this.start = this.start.nextSibling!;\n        }\n    }\n\n    private adjustEnd(): void {\n        if (this.end.lastChild) {\n            this.end = this.end.lastChild!;\n        } else if (this.end.previousSibling) {\n            this.end = this.end.previousSibling;\n        }\n    }\n\n    adjustRange(element: Element): void {\n        if (this.start === element) {\n            this.adjustStart();\n        } else if (this.end === element) {\n            this.adjustEnd();\n        }\n    }\n\n    /**\n     * Returns a range with boundary points `(start, 0)` and `(end, end.length)`.\n     */\n    toDOMRange(): Range {\n        const range = new Range();\n        range.setStart(this.start, 0);\n        range.setEnd(this.end, length(this.end));\n\n        return range;\n    }\n}\n\n/**\n * @returns Split text node to end direction or text itself if a split is\n * not necessary\n */\nfunction splitTextIfNecessary(text: Text, offset: number): Text {\n    if (offset === 0 || offset === text.length) {\n        return text;\n    }\n\n    return text.splitText(offset);\n}\n\nexport function splitPartiallySelected(range: Range): SplitRange {\n    let start: Node;\n    if (nodeIsText(range.startContainer)) {\n        start = splitTextIfNecessary(range.startContainer, range.startOffset);\n    } else {\n        start = range.startContainer.childNodes[range.startOffset];\n    }\n\n    let end: Node;\n    if (nodeIsText(range.endContainer)) {\n        end = range.endContainer;\n        splitTextIfNecessary(range.endContainer, range.endOffset);\n    } else {\n        end = range.endContainer.childNodes[range.endOffset - 1];\n    }\n\n    return new SplitRange(start, end);\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/surround-format.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { MatchType } from \"./match-type\";\nimport type { FormattingNode } from \"./tree\";\n\nexport interface SurroundFormat<T = never> {\n    /**\n     * Determine whether element matches the format. Confirm by calling\n     * `match.remove` or `match.clear`. Sustain parameters provided to the format\n     * by calling `match.setCache`.\n     */\n    matcher: (element: HTMLElement | SVGElement, match: MatchType<T>) => void;\n    /**\n     * @returns Whether before or after are allowed to merge to a single\n     * FormattingNode range\n     */\n    merger?: (before: FormattingNode<T>, after: FormattingNode<T>) => boolean;\n    /**\n     * Apply according to this formatter.\n     *\n     * @returns Whether formatter added a new element around the range.\n     */\n    formatter?: (node: FormattingNode<T>) => boolean;\n    /**\n     * Surround with this node as formatting. Shorthand alternative to `formatter`.\n     */\n    surroundElement?: Element;\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/surround.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n// @vitest-environment jsdom\n\nimport { beforeEach, describe, expect, test } from \"vitest\";\n\nimport { surround } from \"./surround\";\nimport { easyBold, easyUnderline, p } from \"./test-utils\";\n\ndescribe(\"surround text\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"111222\");\n    });\n\n    test(\"all text\", () => {\n        const range = new Range();\n        range.selectNode(body.firstChild!);\n\n        const surroundedRange = surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"<b>111222</b>\");\n        expect(surroundedRange.toString()).toEqual(\"111222\");\n    });\n\n    test(\"first half\", () => {\n        const range = new Range();\n        range.setStart(body.firstChild!, 0);\n        range.setEnd(body.firstChild!, 3);\n\n        const surroundedRange = surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"<b>111</b>222\");\n        expect(surroundedRange.toString()).toEqual(\"111\");\n    });\n\n    test(\"second half\", () => {\n        const range = new Range();\n        range.setStart(body.firstChild!, 3);\n        range.setEnd(body.firstChild!, 6);\n\n        const surroundedRange = surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"111<b>222</b>\");\n        expect(surroundedRange.toString()).toEqual(\"222\");\n    });\n});\n\ndescribe(\"surround text next to nested\", () => {\n    describe(\"before\", () => {\n        let body: HTMLBodyElement;\n\n        beforeEach(() => {\n            body = p(\"before<u><b>after</b></u>\");\n        });\n\n        test(\"enlarges bottom tag of nested\", () => {\n            const range = new Range();\n            range.selectNode(body.firstChild!);\n            surround(range, body, easyUnderline);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<u>before<b>after</b></u>\");\n            // expect(surroundedRange.toString()).toEqual(\"before\");\n        });\n\n        test(\"moves nested down\", () => {\n            const range = new Range();\n            range.selectNode(body.firstChild!);\n            surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b>before<u>after</u></b>\");\n            // expect(surroundedRange.toString()).toEqual(\"before\");\n        });\n    });\n\n    describe(\"after\", () => {\n        let body: HTMLBodyElement;\n\n        beforeEach(() => {\n            body = p(\"<u><b>before</b></u>after\");\n        });\n\n        test(\"enlarges bottom tag of nested\", () => {\n            const range = new Range();\n            range.selectNode(body.childNodes[1]);\n            surround(range, body, easyUnderline);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<u><b>before</b>after</u>\");\n            // expect(surroundedRange.toString()).toEqual(\"after\");\n        });\n\n        test(\"moves nested down\", () => {\n            const range = new Range();\n            range.selectNode(body.childNodes[1]);\n            surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b><u>before</u>after</b>\");\n            // expect(surroundedRange.toString()).toEqual(\"after\");\n        });\n    });\n\n    describe(\"two nested\", () => {\n        let body: HTMLBodyElement;\n\n        beforeEach(() => {\n            body = p(\"aaa<i><b>bbb</b></i><i><b>ccc</b></i>\");\n        });\n\n        test(\"extends to both\", () => {\n            const range = new Range();\n            range.selectNode(body.firstChild!);\n            surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b>aaa<i>bbb</i><i>ccc</i></b>\");\n            // expect(surroundedRange.toString()).toEqual(\"aaa\");\n        });\n    });\n});\n\ndescribe(\"surround across block element\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"Before<br><ul><li>First</li><li>Second</li></ul>\");\n    });\n\n    test(\"does not insert empty elements\", () => {\n        const range = new Range();\n        range.setStartBefore(body.firstChild!);\n        range.setEndAfter(body.lastChild!);\n        const surroundedRange = surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\n            \"innerHTML\",\n            \"<b>Before</b><br><ul><li><b>First</b></li><li><b>Second</b></li></ul>\",\n        );\n        expect(surroundedRange.toString()).toEqual(\"BeforeFirstSecond\");\n    });\n});\n\ndescribe(\"next to nested\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"111<b>222<b>333<b>444</b></b></b>555\");\n    });\n\n    test(\"surround after\", () => {\n        const range = new Range();\n        range.selectNode(body.lastChild!);\n        surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"111<b>222333444555</b>\");\n        // expect(surroundedRange.toString()).toEqual(\"555\");\n    });\n});\n\ndescribe(\"next to element with nested non-matching\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"111<b>222<i>333<i>444</i></i></b>555\");\n    });\n\n    test(\"surround after\", () => {\n        const range = new Range();\n        range.selectNode(body.lastChild!);\n        surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\n            \"innerHTML\",\n            \"111<b>222<i>333<i>444</i></i>555</b>\",\n        );\n        // expect(surroundedRange.toString()).toEqual(\"555\");\n    });\n});\n\ndescribe(\"next to element with text element text\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"111<b>222<b>333</b>444</b>555\");\n    });\n\n    test(\"surround after\", () => {\n        const range = new Range();\n        range.selectNode(body.lastChild!);\n        surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"111<b>222333444555</b>\");\n        // expect(surroundedRange.toString()).toEqual(\"555\");\n    });\n});\n\ndescribe(\"surround elements that already have nested block\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"<b>1<b>2</b></b><br>\");\n    });\n\n    test(\"normalizes nodes\", () => {\n        const range = new Range();\n        range.selectNode(body.children[0]);\n\n        surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"<b>12</b><br>\");\n        // expect(surroundedRange.toString()).toEqual(\"12\");\n    });\n});\n\ndescribe(\"surround complicated nested structure\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"<i>1</i><b><i>2</i>3<i>4</i></b><i>5</i>\");\n    });\n\n    test(\"normalize nodes\", () => {\n        const range = new Range();\n        range.setStartBefore(body.firstElementChild!.firstChild!);\n        range.setEndAfter(body.lastElementChild!.firstChild!);\n\n        const surroundedRange = surround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\n            \"innerHTML\",\n            \"<b><i>1</i><i>2</i>3<i>4</i><i>5</i></b>\",\n        );\n        expect(surroundedRange.toString()).toEqual(\"12345\");\n    });\n});\n\ndescribe(\"skips over empty elements\", () => {\n    describe(\"joins two newly created\", () => {\n        let body: HTMLBodyElement;\n\n        beforeEach(() => {\n            body = p(\"before<br>after\");\n        });\n\n        test(\"normalize nodes\", () => {\n            const range = new Range();\n            range.setStartBefore(body.firstChild!);\n            range.setEndAfter(body.childNodes[2]!);\n\n            const surroundedRange = surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b>before<br>after</b>\");\n            expect(surroundedRange.toString()).toEqual(\"beforeafter\");\n        });\n    });\n\n    describe(\"joins with already existing\", () => {\n        let body: HTMLBodyElement;\n\n        beforeEach(() => {\n            body = p(\"before<br><b>after</b>\");\n        });\n\n        test(\"normalize nodes\", () => {\n            const range = new Range();\n            range.selectNode(body.firstChild!);\n\n            surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b>before<br>after</b>\");\n            // expect(surroundedRange.toString()).toEqual(\"before\");\n        });\n\n        test(\"normalize node contents\", () => {\n            const range = new Range();\n            range.selectNodeContents(body.firstChild!);\n\n            const surroundedRange = surround(range, body, easyBold);\n\n            expect(body).toHaveProperty(\"innerHTML\", \"<b>before<br>after</b>\");\n            expect(surroundedRange.toString()).toEqual(\"before\");\n        });\n    });\n});\n\n// TODO\n// describe(\"special cases when surrounding within range.commonAncestor\", () => {\n//     // these are not vital but rather define how the algorithm works in edge cases\n\n//     test(\"does not normalize beyond level of contained text nodes\", () => {\n//         const body = p(\"<b>before<u>nested</u>after</b>\");\n//         const range = new Range();\n//         range.selectNode(body.firstChild!.childNodes[1].firstChild!);\n\n//         const { addedNodes, removedNodes, surroundedRange } = surround(\n//             range,\n//             body,\n//             easyBold,\n//         );\n\n//         expect(addedNodes).toHaveLength(1);\n//         expect(removedNodes).toHaveLength(0);\n//         expect(body).toHaveProperty(\n//             \"innerHTML\",\n//             \"<b>before<b><u>nested</u></b>after</b>\",\n//         );\n//         expect(surroundedRange.toString()).toEqual(\"nested\");\n//     });\n\n//     test(\"does not normalize beyond level of contained text nodes 2\", () => {\n//         const body = p(\"<b>aaa<b>bbb</b><b>ccc</b></b>\");\n//         const range = new Range();\n//         range.setStartBefore(body.firstChild!.firstChild!);\n//         range.setEndAfter(body.firstChild!.childNodes[1].firstChild!);\n\n//         const { addedNodes, removedNodes } = surround(range, body, easyBold);\n\n//         expect(body).toHaveProperty(\"innerHTML\", \"<b><b>aaabbbccc</b></b>\");\n//         expect(addedNodes).toHaveLength(1);\n//         expect(removedNodes).toHaveLength(2);\n//         // expect(surroundedRange.toString()).toEqual(\"aaabbb\"); // is aaabbbccc instead\n//     });\n\n//     test(\"does normalize beyond level of contained text nodes\", () => {\n//         const body = p(\"<b><b>aaa</b><b><b>bbb</b><b>ccc</b></b></b>\");\n//         const range = new Range();\n//         range.setStartBefore(body.firstChild!.childNodes[1].firstChild!.firstChild!);\n//         range.setEndAfter(body.firstChild!.childNodes[1].childNodes[1].firstChild!);\n\n//         const { addedNodes, removedNodes } = surround(range, body, easyBold);\n\n//         expect(body).toHaveProperty(\"innerHTML\", \"<b><b>aaabbbccc</b></b>\");\n//         expect(addedNodes).toHaveLength(1);\n//         expect(removedNodes).toHaveLength(4);\n//         // expect(surroundedRange.toString()).toEqual(\"aaabbb\"); // is aaabbbccc instead\n//     });\n\n//     test(\"does remove even if there is already equivalent surrounding in place\", () => {\n//         const body = p(\"<b>before<b><u>nested</u></b>after</b>\");\n//         const range = new Range();\n//         range.selectNode(body.firstChild!.childNodes[1].firstChild!.firstChild!);\n\n//         const { addedNodes, removedNodes, surroundedRange } = surround(\n//             range,\n//             body,\n//             easyBold,\n//         );\n\n//         expect(addedNodes).toHaveLength(1);\n//         expect(removedNodes).toHaveLength(1);\n//         expect(body).toHaveProperty(\n//             \"innerHTML\",\n//             \"<b>before<b><u>nested</u></b>after</b>\",\n//         );\n//         expect(surroundedRange.toString()).toEqual(\"nested\");\n//     });\n// });\n"
  },
  {
    "path": "ts/lib/domlib/surround/surround.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Matcher } from \"../find-above\";\nimport { findFarthest } from \"../find-above\";\nimport { apply, ApplyFormat, ReformatApplyFormat, UnsurroundApplyFormat } from \"./apply\";\nimport { build, BuildFormat, ReformatBuildFormat, UnsurroundBuildFormat } from \"./build\";\nimport { boolMatcher } from \"./match-type\";\nimport { splitPartiallySelected } from \"./split-text\";\nimport type { SurroundFormat } from \"./surround-format\";\n\nfunction buildAndApply<T>(\n    node: Node,\n    buildFormat: BuildFormat<T>,\n    applyFormat: ApplyFormat<T>,\n): Range {\n    const forest = build(node, buildFormat);\n    apply(forest, applyFormat);\n    return buildFormat.recreateRange();\n}\n\nfunction surroundOnCorrectNode<T>(\n    range: Range,\n    base: Element,\n    build: BuildFormat<T>,\n    apply: ApplyFormat<T>,\n    matcher: Matcher,\n): Range {\n    const node = findFarthest(\n        range.commonAncestorContainer,\n        base,\n        matcher,\n    ) ?? range.commonAncestorContainer;\n\n    return buildAndApply(node, build, apply);\n}\n\n/**\n * Will surround the entire range, removing any contained formatting nodes in the process.\n */\nexport function surround<T>(\n    range: Range,\n    base: Element,\n    format: SurroundFormat<T>,\n): Range {\n    const splitRange = splitPartiallySelected(range);\n    const build = new BuildFormat(format, base, range, splitRange);\n    const apply = new ApplyFormat(format);\n    return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format));\n}\n\n/**\n * Will not surround any unsurrounded text nodes in the range.\n */\nexport function reformat<T>(\n    range: Range,\n    base: Element,\n    format: SurroundFormat<T>,\n): Range {\n    const splitRange = splitPartiallySelected(range);\n    const build = new ReformatBuildFormat(format, base, range, splitRange);\n    const apply = new ReformatApplyFormat(format);\n    return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format));\n}\n\nexport function unsurround<T>(\n    range: Range,\n    base: Element,\n    format: SurroundFormat<T>,\n): Range {\n    const splitRange = splitPartiallySelected(range);\n    const build = new UnsurroundBuildFormat(format, base, range, splitRange);\n    const apply = new UnsurroundApplyFormat(format);\n    return surroundOnCorrectNode(range, base, build, apply, boolMatcher(format));\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/test-utils.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { MatchType } from \"./match-type\";\n\nexport const matchTagName = (tagName: string) => <T>(element: Element, match: MatchType<T>): void => {\n    if (element.matches(tagName)) {\n        match.remove();\n    }\n};\n\nexport const easyBold = {\n    surroundElement: document.createElement(\"b\"),\n    matcher: matchTagName(\"b\"),\n};\n\nexport const easyItalic = {\n    surroundElement: document.createElement(\"i\"),\n    matcher: matchTagName(\"i\"),\n};\n\nexport const easyUnderline = {\n    surroundElement: document.createElement(\"u\"),\n    matcher: matchTagName(\"u\"),\n};\n\nconst parser = new DOMParser();\n\nexport function p(html: string): HTMLBodyElement {\n    const parsed = parser.parseFromString(html, \"text/html\");\n    return parsed.body as HTMLBodyElement;\n}\n\nexport function t(data: string): Text {\n    return document.createTextNode(data);\n}\n\nfunction element(tagName: string): (...childNodes: Node[]) => HTMLElement {\n    return function(...childNodes: Node[]): HTMLElement {\n        const element = document.createElement(tagName);\n        element.append(...childNodes);\n        return element;\n    };\n}\n\nexport const b = element(\"b\");\nexport const i = element(\"i\");\nexport const u = element(\"u\");\nexport const span = element(\"span\");\nexport const div = element(\"div\");\n"
  },
  {
    "path": "ts/lib/domlib/surround/tree/block-node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { TreeNode } from \"./tree-node\";\n\n/**\n * Its purpose is to block adjacent FormattingNodes from merging, or single\n * FormattingNodes from trying to ascend.\n */\nexport class BlockNode extends TreeNode {\n    private constructor() {\n        super(false);\n    }\n\n    static make(): BlockNode {\n        return new BlockNode();\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/tree/element-node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { TreeNode } from \"./tree-node\";\n\nexport class ElementNode extends TreeNode {\n    private constructor(\n        public readonly element: Element,\n        public readonly insideRange: boolean,\n    ) {\n        super(insideRange);\n    }\n\n    static make(element: Element, insideRange: boolean): ElementNode {\n        return new ElementNode(element, insideRange);\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/tree/formatting-node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { nodeIsElement } from \"@tslib/dom\";\n\nimport { FlatRange } from \"../flat-range\";\nimport type { Match } from \"../match-type\";\nimport { ElementNode } from \"./element-node\";\nimport { TreeNode } from \"./tree-node\";\n\n/**\n * Represents a potential insertion point for a tag or, more generally, a point for starting a format procedure.\n */\nexport class FormattingNode<T = never> extends TreeNode {\n    private constructor(\n        public readonly range: FlatRange,\n        public readonly insideRange: boolean,\n        /**\n         * Match ancestors are all matching matches that are direct ancestors\n         * of `this`. This is important for deciding whether a text node is\n         * turned into a FormattingNode or into a BlockNode, if it is outside\n         * the initial DOM range.\n         */\n        public readonly matchAncestors: Match<T>[],\n    ) {\n        super(insideRange);\n    }\n\n    private static make<T>(\n        range: FlatRange,\n        insideRange: boolean,\n        matchAncestors: Match<T>[],\n    ): FormattingNode<T> {\n        return new FormattingNode(range, insideRange, matchAncestors);\n    }\n\n    static fromText<T>(\n        text: Text,\n        insideRange: boolean,\n        matchAncestors: Match<T>[],\n    ): FormattingNode<T> {\n        return FormattingNode.make(\n            FlatRange.fromNode(text),\n            insideRange,\n            matchAncestors,\n        );\n    }\n\n    /**\n     * A merge is combinging two formatting nodes into a single one.\n     * The merged node will take over their children, their match leaves, and\n     * their match holes, but will drop their extensions.\n     *\n     * @example\n     * Practically speaking, it is what happens, when you combine:\n     * `<b>before</b><b>after</b>` into `<b>beforeafter</b>`, or\n     * `<b>before</b><img src=\"image.jpg\"><b>after</b>` into\n     * `<b>before<img src=\"image.jpg\">after</b>` (negligible nodes inbetween).\n     */\n    static merge<T>(\n        before: FormattingNode<T>,\n        after: FormattingNode<T>,\n    ): FormattingNode<T> {\n        const node = FormattingNode.make(\n            FlatRange.merge(before.range, after.range),\n            before.insideRange && after.insideRange,\n            before.matchAncestors,\n        );\n\n        node.replaceChildren(...before.children, ...after.children);\n        node.matchLeaves.push(...before.matchLeaves, ...after.matchLeaves);\n        node.hasMatchHoles = before.hasMatchHoles || after.hasMatchHoles;\n\n        return node;\n    }\n\n    toString(): string {\n        return this.range.toString();\n    }\n\n    /**\n     * An ascent is placing a FormattingNode above an ElementNode.\n     * This happens, when the element node is an extension to the formatting node.\n     *\n     * @param elementNode: Its children will be discarded in favor of `this`s\n     * children.\n     *\n     * @example\n     * Practically speaking, it is what happens, when you turn:\n     * `<u><b>inside</b></u>` into `<b><u>inside</u></b>`, or\n     * `<u><b>inside</b><img src=\"image.jpg\"></u>` into `<b><u>inside<img src=\"image.jpg\"></u></b>\n     */\n    ascendAbove(elementNode: ElementNode): void {\n        this.range.select(elementNode.element);\n        this.extensions.push(elementNode.element as HTMLElement | SVGElement);\n\n        if (!this.hasChildren()) {\n            // Drop elementNode, as it has no effect\n            return;\n        }\n\n        elementNode.replaceChildren(...this.replaceChildren(elementNode));\n    }\n\n    /**\n     * Extending only makes sense, if it is following by a FormattingNode\n     * ascending above it.\n     * Which is why if the match node is not ascendable, we might as well\n     * stop extending.\n     *\n     * @returns Whether formatting node ascended at least one level\n     */\n    getExtension(): ElementNode | null {\n        const node = this.range.parent;\n\n        if (nodeIsElement(node)) {\n            return ElementNode.make(node, this.insideRange);\n        }\n\n        return null;\n    }\n\n    // The following methods are meant for users when specifying their surround\n    // formats and is not vital to the algorithm itself\n\n    /**\n     * Match leaves are the matching elements that are/were descendants of\n     * `this`. This makes them the element nodes, which actually affect text\n     * nodes located inside `this`.\n     *\n     * @example\n     * If we are surrounding with bold, then in this case:\n     * `<b><b>first</b><b>second</b></b>\n     * The inner b tags are match leaves, but the outer b tag is not, because\n     * it does affect any text nodes.\n     *\n     * @remarks\n     * These are important for mergers.\n     */\n    matchLeaves: Match<T>[] = [];\n\n    get firstLeaf(): Match<T> | null {\n        if (this.matchLeaves.length === 0) {\n            return null;\n        }\n\n        return this.matchLeaves[0];\n    }\n\n    /**\n     * Match holes are text nodes which are descendants of `this`, but are not\n     * descendants of any match leaves of `this`.\n     */\n    hasMatchHoles = true;\n\n    get closestAncestor(): Match<T> | null {\n        if (this.matchAncestors.length === 0) {\n            return null;\n        }\n\n        return this.matchAncestors[this.matchAncestors.length - 1];\n    }\n\n    /**\n     * Extensions of formatting nodes with a single element contained in their\n     * range are direct exclusive descendant elements of this element.\n     * Extensions are sorted in tree order.\n     *\n     * @example\n     * When surrounding \"inside\" with a bold format in the following case:\n     * `<span class=\"myclass\"><em>inside</em></span>`\n     * The formatting node would sit above the span (it ascends above both\n     * the em and the span tag), and its extensions are the span tag and the\n     * em tag (in this order).\n     *\n     * @example\n     * When a format only wants to add a class, it would typically look for an\n     * extension first. When applying class=\"myclass\" to \"inside\" in the\n     * following case:\n     * `<em><span style=\"color: rgb(255, 0, 0)\"><b>inside</b></span></em>`\n     * It should typically become:\n     * `<em><span class=\"myclass\" style=\"color: rgb(255, 0, 0)\"><b>inside</b></span></em>`\n     */\n    extensions: (HTMLElement | SVGElement)[] = [];\n\n    /**\n     * @param insideValue: The value that should be returned, if the formatting\n     * node is inside the original range. If the node is not inside the original\n     * range, the cache of the first leaf, or the closest match ancestor will be\n     * returned.\n     */\n    getCache(insideValue: T): T | null {\n        if (this.insideRange) {\n            return insideValue;\n        } else if (this.firstLeaf) {\n            return this.firstLeaf.cache;\n        } else if (this.closestAncestor) {\n            return this.closestAncestor.cache;\n        }\n\n        // Should never happen, as a formatting node is always either\n        // inside a range or inside a match\n        return null;\n    }\n\n    /**\n     * Whether the text nodes in this formatting node are affected by any match.\n     * This can only be false, if `insideRange` is true (otherwise it would have\n     * become a BlockNode).\n     */\n    get hasMatch(): boolean {\n        return this.matchLeaves.length > 0 || this.matchAncestors.length > 0;\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/tree/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport { BlockNode } from \"./block-node\";\nexport { ElementNode } from \"./element-node\";\nexport { FormattingNode } from \"./formatting-node\";\nexport type { TreeNode } from \"./tree-node\";\n"
  },
  {
    "path": "ts/lib/domlib/surround/tree/tree-node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport abstract class TreeNode {\n    readonly children: TreeNode[] = [];\n\n    protected constructor(\n        /**\n         * Whether all text nodes within this node are inside the initial DOM range.\n         */\n        public insideRange: boolean,\n    ) {}\n\n    /**\n     * @returns Children which were replaced.\n     */\n    replaceChildren(...newChildren: TreeNode[]): TreeNode[] {\n        return this.children.splice(0, this.length, ...newChildren);\n    }\n\n    hasChildren(): boolean {\n        return this.children.length > 0;\n    }\n\n    get length(): number {\n        return this.children.length;\n    }\n}\n"
  },
  {
    "path": "ts/lib/domlib/surround/unsurround.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n// @vitest-environment jsdom\n\nimport { beforeEach, describe, expect, test } from \"vitest\";\n\nimport { unsurround } from \"./surround\";\nimport { easyBold, p } from \"./test-utils\";\n\ndescribe(\"unsurround text\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"<b>test</b>\");\n    });\n\n    test(\"normalizes nodes\", () => {\n        const range = new Range();\n        range.selectNode(body.firstChild!);\n\n        unsurround(range, body, easyBold);\n        expect(body).toHaveProperty(\"innerHTML\", \"test\");\n        // expect(surroundedRange.toString()).toEqual(\"test\");\n    });\n});\n\n// describe(\"unsurround element and text\", () => {\n//     let body: HTMLBodyElement;\n\n//     beforeEach(() => {\n//         body = p(\"<b>before</b>after\");\n//     });\n\n//     test(\"normalizes nodes\", () => {\n//         const range = new Range();\n//         range.setStartBefore(body.childNodes[0].firstChild!);\n//         range.setEndAfter(body.childNodes[1]);\n\n//         const surroundedRange = unsurround(range, body, easyBold);\n\n//         expect(body).toHaveProperty(\"innerHTML\", \"beforeafter\");\n//         expect(surroundedRange.toString()).toEqual(\"beforeafter\");\n//     });\n// });\n\ndescribe(\"unsurround element with surrounding text\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"11<b>22</b>33\");\n    });\n\n    test(\"normalizes nodes\", () => {\n        const range = new Range();\n        range.selectNode(body.firstElementChild!);\n\n        unsurround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"112233\");\n        // expect(surroundedRange.toString()).toEqual(\"22\");\n    });\n});\n\n// describe(\"unsurround from one element to another\", () => {\n//     let body: HTMLBodyElement;\n\n//     beforeEach(() => {\n//         body = p(\"<b>111</b>222<b>333</b>\");\n//     });\n\n//     test(\"unsurround whole\", () => {\n//         const range = new Range();\n//         range.setStartBefore(body.children[0].firstChild!);\n//         range.setEndAfter(body.children[1].firstChild!);\n\n//         unsurround(range, body, easyBold);\n\n//         expect(body).toHaveProperty(\"innerHTML\", \"111222333\");\n//         // expect(surroundedRange.toString()).toEqual(\"22\");\n//     });\n// });\n\n// describe(\"unsurround text portion of element\", () => {\n//     let body: HTMLBodyElement;\n\n//     beforeEach(() => {\n//         body = p(\"<b>112233</b>\");\n//     });\n\n//     test(\"normalizes nodes\", () => {\n//         const range = new Range();\n//         range.setStart(body.firstChild!, 2);\n//         range.setEnd(body.firstChild!, 4);\n\n//         const { addedNodes, removedNodes } = unsurround(\n//             range,\n//             document.createElement(\"b\"),\n//             body,\n//         );\n\n//         expect(addedNodes).toHaveLength(2);\n//         expect(removedNodes).toHaveLength(1);\n//         expect(body).toHaveProperty(\"innerHTML\", \"<b>11</b>22<b>33</b>\");\n//         // expect(surroundedRange.toString()).toEqual(\"22\");\n//     });\n// });\n\ndescribe(\"with bold around block item\", () => {\n    let body: HTMLBodyElement;\n\n    beforeEach(() => {\n        body = p(\"<b>111<br><ul><li>222</li></ul></b>\");\n    });\n\n    test(\"unsurround list item\", () => {\n        const range = new Range();\n        range.selectNodeContents(\n            body.firstChild!.childNodes[2].firstChild!.firstChild!,\n        );\n\n        unsurround(range, body, easyBold);\n\n        expect(body).toHaveProperty(\"innerHTML\", \"<b>111</b><br><ul><li>222</li></ul>\");\n        // expect(surroundedRange.toString()).toEqual(\"222\");\n    });\n});\n\ndescribe(\"with two double nested and one single nested\", () => {\n    // test(\"unsurround one double and single nested\", () => {\n    //     const body = p(\"<b><b>aaa</b><b>bbb</b>ccc</b>\");\n    //     const range = new Range();\n    //     range.setStartBefore(body.firstChild!.childNodes[1].firstChild!);\n    //     range.setEndAfter(body.firstChild!.childNodes[2]);\n\n    //     const surroundedRange = unsurround(\n    //         range,\n    //         body,\n    //         easyBold,\n    //     );\n\n    //     expect(body).toHaveProperty(\"innerHTML\", \"<b>aaa</b>bbbccc\");\n    //     expect(surroundedRange.toString()).toEqual(\"bbbccc\");\n    // });\n\n    test(\"unsurround single and one double nested\", () => {\n        const body = p(\"<b>aaa<b>bbb</b><b>ccc</b></b>\");\n        const range = new Range();\n        range.setStartBefore(body.firstChild!.firstChild!);\n        range.setEndAfter(body.firstChild!.childNodes[1].firstChild!);\n\n        const surroundedRange = unsurround(range, body, easyBold);\n        expect(body).toHaveProperty(\"innerHTML\", \"aaabbb<b>ccc</b>\");\n        expect(surroundedRange.toString()).toEqual(\"aaabbb\");\n    });\n});\n"
  },
  {
    "path": "ts/lib/generated/README.md",
    "content": "Files in this folder get combined with generated files in out/ts/lib/generated/\n"
  },
  {
    "path": "ts/lib/generated/ftl-helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { FluentBundle, FluentVariable } from \"@fluent/bundle\";\nimport { FluentNumber } from \"@fluent/bundle\";\n\nlet bundles: FluentBundle[] = [];\n\nexport function setBundles(newBundles: FluentBundle[]): void {\n    bundles = newBundles;\n}\n\nexport function firstLanguage(): string {\n    return bundles[0].locales[0];\n}\n\nexport function translate(key: string, args: Record<string, FluentVariable> = {}) {\n    return getMessage(key, args) ?? `missing key: ${key}`;\n}\n\nfunction toFluentNumber(num: number): FluentNumber {\n    return new FluentNumber(num, {\n        maximumFractionDigits: 2,\n    });\n}\n\nfunction formatArgs(\n    args: Record<string, FluentVariable>,\n): Record<string, FluentVariable> {\n    const entries: [string, FluentVariable][] = Object.entries(args).map(\n        ([key, value]) => {\n            return [\n                key,\n                typeof value === \"number\" ? toFluentNumber(value) : value,\n            ];\n        },\n    );\n    const out: Record<string, FluentVariable> = {};\n    for (const [key, value] of entries) {\n        out[key] = value;\n    }\n    return out;\n}\n\nfunction getMessage(\n    key: string,\n    args: Record<string, FluentVariable> = {},\n): string | null {\n    for (const bundle of bundles) {\n        const msg = bundle.getMessage(key);\n        if (msg && msg.value) {\n            return bundle.formatPattern(msg.value, formatArgs(args));\n        }\n    }\n\n    return null;\n}\n"
  },
  {
    "path": "ts/lib/generated/post.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport interface PostProtoOptions {\n    /** True by default. Shows a dialog with the error message, then rethrows. */\n    alertOnError?: boolean;\n}\n\nexport async function postProto<T>(\n    method: string,\n    input: { toBinary(): Uint8Array; getType(): { typeName: string } },\n    outputType: { fromBinary(arr: Uint8Array): T },\n    options: PostProtoOptions = {},\n): Promise<T> {\n    try {\n        const inputBytes = input.toBinary();\n        const path = `/_anki/${method}`;\n        const outputBytes = await postProtoInner(path, inputBytes);\n        return outputType.fromBinary(outputBytes);\n    } catch (err) {\n        const { alertOnError = true } = options;\n        if (alertOnError && !(err instanceof Error && err.message === \"500: Interrupted\")) {\n            alert(err);\n        }\n        throw err;\n    }\n}\n\nasync function postProtoInner(url: string, body: Uint8Array): Promise<Uint8Array> {\n    const result = await fetch(url, {\n        method: \"POST\",\n        headers: {\n            \"Content-Type\": \"application/binary\",\n        },\n        body,\n    });\n    if (!result.ok) {\n        let msg = \"something went wrong\";\n        try {\n            msg = await result.text();\n        } catch {\n            // ignore\n        }\n        throw new Error(`${result.status}: ${msg}`);\n    }\n    const blob = await result.blob();\n    const respBuf = await new Response(blob).arrayBuffer();\n    return new Uint8Array(respBuf);\n}\n"
  },
  {
    "path": "ts/lib/sass/_button-mixins.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n@use \"vars\";\n@use \"sass:color\";\n@use \"./elevation\" as *;\n\n@import \"bootstrap/scss/functions\";\n@import \"bootstrap/scss/variables\";\n\n@mixin impressed-shadow($intensity) {\n    box-shadow: inset 0 calc(var(--buttons-size, 10px) / 15)\n        calc(var(--buttons-size, 10px) / 5) rgba(black, $intensity);\n}\n\n@mixin border-radius {\n    border-top-left-radius: var(--border-left-radius);\n    border-bottom-left-radius: var(--border-left-radius);\n\n    border-top-right-radius: var(--border-right-radius);\n    border-bottom-right-radius: var(--border-right-radius);\n}\n\n@mixin background($primary: false, $hover: true) {\n    @if $primary {\n        background: var(--button-primary-bg);\n        @if $hover {\n            &:hover {\n                background: linear-gradient(\n                    180deg,\n                    var(--button-primary-gradient-start) 0%,\n                    var(--button-primary-gradient-end) 100%\n                );\n            }\n        }\n    } @else {\n        background: var(--button-bg);\n        @if $hover {\n            &:hover {\n                background: linear-gradient(\n                    180deg,\n                    var(--button-gradient-start) 0%,\n                    var(--button-gradient-end) 100%\n                );\n                /* Makes distinguishing hover state in light theme easier */\n                border: 1px solid var(--shadow);\n            }\n        }\n    }\n}\n\n@mixin base(\n    $primary: false,\n    $border: true,\n    $with-hover: true,\n    $with-active: true,\n    $active-class: \"\",\n    $with-disabled: true\n) {\n    -webkit-appearance: none;\n    cursor: pointer;\n    @if $border {\n        @if $primary {\n            border: none;\n        } @else {\n            border: 1px solid var(--border-subtle);\n            border-bottom-color: var(--shadow);\n        }\n    } @else {\n        border: none;\n    }\n    @include background($primary, $hover: $with-hover);\n\n    @if ($primary) {\n        color: white;\n    } @else {\n        color: var(--fg);\n    }\n\n    @if ($with-active) {\n        &:active {\n            @include impressed-shadow(0.35);\n            border-color: var(--border-subtle);\n        }\n        @if ($active-class != \"\") {\n            &.#{$active-class} {\n                @include impressed-shadow(0.35);\n                background: var(--button-primary-bg);\n                color: white;\n                border-color: var(--border);\n            }\n        }\n    }\n\n    @if ($with-disabled) {\n        &[disabled],\n        &[disabled]:hover {\n            cursor: not-allowed;\n            color: var(--fg-disabled);\n            box-shadow: none !important;\n            background-color: var(--button-gradient-end);\n            border-bottom-color: var(--border-subtle);\n        }\n    }\n}\n\n$focus-color: var(--shadow-focus);\n\n@mixin select($with-disabled: true) {\n    width: 100%;\n\n    pointer-events: all;\n    cursor: pointer;\n\n    @include base($with-disabled: $with-disabled);\n\n    border-radius: var(--border-radius);\n\n    &.rtl {\n        direction: rtl;\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/_color-palette.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html \n *\n * Anki Color Palette\n * custom gray, rest from Tailwind CSS v3 palette\n * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */\n\n$color-palette: (\n    lightgray: (\n        0: #fcfcfc,\n        1: #fafafa,\n        2: #f5f5f5,\n        3: #eeeeee,\n        4: #e4e4e4,\n        5: #d6d6d6,\n        6: #c4c4c4,\n        7: #afafaf,\n        8: #999999,\n        9: #858585,\n    ),\n    darkgray: (\n        0: #737373,\n        1: #636363,\n        2: #545454,\n        3: #454545,\n        4: #363636,\n        5: #2c2c2c,\n        6: #252525,\n        7: #202020,\n        8: #141414,\n        9: #020202,\n    ),\n    red: (\n        0: #fef2f2,\n        1: #fee2e2,\n        2: #fecaca,\n        3: #fca5a5,\n        4: #f87171,\n        5: #ef4444,\n        6: #dc2626,\n        7: #b91c1c,\n        8: #991b1b,\n        9: #7f1d1d,\n    ),\n    orange: (\n        0: #fff7ed,\n        1: #ffedd5,\n        2: #fed7aa,\n        3: #fdba74,\n        4: #fb923c,\n        5: #f97316,\n        6: #ea580c,\n        7: #c2410c,\n        8: #9a3412,\n        9: #7c2d12,\n    ),\n    amber: (\n        0: #fffbeb,\n        1: #fef3c7,\n        2: #fde68a,\n        3: #fcd34d,\n        4: #fbbf24,\n        5: #f59e0b,\n        6: #d97706,\n        7: #b45309,\n        8: #92400e,\n        9: #78350f,\n    ),\n    yellow: (\n        0: #fefce8,\n        1: #fef9c3,\n        2: #fef08a,\n        3: #fde047,\n        4: #facc15,\n        5: #eab308,\n        6: #ca8a04,\n        7: #a16207,\n        8: #854d0e,\n        9: #713f12,\n    ),\n    lime: (\n        0: #f7fee7,\n        1: #ecfccb,\n        2: #d9f99d,\n        3: #bef264,\n        4: #a3e635,\n        5: #84cc16,\n        6: #65a30d,\n        7: #4d7c0f,\n        8: #3f6212,\n        9: #365314,\n    ),\n    green: (\n        0: #f0fdf4,\n        1: #dcfce7,\n        2: #bbf7d0,\n        3: #86efac,\n        4: #4ade80,\n        5: #22c55e,\n        6: #16a34a,\n        7: #15803d,\n        8: #166534,\n        9: #14532d,\n    ),\n    teal: (\n        0: #f0fdfa,\n        1: #ccfbf1,\n        2: #99f6e4,\n        3: #5eead4,\n        4: #2dd4bf,\n        5: #14b8a6,\n        6: #0d9488,\n        7: #0f766e,\n        8: #115e59,\n        9: #134e4a,\n    ),\n    cyan: (\n        0: #ecfeff,\n        1: #cffafe,\n        2: #a5f3fc,\n        3: #67e8f9,\n        4: #22d3ee,\n        5: #06b6d4,\n        6: #0891b2,\n        7: #0e7490,\n        8: #155e75,\n        9: #164e63,\n    ),\n    sky: (\n        0: #f0f9ff,\n        1: #e0f2fe,\n        2: #bae6fd,\n        3: #7dd3fc,\n        4: #38bdf8,\n        5: #0ea5e9,\n        6: #0284c7,\n        7: #0369a1,\n        8: #075985,\n        9: #0c4a6e,\n    ),\n    blue: (\n        0: #eff6ff,\n        1: #dbeafe,\n        2: #bfdbfe,\n        3: #93c5fd,\n        4: #60a5fa,\n        5: #3b82f6,\n        6: #2563eb,\n        7: #1d4ed8,\n        8: #1e40af,\n        9: #1e3a8a,\n    ),\n    indigo: (\n        0: #eef2ff,\n        1: #e0e7ff,\n        2: #c7d2fe,\n        3: #a5b4fc,\n        4: #818cf8,\n        5: #6366f1,\n        6: #4f46e5,\n        7: #4338ca,\n        8: #3730a3,\n        9: #312e81,\n    ),\n    violet: (\n        0: #f5f3ff,\n        1: #ede9fe,\n        2: #ddd6fe,\n        3: #c4b5fd,\n        4: #a78bfa,\n        5: #8b5cf6,\n        6: #7c3aed,\n        7: #6d28d9,\n        8: #5b21b6,\n        9: #4c1d95,\n    ),\n    purple: (\n        0: #faf5ff,\n        1: #f3e8ff,\n        2: #e9d5ff,\n        3: #d8b4fe,\n        4: #c084fc,\n        5: #a855f7,\n        6: #9333ea,\n        7: #7e22ce,\n        8: #6b21a8,\n        9: #581c87,\n    ),\n    fuchsia: (\n        0: #fdf4ff,\n        1: #fae8ff,\n        2: #f5d0fe,\n        3: #f0abfc,\n        4: #e879f9,\n        5: #d946ef,\n        6: #c026d3,\n        7: #a21caf,\n        8: #86198f,\n        9: #701a75,\n    ),\n    pink: (\n        0: #fdf2f8,\n        1: #fce7f3,\n        2: #fbcfe8,\n        3: #f9a8d4,\n        4: #f472b6,\n        5: #ec4899,\n        6: #db2777,\n        7: #be185d,\n        8: #9d174d,\n        9: #831843,\n    ),\n);\n"
  },
  {
    "path": "ts/lib/sass/_functions.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"sass:map\";\n@use \"sass:list\";\n\n@function create-vars-from-map($map, $theme, $name: \"-\", $output: ()) {\n    @each $key, $value in $map {\n        @if $key ==\n            $theme or\n            (\n                $key ==\n                    \"default\" and\n                    type-of($value) !=\n                    \"map\" and\n                    type-of($value) !=\n                    \"list\"\n            )\n        {\n            @return map.set($output, $name, map.get($map, $key));\n        }\n        @if type-of($value) == \"map\" {\n            @if $key == \"default\" {\n                $output: map-merge(\n                    $output,\n                    create-vars-from-map($value, $theme, #{$name}, $output)\n                );\n            } @else {\n                $output: map-merge(\n                    $output,\n                    create-vars-from-map($value, $theme, #{$name}-#{$key}, $output)\n                );\n            }\n        } @else if type-of($value) == \"list\" and list.length($value) > 1 {\n            $next-name: #{$name}-#{$key};\n            @if $key == \"default\" {\n                $next-name: $name;\n            }\n            $output: map-merge(\n                $output,\n                (#{\"comment\"}#{$next-name}: list.nth($value, 1))\n            );\n            $output: map-merge(\n                $output,\n                create-vars-from-map(\n                    list.nth($value, 2),\n                    $theme,\n                    #{$next-name},\n                    $output\n                )\n            );\n        }\n    }\n    @return $output;\n}\n\n@function map-deep-get($map, $keys) {\n    @each $key in $keys {\n        @if type-of($map) == \"list\" and list.length($map) > 1 {\n            $map: map-get(list.nth($map, 2), $key);\n        } @else {\n            $map: map-get($map, $key);\n        }\n    }\n    @return $map;\n}\n\n@function get-value-from-map($map, $keyword, $theme, $keys: ()) {\n    $i: str-index($keyword, \"-\");\n\n    @if $i {\n        @while $i {\n            $sub: str-slice($keyword, 0, $i - 1);\n\n            @if list.length($keys) == 0 {\n                $keys: ($sub);\n            } @else {\n                $keys: list.append($keys, $sub);\n            }\n            $keyword: str-slice($keyword, $i + 1, -1);\n            $i: str-index($keyword, \"-\");\n        }\n    }\n    $keys: list.join($keys, ($keyword, $theme));\n\n    @return map-deep-get($map, $keys);\n}\n"
  },
  {
    "path": "ts/lib/sass/_root-vars.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html */\n\n@use \"sass:map\";\n@use \"vars\" as *;\n@use \"functions\" as *;\n@use \"scrollbar\";\n\n/*! colors */\n:root {\n    $colors: map.get($vars, colors);\n\n    @each $name, $val in create-vars-from-map($colors, light) {\n        @if str-index($name, \"comment\") == 1 {\n            /*! #{$val} */\n        } @else {\n            #{$name}: #{$val};\n        }\n    }\n    color-scheme: light;\n\n    &.night-mode {\n        @each $name, $val in create-vars-from-map($colors, dark) {\n            @if str-index($name, \"comment\") == 1 {\n                /*! #{$val} */\n            } @else {\n                #{$name}: #{$val};\n            }\n        }\n        color-scheme: dark;\n    }\n}\n\n/*! props */\n:root {\n    $props: map.get($vars, props);\n    @each $name, $val in create-vars-from-map($props, light) {\n        @if str-index($name, \"comment\") == 1 {\n            /*! #{$val} */\n        } @else {\n            #{$name}: #{$val};\n        }\n    }\n    &.night-mode {\n        @each $name, $val in create-vars-from-map($props, dark) {\n            @if str-index($name, \"comment\") == 1 {\n                /*! #{$val} */\n            } @else {\n                #{$name}: #{$val};\n            }\n        }\n    }\n}\n\n/*! rest */\nhtml {\n    font-size: prop(font-size);\n    body {\n        overscroll-behavior: none;\n        &:not(.isMac),\n        &:not(.isMac) * {\n            @include scrollbar.custom;\n        }\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/_vars.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later, http://www.gnu.org/licenses/agpl.html */\n\n@use \"sass:map\";\n@use \"sass:color\";\n@use \"functions\" as *;\n@use \"color-palette\" as *;\n\n@function palette($key, $shade) {\n    $color: map.get($color-palette, $key);\n    @return map.get($color, $shade);\n}\n\n$vars: (\n    props: (\n        font: (\n            size: (\n                default: 15px,\n            ),\n        ),\n        border-radius: (\n            default: (\n                \"Used to round corners of various UI elements\",\n                (\n                    default: 5px,\n                ),\n            ),\n            medium: (\n                \"Used for container corners\",\n                (\n                    default: 12px,\n                ),\n            ),\n            large: (\n                \"Used for pill-shaped buttons\",\n                (\n                    default: 15px,\n                ),\n            ),\n        ),\n        transition: (\n            default: (\n                \"Default duration of transitions in milliseconds\",\n                (\n                    default: 180ms,\n                ),\n            ),\n            medium: (\n                \"Slightly longer transition duration in milliseconds\",\n                (\n                    default: 500ms,\n                ),\n            ),\n            slow: (\n                \"Long transition duration in milliseconds\",\n                (\n                    default: 1000ms,\n                ),\n            ),\n        ),\n        blur: (\n            default: (\n                \"Default background blur value\",\n                (\n                    default: 20px,\n                ),\n            ),\n        ),\n    ),\n    colors: (\n        fg: (\n            default: (\n                \"Default text/icon color\",\n                (\n                    light: palette(darkgray, 9),\n                    dark: palette(lightgray, 0),\n                ),\n            ),\n            subtle: (\n                \"Placeholder text, icons in idle state\",\n                (\n                    light: palette(darkgray, 0),\n                    dark: palette(lightgray, 9),\n                ),\n            ),\n            disabled: (\n                \"Foreground color of disabled UI elements\",\n                (\n                    light: palette(lightgray, 9),\n                    dark: palette(darkgray, 0),\n                ),\n            ),\n            faint: (\n                \"Foreground color that barely stands out against canvas\",\n                (\n                    light: palette(lightgray, 7),\n                    dark: palette(darkgray, 2),\n                ),\n            ),\n            link: (\n                \"Hyperlink foreground color\",\n                (\n                    light: palette(blue, 7),\n                    dark: palette(blue, 2),\n                ),\n            ),\n        ),\n        canvas: (\n            default: (\n                \"Window background\",\n                (\n                    light: palette(lightgray, 2),\n                    dark: palette(darkgray, 5),\n                ),\n            ),\n            elevated: (\n                \"Background of containers\",\n                (\n                    light: white,\n                    dark: palette(darkgray, 4),\n                ),\n            ),\n            inset: (\n                \"Background of inputs inside containers\",\n                (\n                    light: white,\n                    dark: palette(darkgray, 5),\n                ),\n            ),\n            overlay: (\n                \"Background of floating elements (menus, tooltips)\",\n                (\n                    light: palette(lightgray, 0),\n                    dark: palette(darkgray, 5),\n                ),\n            ),\n            code: (\n                \"Background of code editors\",\n                (\n                    light: white,\n                    dark: palette(darkgray, 6),\n                ),\n            ),\n            glass: (\n                \"Transparent background for surfaces containing text\",\n                (\n                    light: color.scale(white, $alpha: -60%),\n                    dark: color.scale(palette(darkgray, 4), $alpha: -60%),\n                ),\n            ),\n        ),\n        border: (\n            default: (\n                \"Border color with medium contrast against window background\",\n                (\n                    light: palette(lightgray, 6),\n                    dark: palette(darkgray, 7),\n                ),\n            ),\n            subtle: (\n                \"Border color with low contrast against window background\",\n                (\n                    light: palette(lightgray, 4),\n                    dark: palette(darkgray, 6),\n                ),\n            ),\n            strong: (\n                \"Border color with high contrast against window background\",\n                (\n                    light: palette(lightgray, 9),\n                    dark: palette(darkgray, 9),\n                ),\n            ),\n            focus: (\n                \"Border color of focused input elements\",\n                (\n                    light: palette(blue, 5),\n                    dark: palette(blue, 5),\n                ),\n            ),\n        ),\n        button: (\n            bg: (\n                \"Background color of buttons\",\n                (\n                    light: palette(lightgray, 0),\n                    dark: color.scale(palette(darkgray, 4), $lightness: 5%),\n                ),\n            ),\n            gradient: (\n                start: (\n                    \"Start value of default button gradient\",\n                    (\n                        light: white,\n                        dark: color.scale(palette(darkgray, 4), $lightness: 10%),\n                    ),\n                ),\n                end: (\n                    \"End value of default button gradient\",\n                    (\n                        light: palette(lightgray, 0),\n                        dark: color.scale(palette(darkgray, 4), $lightness: 5%),\n                    ),\n                ),\n            ),\n            hover: (\n                border: (\n                    \"Border color of default button in hover state\",\n                    (\n                        light: palette(lightgray, 8),\n                        dark: palette(darkgray, 8),\n                    ),\n                ),\n            ),\n            disabled: (\n                \"Background color of disabled button\",\n                (\n                    light: color.scale(palette(lightgray, 5), $alpha: -50%),\n                    dark: color.scale(palette(darkgray, 3), $alpha: -50%),\n                ),\n            ),\n            primary: (\n                bg: (\n                    \"Background color of primary button\",\n                    (\n                        light: color.scale(palette(blue, 6), $lightness: 5%),\n                        dark: color.scale(palette(blue, 7), $saturation: -10%),\n                    ),\n                ),\n                gradient: (\n                    start: (\n                        \"Start value of primary button gradient\",\n                        (\n                            light: palette(blue, 5),\n                            dark: color.scale(palette(blue, 6), $saturation: -10%),\n                        ),\n                    ),\n                    end: (\n                        \"End value of primary button gradient\",\n                        (\n                            light: color.scale(palette(blue, 6), $lightness: 5%),\n                            dark: color.scale(palette(blue, 7), $saturation: -10%),\n                        ),\n                    ),\n                ),\n                disabled: (\n                    \"Background color of primary button in disabled state\",\n                    (\n                        light: palette(blue, 3),\n                        dark: color.scale(palette(blue, 5), $saturation: -10%),\n                    ),\n                ),\n            ),\n        ),\n        scrollbar: (\n            bg: (\n                default: (\n                    \"Background of scrollbar in idle state (Win/Lin only)\",\n                    (\n                        light: palette(lightgray, 5),\n                        dark: palette(darkgray, 3),\n                    ),\n                ),\n                hover: (\n                    \"Background of scrollbar in hover state (Win/Lin only)\",\n                    (\n                        light: palette(lightgray, 6),\n                        dark: palette(darkgray, 2),\n                    ),\n                ),\n                active: (\n                    \"Background of scrollbar in pressed state (Win/Lin only)\",\n                    (\n                        light: palette(lightgray, 7),\n                        dark: palette(darkgray, 1),\n                    ),\n                ),\n            ),\n        ),\n        shadow: (\n            default: (\n                \"Default box-shadow color\",\n                (\n                    light: palette(lightgray, 6),\n                    dark: palette(darkgray, 8),\n                ),\n            ),\n            inset: (\n                \"Inset box-shadow color\",\n                (\n                    light: palette(darkgray, 3),\n                    dark: palette(darkgray, 7),\n                ),\n            ),\n            subtle: (\n                \"Box-shadow color with lower contrast against window background\",\n                (\n                    light: palette(darkgray, 0),\n                    dark: palette(darkgray, 4),\n                ),\n            ),\n            focus: (\n                \"Box-shadow color for elements in focused state\",\n                (\n                    default: palette(indigo, 5),\n                ),\n            ),\n        ),\n        accent: (\n            card: (\n                \"Accent color for cards\",\n                (\n                    light: palette(blue, 4),\n                    dark: palette(blue, 3),\n                ),\n            ),\n            note: (\n                \"Accent color for notes\",\n                (\n                    light: palette(green, 5),\n                    dark: palette(green, 4),\n                ),\n            ),\n            danger: (\n                \"Saturated accent color to grab attention\",\n                (\n                    light: palette(red, 5),\n                    dark: palette(red, 4),\n                ),\n            ),\n        ),\n        flag: (\n            1: (\n                \"Flag 1 (red)\",\n                (\n                    light: palette(red, 5),\n                    dark: palette(red, 4),\n                ),\n            ),\n            2: (\n                \"Flag 2 (orange)\",\n                (\n                    light: palette(orange, 4),\n                    dark: palette(orange, 3),\n                ),\n            ),\n            3: (\n                \"Flag 3 (green)\",\n                (\n                    light: palette(green, 4),\n                    dark: palette(green, 3),\n                ),\n            ),\n            4: (\n                \"Flag 4 (blue)\",\n                (\n                    light: palette(blue, 5),\n                    dark: palette(blue, 4),\n                ),\n            ),\n            5: (\n                \"Flag 5 (pink)\",\n                (\n                    light: palette(fuchsia, 4),\n                    dark: palette(fuchsia, 3),\n                ),\n            ),\n            6: (\n                \"Flag 6 (turquoise)\",\n                (\n                    light: palette(teal, 4),\n                    dark: palette(teal, 3),\n                ),\n            ),\n            7: (\n                \"Flag 7 (purple)\",\n                (\n                    light: palette(purple, 5),\n                    dark: palette(purple, 4),\n                ),\n            ),\n        ),\n        state: (\n            new: (\n                \"Accent color for new cards\",\n                (\n                    light: palette(blue, 5),\n                    dark: palette(blue, 3),\n                ),\n            ),\n            learn: (\n                \"Accent color for cards in learning state\",\n                (\n                    light: palette(red, 6),\n                    dark: palette(red, 4),\n                ),\n            ),\n            review: (\n                \"Accent color for cards in review state\",\n                (\n                    light: palette(green, 6),\n                    dark: palette(green, 5),\n                ),\n            ),\n            buried: (\n                \"Accent color for buried cards\",\n                (\n                    light: palette(amber, 5),\n                    dark: palette(amber, 8),\n                ),\n            ),\n            suspended: (\n                \"Accent color for suspended cards\",\n                (\n                    light: palette(yellow, 4),\n                    dark: palette(yellow, 1),\n                ),\n            ),\n            marked: (\n                \"Accent color for marked cards\",\n                (\n                    light: palette(indigo, 5),\n                    dark: palette(purple, 5),\n                ),\n            ),\n        ),\n        highlight: (\n            bg: (\n                \"Background color of highlighted items\",\n                (\n                    light: color.scale(palette(blue, 6), $alpha: -50%),\n                    dark: color.scale(palette(blue, 3), $alpha: -50%),\n                ),\n            ),\n            fg: (\n                \"Foreground color of highlighted items\",\n                (\n                    light: black,\n                    dark: white,\n                ),\n            ),\n        ),\n        selected: (\n            bg: (\n                \"Background color of selected text\",\n                (\n                    light: color.scale(palette(lightgray, 5), $alpha: -50%),\n                    dark: color.scale(palette(blue, 3), $alpha: -50%),\n                ),\n            ),\n            fg: (\n                \"Foreground color of selected text\",\n                (\n                    light: black,\n                    dark: white,\n                ),\n            ),\n        ),\n    ),\n);\n\n@function prop($keyword) {\n    @return var(--#{$keyword});\n}\n\n@function color($keyword) {\n    @return var(--#{$keyword});\n}\n\n@function palette-of($keyword, $theme: default) {\n    $colors: map.get($vars, colors);\n    @return get-value-from-map($colors, $keyword, $theme);\n}\n"
  },
  {
    "path": "ts/lib/sass/base.scss",
    "content": "@use \"vars\" as *;\n@use \"root-vars\";\n@use \"button-mixins\" as button;\n@use \"./scrollbar\";\n\n$body-color: palette(darkgray, 9);\n$body-color-dark: palette(lightgray, 0);\n$body-bg: palette(lightgray, 2);\n$body-bg-dark: palette(darkgray, 5);\n$link-hover-decoration: none;\n\n$utilities: (\n    \"opacity\": (\n        property: opacity,\n        values: (\n            0: 0,\n            25: 0.25,\n            50: 0.5,\n            75: 0.75,\n            100: 1,\n        ),\n    ),\n);\n\n@import \"bootstrap/scss/bootstrap-reboot\";\n@import \"bootstrap/scss/bootstrap-utilities\";\n\n/* Bootstrap \"extensions\" */\n.flex-basis-100 {\n    flex-basis: 100%;\n}\n\n.flex-basis-75 {\n    flex-basis: 75%;\n}\n\nhtml,\nbody {\n    height: 100%;\n}\n\nhtml {\n    overscroll-behavior: none;\n}\n\nbody {\n    font-family: inherit;\n    overflow-x: hidden;\n    &:not(.isMac),\n    &:not(.isMac) * {\n        @include scrollbar.custom;\n    }\n    &.no-blur * {\n        backdrop-filter: none !important;\n    }\n}\n\nbutton:not(.btn, .btn-close) {\n    /* override transition for instant hover response */\n    transition:\n        color var(--transition) ease-in-out,\n        box-shadow var(--transition) ease-in-out !important;\n    border-radius: prop(border-radius);\n    @include button.base;\n}\n\npre,\ncode,\nkbd,\nsamp {\n    unicode-bidi: normal !important;\n}\n\nlabel,\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n    cursor: pointer;\n}\n\ntextarea,\ninput[type=\"date\"],\ninput[type=\"text\"] {\n    border-radius: prop(border-radius);\n    outline: none;\n    border: 1px solid color(border);\n    &:focus {\n        border-color: color(border-focus);\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/bootstrap-dark.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"vars\";\n\n@mixin night-mode {\n    input,\n    select {\n        background-color: var(--canvas-inset);\n        border-color: var(--border);\n\n        &:focus {\n            background-color: var(--canvas-inset);\n        }\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/bootstrap-forms.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@import \"bootstrap/scss/forms\";\n\n.form-control,\n.form-select {\n    // the unprefixed version wasn't added until Chrome 81\n    -webkit-appearance: none;\n}\n"
  },
  {
    "path": "ts/lib/sass/bootstrap-tooltip.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"vars\";\n\n$tooltip-padding-y: 0.45rem;\n$tooltip-padding-x: 0.65rem;\n$tooltip-max-width: 300px;\n\n@import \"bootstrap/scss/tooltip\";\n\n.tooltip-inner {\n    text-align: start;\n\n    // marked transpiles tooltips into multiple paragraphs\n    // where trailing <p>s cause a bottom margin\n    > p:last-child {\n        display: inline;\n    }\n\n    // the default code color in tooltips is difficult to read; we'll probably\n    // want to add more of our own styling in the future\n    code {\n        color: palette(red, 0);\n        direction: inherit;\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/breakpoints.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n@use \"sass:list\";\n@use \"sass:map\";\n\n$bps: (\"xs\", \"sm\", \"md\", \"lg\", \"xl\", \"xxl\");\n\n$breakpoints: (\n    list.nth($bps, 2): 576px,\n    list.nth($bps, 3): 768px,\n    list.nth($bps, 4): 992px,\n    list.nth($bps, 5): 1200px,\n    list.nth($bps, 6): 1400px,\n);\n\n@mixin with-breakpoint($bp) {\n    @if map.get($breakpoints, $bp) {\n        @media (min-width: map.get($breakpoints, $bp)) {\n            @content;\n        }\n    } @else {\n        @content;\n    }\n}\n\n@mixin with-breakpoints($prefix, $dict) {\n    @each $property, $values in $dict {\n        @each $bp, $value in $values {\n            @if map.get($breakpoints, $bp) {\n                @media (min-width: map.get($breakpoints, $bp)) {\n                    .#{$prefix}-#{$bp} {\n                        #{$property}: $value;\n                    }\n                }\n            } @else {\n                .#{$prefix}-#{$bp} {\n                    #{$property}: $value;\n                }\n            }\n        }\n    }\n}\n\n@function breakpoints-upto($upto) {\n    $result: ();\n\n    @each $bp in $bps {\n        $result: list.append($result, $bp);\n\n        @if $bp == $upto {\n            @return $result;\n        }\n    }\n\n    @return $result;\n}\n\n@function breakpoint-selector-upto($prefix, $upto) {\n    $result: ();\n\n    @each $bp in breakpoints-upto($upto) {\n        $result: list.append($result, \".#{$prefix}-#{$bp}\", $separator: comma);\n    }\n\n    @return $result;\n}\n\n@mixin with-breakpoints-upto($prefix, $dict) {\n    @each $property, $values in $dict {\n        @each $bp, $value in $values {\n            $selector: breakpoint-selector-upto($prefix, $bp);\n\n            @if map.get($breakpoints, $bp) {\n                @media (min-width: map.get($breakpoints, $bp)) {\n                    #{$selector} {\n                        #{$property}: $value;\n                    }\n                }\n            } @else {\n                #{$selector} {\n                    #{$property}: $value;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/buttons.scss",
    "content": "@use \"vars\";\n@use \"button-mixins\" as button;\n@use \"elevation\" as *;\n\n:root {\n    --focus-color: #{vars.palette-of(shadow-focus)};\n\n    .isMac {\n        --focus-color: rgba(0 103 244 / 0.247);\n    }\n}\n\n.isWin {\n    button {\n        font-size: 12px;\n    }\n}\n\n.isMac {\n    button {\n        font-size: 13px;\n    }\n}\n\nbutton {\n    outline: none !important;\n    background: var(--button-bg);\n    border-radius: var(--border-radius);\n    border: 1px solid var(--border-subtle);\n    &:hover {\n        background: var(--button-gradient-start);\n        border: 1px solid var(--border);\n    }\n    font-weight: 500;\n    padding: 8px 10px;\n    margin: 0 4px;\n\n    @include button.base;\n    .fancy & {\n        border-radius: var(--border-radius-large);\n        @include elevation(1, $opacity-boost: -0.08);\n        &:hover {\n            @include elevation(2);\n            transition: box-shadow var(--transition) linear;\n        }\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/card-counts.scss",
    "content": ".review-count {\n    color: var(--state-review);\n}\n\n.new-count {\n    color: var(--state-new);\n}\n\n.learn-count {\n    color: var(--state-learn);\n}\n\n.zero-count {\n    color: var(--fg-faint);\n}\n\n.bury-count {\n    color: var(--fg-disabled);\n    font-weight: bold;\n    margin-inline-start: 2px;\n\n    &:empty {\n        display: none;\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/core.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"vars\";\n\n* {\n    box-sizing: border-box;\n}\n\nbody {\n    color: var(--fg);\n    background: var(--canvas);\n    margin: 1em;\n    &.fancy {\n        transition: opacity var(--transition-medium) ease-out;\n    }\n    overscroll-behavior: none;\n}\n\na {\n    color: var(--fg-link);\n    text-decoration: none;\n}\n"
  },
  {
    "path": "ts/lib/sass/elevation.scss",
    "content": "// Heavily inspired by https://github.com/material-components/material-components-web/tree/master/packages/mdc-elevation\n@use \"sass:color\";\n@use \"sass:map\";\n@use \"sass:list\";\n\n/**\n * The maps correspond to dp levels:\n * 0: 0dp\n * 1: 1dp\n * 2: 2dp\n * 3: 3dp\n * 4: 4dp\n * 5: 6dp\n * 6: 8dp\n * 7: 12dp\n * 8: 16dp\n * 9: 24dp\n */\n\n$umbra-map: (\n    0: \"0px 0px 0px 0px\",\n    1: \"0px 2px 1px -1px\",\n    2: \"0px 3px 1px -2px\",\n    3: \"0px 3px 3px -2px\",\n    4: \"0px 2px 4px -1px\",\n    5: \"0px 3px 5px -1px\",\n    6: \"0px 5px 5px -3px\",\n    7: \"0px 7px 8px -4px\",\n    8: \"0px 8px 10px -5px\",\n    9: \"0px 11px 15px -7px\",\n);\n\n$penumbra-map: (\n    0: \"0px 0px 0px 0px\",\n    1: \"0px 1px 1px 0px\",\n    2: \"0px 2px 2px 0px\",\n    3: \"0px 3px 4px 0px\",\n    4: \"0px 4px 5px 0px\",\n    5: \"0px 6px 10px 0px\",\n    6: \"0px 8px 10px 1px\",\n    7: \"0px 12px 17px 2px\",\n    8: \"0px 16px 24px 2px\",\n    9: \"0px 24px 38px 3px\",\n);\n\n$ambient-map: (\n    0: \"0px 0px 0px 0px\",\n    1: \"0px 1px 3px 0px\",\n    2: \"0px 1px 5px 0px\",\n    3: \"0px 1px 8px 0px\",\n    4: \"0px 1px 10px 0px\",\n    5: \"0px 1px 18px 0px\",\n    6: \"0px 3px 14px 2px\",\n    7: \"0px 5px 22px 4px\",\n    8: \"0px 6px 30px 5px\",\n    9: \"0px 9px 46px 8px\",\n);\n\n$umbra-opacity: 0.2;\n$penumbra-opacity: 0.14;\n$ambient-opacity: 0.12;\n\n@function box-shadow($level, $opacity-boost, $color) {\n    $umbra-z-value: map.get($umbra-map, $level);\n    $penumbra-z-value: map.get($penumbra-map, $level);\n    $ambient-z-value: map.get($ambient-map, $level);\n\n    $umbra-color: color.adjust(rgba($color, $umbra-opacity), $alpha: $opacity-boost);\n    $penumbra-color: color.adjust(\n        rgba($color, $penumbra-opacity),\n        $alpha: $opacity-boost\n    );\n    $ambient-color: color.adjust(\n        rgba($color, $ambient-opacity),\n        $alpha: $opacity-boost\n    );\n\n    @return (\n        #{$umbra-z-value} $umbra-color,\n        #{$penumbra-z-value} $penumbra-color,\n        #{$ambient-z-value} $ambient-color\n    );\n}\n\n@mixin elevation($level, $opacity-boost: 0, $color: #141414) {\n    box-shadow: box-shadow($level, $opacity-boost, $color);\n}\n"
  },
  {
    "path": "ts/lib/sass/night-mode.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@mixin input {\n    background-color: var(--canvas-inset);\n    border-color: var(--border);\n\n    &:focus {\n        background-color: var(--canvas-inset);\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/panes.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@mixin resizable($direction, $width-resizable, $height-resizable) {\n    display: flex;\n    flex-flow: #{$direction} nowrap;\n\n    flex-basis: 0;\n    flex-grow: var(--pane-size);\n\n    overflow: hidden;\n    overflow-y: auto;\n\n    &.resize {\n        flex-basis: auto;\n\n        @if $width-resizable {\n            &.resize-width {\n                width: var(--resized-width);\n            }\n        }\n\n        @if $height-resizable {\n            &.resize-height {\n                height: var(--resized-height);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ts/lib/sass/scrollbar.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"vars\";\n\n@mixin custom {\n    &::-webkit-scrollbar {\n        background-color: vars.color(canvas);\n\n        &:horizontal {\n            height: 12px;\n        }\n\n        &:vertical {\n            width: 12px;\n        }\n    }\n\n    &::-webkit-scrollbar-thumb {\n        background: vars.color(scrollbar-bg);\n        border-radius: vars.prop(border-radius);\n\n        &:horizontal {\n            min-width: 50px;\n        }\n\n        &:vertical {\n            min-height: 50px;\n        }\n\n        &:hover {\n            background: vars.color(scrollbar-bg-hover);\n        }\n\n        &:active {\n            background: vars.color(scrollbar-bg-active);\n        }\n    }\n\n    &::-webkit-scrollbar-corner {\n        background-color: vars.color(canvas);\n    }\n\n    &::-webkit-scrollbar-track {\n        border-radius: 5px;\n        background-color: transparent;\n    }\n}\n"
  },
  {
    "path": "ts/lib/sveltelib/action-list.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { truthy } from \"@tslib/functional\";\n\ninterface ActionReturn<P> {\n    destroy?(): void;\n    update?(params: P): void;\n}\n\ntype Action<E extends HTMLElement, P> = (\n    element: E,\n    params: P,\n) => ActionReturn<P> | void;\n\n/**\n * A helper function for treating a list of Svelte actions as a single Svelte action\n * and use it with a single `use:` directive\n */\nfunction actionList<E extends HTMLElement, P>(actions: Action<E, P>[]): Action<E, P> {\n    return function action(element: E, params: P): ActionReturn<P> | void {\n        const results = actions.map((action) => action(element, params)).filter(truthy);\n\n        return {\n            update(params: P) {\n                for (const { update } of results) {\n                    update?.(params);\n                }\n            },\n            destroy() {\n                for (const { destroy } of results) {\n                    destroy?.();\n                }\n            },\n        };\n    };\n}\n\nexport default actionList;\n"
  },
  {
    "path": "ts/lib/sveltelib/closing-click.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Readable } from \"svelte/store\";\nimport { derived } from \"svelte/store\";\n\nimport type { EventPredicateResult } from \"./event-predicate\";\n\n/**\n * Typically the right-sided mouse button.\n */\nfunction isSecondaryButton(event: MouseEvent): boolean {\n    return event.button === 2;\n}\n\ninterface ClosingClickArgs {\n    /**\n     * Clicking on the reference element should not close.\n     * The reference should handle this itself.\n     */\n    reference: EventTarget;\n    floating: EventTarget;\n    inside: boolean;\n    outside: boolean;\n}\n\n/**\n * Returns a derived store, which translates `MouseEvent`s into a boolean\n * indicating whether they constitute a click that should close `floating`.\n *\n * @param store: Should be an event store wrapping document.click.\n */\nfunction isClosingClick(\n    store: Readable<MouseEvent>,\n    { reference, floating, inside, outside }: ClosingClickArgs,\n): Readable<EventPredicateResult> {\n    function isTriggerClick(path: EventTarget[]): string | false {\n        // Reference element was clicked, e.g. the button.\n        // The reference element needs to handle opening/closing itself.\n        if (path.includes(reference)) {\n            return false;\n        }\n\n        if (inside && path.includes(floating)) {\n            return \"insideClick\";\n        }\n\n        if (outside && !path.includes(floating)) {\n            return \"outsideClick\";\n        }\n\n        return false;\n    }\n\n    function shouldClose(event: MouseEvent): string | false {\n        if (isSecondaryButton(event)) {\n            return \"secondaryButton\";\n        }\n\n        return isTriggerClick(event.composedPath());\n    }\n\n    return derived(\n        store,\n        (event: MouseEvent, set: (value: EventPredicateResult) => void): void => {\n            const reason = shouldClose(event);\n\n            if (reason) {\n                set({ reason, originalEvent: event });\n            }\n        },\n    );\n}\n\nexport default isClosingClick;\n"
  },
  {
    "path": "ts/lib/sveltelib/closing-keyup.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Readable } from \"svelte/store\";\nimport { derived } from \"svelte/store\";\n\nimport type { EventPredicateResult } from \"./event-predicate\";\n\ninterface ClosingKeyupArgs {\n    /**\n     * Clicking on the reference element should not close.\n     * The reference should handle this itself.\n     */\n    reference: Node;\n    floating: Node;\n}\n\n/**\n * Returns a derived store, which translates `MouseEvent`s into a boolean\n * indicating whether they constitute a click that should close `floating`.\n *\n * @param store: Should be an event store wrapping document.click.\n */\nfunction isClosingKeyup(\n    store: Readable<KeyboardEvent>,\n    _args: ClosingKeyupArgs,\n): Readable<EventPredicateResult> {\n    // TODO there needs to be special treatment, whether the keyup happens\n    // inside the floating element or outside, but I'll defer until we actually\n    // use this for a popover with an input field\n    function shouldClose(event: KeyboardEvent): string | false {\n        if (event.key === \"Tab\") {\n            // Allow Tab navigation.\n            return false;\n        }\n\n        return \"keyup\";\n    }\n\n    return derived(\n        store,\n        (event: KeyboardEvent, set: (value: EventPredicateResult) => void): void => {\n            const reason = shouldClose(event);\n\n            if (reason) {\n                set({ reason, originalEvent: event });\n            }\n        },\n    );\n}\n\nexport default isClosingKeyup;\n"
  },
  {
    "path": "ts/lib/sveltelib/composition.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { writable } from \"svelte/store\";\n\n/**\n * Indicates whether an IME composition session is currently active\n */\nexport const isComposing = writable(false);\n\nwindow.addEventListener(\"compositionstart\", () => isComposing.set(true));\nwindow.addEventListener(\"compositionend\", () => isComposing.set(false));\n"
  },
  {
    "path": "ts/lib/sveltelib/context-property.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getContext, hasContext, setContext } from \"svelte\";\n\ntype SetContextPropertyAction<T> = (value: T) => void;\n\nexport interface ContextProperty<T> {\n    /**\n     * Retrieves the component's context\n     *\n     * @remarks\n     * The typing of the return value is a lie insofar as calling `get` outside\n     * of the component's context will return `undefined`.\n     * If you are uncertain if your component is actually within the context\n     * of this component, you should check with `available` first.\n     *\n     * @returns The component's context\n     */\n    get(): T;\n    /**\n     * Checks whether the component's context is available\n     */\n    available(): boolean;\n}\n\nfunction contextProperty<T>(\n    key: symbol,\n): [ContextProperty<T>, SetContextPropertyAction<T>] {\n    function set(context: T): void {\n        setContext(key, context);\n    }\n\n    const context = {\n        get(): T {\n            return getContext(key);\n        },\n        available(): boolean {\n            return hasContext(key);\n        },\n    };\n\n    return [context, set];\n}\n\nexport default contextProperty;\n"
  },
  {
    "path": "ts/lib/sveltelib/dom-mirror.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { on } from \"@tslib/events\";\nimport type { Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\nimport storeSubscribe from \"./store-subscribe\";\n\nconst config = {\n    childList: true,\n    subtree: true,\n    attributes: true,\n    characterData: true,\n};\n\nexport type MirrorAction = (\n    element: HTMLElement,\n    params: { store: Writable<DocumentFragment> },\n) => { destroy(): void };\n\ninterface DOMMirrorAPI {\n    mirror: MirrorAction;\n    preventResubscription(): () => void;\n}\n\nfunction cloneNode(node: Node): DocumentFragment {\n    /**\n     * Creates a deep clone\n     * This seems to be less buggy than node.cloneNode(true)\n     */\n    const range = document.createRange();\n\n    range.selectNodeContents(node);\n    return range.cloneContents();\n}\n\n/**\n * Allows you to keep an element's inner HTML bidirectionally\n * in sync with a store containing a DocumentFragment.\n * While the element has focus, this connection is tethered.\n * In practice, this will sync changes from PlainTextInput to RichTextInput.\n */\nfunction useDOMMirror(): DOMMirrorAPI {\n    const allowResubscription = writable(true);\n\n    function preventResubscription() {\n        allowResubscription.set(false);\n\n        return () => {\n            allowResubscription.set(true);\n        };\n    }\n\n    function mirror(\n        element: HTMLElement,\n        { store }: { store: Writable<DocumentFragment> },\n    ): { destroy(): void } {\n        function saveHTMLToStore(): void {\n            store.set(cloneNode(element));\n        }\n\n        const observer = new MutationObserver(saveHTMLToStore);\n        observer.observe(element, config);\n\n        function mirrorToElement(node: Node): void {\n            observer.disconnect();\n            // element.replaceChildren(...node.childNodes); // TODO use once available\n            while (element.firstChild) {\n                element.firstChild.remove();\n            }\n\n            while (node.firstChild) {\n                element.appendChild(node.firstChild);\n            }\n            observer.observe(element, config);\n        }\n\n        function mirrorFromFragment(fragment: DocumentFragment): void {\n            mirrorToElement(cloneNode(fragment));\n        }\n\n        const { subscribe, unsubscribe } = storeSubscribe(\n            store,\n            mirrorFromFragment,\n            false,\n        );\n\n        /* do not update when focused as it will reset caret */\n        const removeFocus = on(element, \"focus\", unsubscribe);\n        let removeBlur: (() => void) | undefined;\n\n        const unsubResubscription = allowResubscription.subscribe(\n            (allow: boolean): void => {\n                if (allow) {\n                    if (!removeBlur) {\n                        removeBlur = on(element, \"blur\", subscribe);\n                    }\n\n                    const root = element.getRootNode() as Document | ShadowRoot;\n\n                    if (root.activeElement !== element) {\n                        subscribe();\n                    }\n                } else if (removeBlur) {\n                    removeBlur();\n                    removeBlur = undefined;\n                }\n            },\n        );\n\n        return {\n            destroy() {\n                observer.disconnect();\n\n                removeFocus();\n                removeBlur?.();\n\n                unsubscribe();\n                unsubResubscription();\n            },\n        };\n    }\n\n    return {\n        mirror,\n        preventResubscription,\n    };\n}\n\nexport default useDOMMirror;\n"
  },
  {
    "path": "ts/lib/sveltelib/dynamic-slotting.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { Identifier } from \"@tslib/children-access\";\nimport type { ChildrenAccess } from \"@tslib/children-access\";\nimport childrenAccess from \"@tslib/children-access\";\nimport { nodeIsElement } from \"@tslib/dom\";\nimport type { Callback } from \"@tslib/helpers\";\nimport { removeItem } from \"@tslib/helpers\";\nimport { promiseWithResolver } from \"@tslib/promise\";\nimport type { SvelteComponent } from \"svelte\";\nimport type { Readable, Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\nexport interface DynamicSvelteComponent {\n    component: typeof SvelteComponent<any>;\n    /**\n     * Props that are passed to the component\n     */\n    props?: Record<string, unknown>;\n    /**\n     * ID that will be assigned to the component that hosts\n     * the dynamic component (slot host)\n     */\n    id?: string;\n}\n\n/**\n * Props that will be passed to the slot host, e.g. ButtonGroupItem.\n */\nexport interface SlotHostProps {\n    detach: Writable<boolean>;\n}\n\nexport interface CreateInterfaceAPI<T extends SlotHostProps, U extends Element> {\n    addComponent(\n        component: DynamicSvelteComponent,\n        reinsert: (newElement: U, access: ChildrenAccess<U>) => number,\n    ): Promise<{ destroy: Callback }>;\n    updateProps(update: (hostProps: T) => T, identifier: Identifier): Promise<boolean>;\n}\n\nexport interface GetSlotHostProps<T> {\n    getProps(): T;\n}\n\nexport interface DynamicSlotted<T extends SlotHostProps = SlotHostProps> {\n    component: DynamicSvelteComponent;\n    hostProps: T;\n}\n\nexport interface DynamicSlottingAPI<\n    T extends SlotHostProps,\n    U extends Element,\n    X extends Record<string, unknown>,\n> {\n    /**\n     * This should be used as an action on the element that hosts the slot hosts.\n     */\n    resolveSlotContainer: (element: U) => void;\n    /**\n     * Contains the props for the DynamicSlot component\n     */\n    dynamicSlotted: Readable<DynamicSlotted<T>[]>;\n    slotsInterface: X;\n}\n\n/**\n * Allow add-on developers to dynamically extend/modify components our components\n *\n * @remarks\n * It allows to insert elements in between the components, or modify their props.\n * Practically speaking, we let Svelte do the initial insertion of an element,\n * but then immediately move it to its destination, and save a reference to it.\n *\n * @experimental\n */\nfunction dynamicSlotting<\n    T extends SlotHostProps,\n    U extends Element,\n    X extends Record<string, unknown>,\n>(\n    /**\n     * A function which will create props which are passed to the dynamically\n     * slotted component's host component, the slot host, e.g. `ButtonGroupItem`\n     */\n    makeProps: () => T,\n    /**\n     * This is called on *all* items whenever any item updates\n     */\n    updatePropsList: (propsList: T[]) => T[],\n    /**\n     * A function to create an interface to interact with slotted components\n     */\n    setSlotHostContext: (callback: GetSlotHostProps<T>) => void,\n    createInterface: (api: CreateInterfaceAPI<T, U>) => X,\n): DynamicSlottingAPI<T, U, X> {\n    const slotted = writable<T[]>([]);\n    slotted.subscribe(updatePropsList);\n\n    function addDynamicallySlotted(index: number, props: T): void {\n        slotted.update((slotted: T[]): T[] => {\n            slotted.splice(index, 0, props);\n            return slotted;\n        });\n    }\n\n    const [elementPromise, resolveSlotContainer] = promiseWithResolver<U>();\n    const accessPromise = elementPromise.then(childrenAccess);\n\n    const dynamicSlotted = writable<DynamicSlotted<T>[]>([]);\n\n    async function addComponent(\n        component: DynamicSvelteComponent,\n        reinsert: (newElement: U, access: ChildrenAccess<U>) => number,\n    ): Promise<{ destroy: Callback }> {\n        const [dynamicallySlottedMounted, resolveDynamicallySlotted] = promiseWithResolver();\n        const access = await accessPromise;\n        const hostProps = makeProps();\n\n        function elementIsDynamicComponent(element: Element): boolean {\n            return !component.id || element.id === component.id;\n        }\n\n        async function callback(\n            mutations: MutationRecord[],\n            observer: MutationObserver,\n        ): Promise<void> {\n            for (const mutation of mutations) {\n                for (const addedNode of mutation.addedNodes) {\n                    if (\n                        !nodeIsElement(addedNode)\n                        || !elementIsDynamicComponent(addedNode)\n                    ) {\n                        continue;\n                    }\n\n                    const theElement = addedNode as U;\n                    const index = reinsert(theElement, access);\n\n                    if (index >= 0) {\n                        addDynamicallySlotted(index, hostProps);\n                    }\n\n                    resolveDynamicallySlotted(undefined);\n                    return observer.disconnect();\n                }\n            }\n        }\n\n        const observer = new MutationObserver(callback);\n        observer.observe(access.parent, { childList: true });\n\n        const dynamicSlot = {\n            component,\n            hostProps,\n        };\n\n        dynamicSlotted.update(\n            (dynamicSlotted: DynamicSlotted<T>[]): DynamicSlotted<T>[] => {\n                dynamicSlotted.push(dynamicSlot);\n                return dynamicSlotted;\n            },\n        );\n\n        await dynamicallySlottedMounted;\n\n        return {\n            destroy() {\n                dynamicSlotted.update(\n                    (dynamicSlotted: DynamicSlotted<T>[]): DynamicSlotted<T>[] => {\n                        // TODO needs testing, if Svelte actually correctly removes the element\n                        removeItem(dynamicSlotted, dynamicSlot);\n                        return dynamicSlotted;\n                    },\n                );\n            },\n        };\n    }\n\n    async function updateProps(\n        update: (props: T) => T,\n        identifier: Identifier,\n    ): Promise<boolean> {\n        const access = await accessPromise;\n\n        return access.updateElement((_element: U, index: number): void => {\n            slotted.update((slottedProps: T[]) => {\n                slottedProps[index] = update(slottedProps[index]);\n                return slottedProps;\n            });\n        }, identifier);\n    }\n\n    const slotsInterface = createInterface({ addComponent, updateProps });\n\n    function getSlotHostProps(): T {\n        const props = makeProps();\n\n        slotted.update((slotted: T[]): T[] => {\n            slotted.push(props);\n            return slotted;\n        });\n\n        return props;\n    }\n\n    setSlotHostContext({ getProps: getSlotHostProps });\n\n    return {\n        dynamicSlotted,\n        resolveSlotContainer,\n        slotsInterface,\n    };\n}\n\nexport default dynamicSlotting;\n\n/** Convenient default functions for dynamic slotting */\n\nexport function defaultProps(): SlotHostProps {\n    return {\n        detach: writable(false),\n    };\n}\n\nexport interface DefaultSlotInterface extends Record<string, unknown> {\n    insert(\n        button: DynamicSvelteComponent,\n        position?: Identifier,\n    ): Promise<{ destroy: Callback }>;\n    append(\n        button: DynamicSvelteComponent,\n        position?: Identifier,\n    ): Promise<{ destroy: Callback }>;\n    show(position: Identifier): Promise<boolean>;\n    hide(position: Identifier): Promise<boolean>;\n    toggle(position: Identifier): Promise<boolean>;\n}\n\nexport function defaultInterface<T extends SlotHostProps, U extends Element>({\n    addComponent,\n    updateProps,\n}: CreateInterfaceAPI<T, U>): DefaultSlotInterface {\n    function insert(\n        component: DynamicSvelteComponent,\n        id: Identifier = 0,\n    ): Promise<{ destroy: Callback }> {\n        return addComponent(\n            component,\n            (element: Element, access: ChildrenAccess<U>) => access.insertElement(element, id),\n        );\n    }\n\n    function append(\n        component: DynamicSvelteComponent,\n        id: Identifier = -1,\n    ): Promise<{ destroy: Callback }> {\n        return addComponent(\n            component,\n            (element: Element, access: ChildrenAccess<U>) => access.appendElement(element, id),\n        );\n    }\n\n    function show(id: Identifier): Promise<boolean> {\n        return updateProps((props: T): T => {\n            props.detach.set(false);\n            return props;\n        }, id);\n    }\n\n    function hide(id: Identifier): Promise<boolean> {\n        return updateProps((props: T): T => {\n            props.detach.set(true);\n            return props;\n        }, id);\n    }\n\n    function toggle(id: Identifier): Promise<boolean> {\n        return updateProps((props: T): T => {\n            props.detach.update((detached: boolean) => !detached);\n            return props;\n        }, id);\n    }\n\n    return {\n        insert,\n        append,\n        show,\n        hide,\n        toggle,\n    };\n}\n\nimport contextProperty from \"./context-property\";\n\nconst key = Symbol(\"dynamicSlotting\");\nconst [defaultSlotHostContext, setSlotHostContext] = contextProperty<GetSlotHostProps<SlotHostProps>>(key);\n\nexport { defaultSlotHostContext, setSlotHostContext };\n"
  },
  {
    "path": "ts/lib/sveltelib/dynamicComponent.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { SvelteComponent } from \"svelte\";\n\nexport interface DynamicSvelteComponent<\n    T extends typeof SvelteComponent<any> = typeof SvelteComponent<any>,\n> {\n    component: T;\n    [k: string]: unknown;\n}\n\nexport const dynamicComponent = <\n    Comp extends typeof SvelteComponent<any>,\n    DefaultProps = NonNullable<ConstructorParameters<Comp>[0][\"props\"]>,\n>(\n    component: Comp,\n) =>\n<Props = DefaultProps>(props: Props): DynamicSvelteComponent<Comp> & Props => {\n    return { component, ...props };\n};\n"
  },
  {
    "path": "ts/lib/sveltelib/event-predicate.d.ts",
    "content": "export interface EventPredicateResult {\n    reason: string;\n    originalEvent: Event;\n}\n"
  },
  {
    "path": "ts/lib/sveltelib/event-store.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { EventTargetToMap } from \"@tslib/events\";\nimport { on } from \"@tslib/events\";\nimport type { Callback } from \"@tslib/typing\";\nimport type { Readable, Subscriber } from \"svelte/store\";\nimport { readable } from \"svelte/store\";\n\ntype Init<T> = { new(type: string): T; prototype: T };\n\n/**\n * A store wrapping an event. Automatically adds/removes event handler upon\n * first/last subscriber.\n *\n * @remarks\n * Should probably always be used in conjunction with `subscribeToUpdates`.\n */\nfunction eventStore<T extends EventTarget, K extends keyof EventTargetToMap<T>>(\n    target: T,\n    eventType: Exclude<K, symbol | number>,\n    /**\n     * Store needs an initial value. This should probably be a freshly\n     * constructed event, e.g. `new MouseEvent(\"click\")`.\n     */\n    constructor: Init<EventTargetToMap<T>[K]>,\n): Readable<EventTargetToMap<T>[K]> {\n    const initEvent = new constructor(eventType);\n    return readable(\n        initEvent,\n        (set: Subscriber<EventTargetToMap<T>[K]>): Callback => on(target, eventType, set),\n    );\n}\n\nexport default eventStore;\n\n/**\n * A click event that fires only if the mouse has not appreciably moved since the button\n * was pressed down. This was added so that if the user clicks inside a floating area and\n * drags the mouse outside the area while selecting text, it doesn't end up closing the\n * floating area.\n */\nfunction mouseClickWithoutDragStore(): Readable<MouseEvent> {\n    const initEvent = new MouseEvent(\"click\");\n\n    return readable(\n        initEvent,\n        (set: Subscriber<MouseEvent>): Callback => {\n            let startingX: number;\n            let startingY: number;\n            function onMouseDown(evt: MouseEvent): void {\n                startingX = evt.clientX;\n                startingY = evt.clientY;\n            }\n            function onClick(evt: MouseEvent): void {\n                if (Math.abs(startingX - evt.clientX) < 5 && Math.abs(startingY - evt.clientY) < 5) {\n                    set(evt);\n                }\n            }\n            document.addEventListener(\"mousedown\", onMouseDown);\n            document.addEventListener(\"click\", onClick);\n            return () => {\n                document.removeEventListener(\"click\", onClick);\n                document.removeEventListener(\"mousedown\", onMouseDown);\n            };\n        },\n    );\n}\n\nconst documentClick = mouseClickWithoutDragStore();\nconst documentKeyup = eventStore(document, \"keyup\", KeyboardEvent);\n\nexport { documentClick, documentKeyup };\n"
  },
  {
    "path": "ts/lib/sveltelib/export-runtime.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// Expose the Svelte runtime bundled with Anki, so that add-ons can require() it.\n// If they were to bundle their own runtime, things like bindings and contexts\n// would not work.\n\nimport { registerPackageRaw } from \"@tslib/runtime-require\";\nimport * as svelteRuntime from \"svelte\";\n// import * as svelteInternal from \"svelte/internal\";\n// import * as svelteDiscloseVersion from \"svelte/internal/disclose-version\";\nimport * as svelteStore from \"svelte/store\";\n\nregisterPackageRaw(\"svelte\", svelteRuntime);\nregisterPackageRaw(\"svelte/store\", svelteStore);\n// registerPackageRaw(\"svelte/internal\", svelteInternal);\n// registerPackageRaw(\"svelte/internal/disclose-version\", svelteDiscloseVersion);\n"
  },
  {
    "path": "ts/lib/sveltelib/handler-list.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Callback } from \"@tslib/typing\";\nimport type { Readable, Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\ntype Handler<T> = (args: T) => Promise<void>;\n\ninterface HandlerAccess<T> {\n    callback: Handler<T>;\n    clear(): void;\n}\n\nclass TriggerItem<T> {\n    #active: Writable<boolean>;\n\n    constructor(\n        private setter: (handler: Handler<T>, clear: Callback) => void,\n        private clear: Callback,\n    ) {\n        this.#active = writable(false);\n    }\n\n    /**\n     * A store which indicates whether the trigger is currently turned on.\n     */\n    get active(): Readable<boolean> {\n        return this.#active;\n    }\n\n    /**\n     * Deactivate the trigger. Can be safely called multiple times.\n     */\n    off(): void {\n        this.#active.set(false);\n        this.clear();\n    }\n\n    on(handler: Handler<T>): void {\n        this.setter(handler, () => this.off());\n        this.#active.set(true);\n    }\n}\n\ninterface HandlerOptions {\n    once: boolean;\n}\n\nexport class HandlerList<T> {\n    #list: HandlerAccess<T>[] = [];\n\n    /**\n     * Returns a `TriggerItem`, which can be used to attach event handlers.\n     * This TriggerItem exposes an additional `active` store. This can be\n     * useful, if other components need to react to the input handler being active.\n     */\n    trigger(options?: Partial<HandlerOptions>): TriggerItem<T> {\n        const once = options?.once ?? false;\n        let handler: Handler<T> | null = null;\n\n        return new TriggerItem(\n            (callback: Handler<T>, doClear: Callback): void => {\n                const handlerAccess = {\n                    callback(args: T): Promise<void> {\n                        const result = callback(args);\n                        if (once) {\n                            doClear();\n                        }\n                        return result;\n                    },\n                    clear(): void {\n                        if (once) {\n                            doClear();\n                        }\n                    },\n                };\n\n                this.#list.push(handlerAccess);\n                handler = handlerAccess.callback;\n            },\n            () => {\n                if (handler) {\n                    this.off(handler);\n                    handler = null;\n                }\n            },\n        );\n    }\n\n    /**\n     * Attaches an event handler.\n     * @returns a callback, which removes the event handler. Alternatively,\n     * you can call `off` on the HandlerList.\n     */\n    on(handler: Handler<T>, options?: Partial<HandlerOptions>): Callback {\n        const once = options?.once ?? false;\n        let offHandler: Handler<T> | null = null;\n\n        const off = (): void => {\n            if (offHandler) {\n                this.off(offHandler);\n                offHandler = null;\n            }\n        };\n\n        const handlerAccess = {\n            callback: (args: T): Promise<void> => {\n                const result = handler(args);\n                if (once) {\n                    off();\n                }\n                return result;\n            },\n            clear(): void {\n                if (once) {\n                    off();\n                }\n            },\n        };\n\n        offHandler = handlerAccess.callback;\n\n        this.#list.push(handlerAccess);\n        return off;\n    }\n\n    private off(handler: Handler<T>): void {\n        const index = this.#list.findIndex(\n            (value: HandlerAccess<T>): boolean => value.callback === handler,\n        );\n\n        if (index >= 0) {\n            this.#list.splice(index, 1);\n        }\n    }\n\n    get length(): number {\n        return this.#list.length;\n    }\n\n    dispatch(args: T): Promise<void> {\n        const promises: Promise<void>[] = [];\n\n        for (const { callback } of [...this]) {\n            promises.push(callback(args));\n        }\n\n        return Promise.all(promises) as unknown as Promise<void>;\n    }\n\n    clear(): void {\n        for (const { clear } of [...this]) {\n            clear();\n        }\n    }\n\n    [Symbol.iterator](): Iterator<HandlerAccess<T>, null, unknown> {\n        const list = this.#list;\n        let step = 0;\n\n        return {\n            next(): IteratorResult<HandlerAccess<T>, null> {\n                if (step >= list.length) {\n                    return { value: null, done: true };\n                }\n\n                return { value: list[step++], done: false };\n            },\n        };\n    }\n}\n\nexport type { TriggerItem };\n"
  },
  {
    "path": "ts/lib/sveltelib/input-handler.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getRange, getSelection } from \"@tslib/cross-browser\";\nimport { on } from \"@tslib/events\";\nimport { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from \"@tslib/keys\";\nimport { singleCallback } from \"@tslib/typing\";\n\nimport { HandlerList } from \"./handler-list\";\n\nconst nbsp = \"\\xa0\";\n\nexport type SetupInputHandlerAction = (element: HTMLElement) => { destroy(): void };\n\nexport interface InputEventParams {\n    event: InputEvent;\n}\n\nexport interface EventParams {\n    event: Event;\n}\n\nexport interface InsertTextParams {\n    event: InputEvent;\n    text: Text;\n}\n\ntype SpecialKeyAction =\n    | \"caretUp\"\n    | \"caretDown\"\n    | \"caretLeft\"\n    | \"caretRight\"\n    | \"enter\"\n    | \"tab\";\n\nexport interface SpecialKeyParams {\n    event: KeyboardEvent;\n    action: SpecialKeyAction;\n}\n\nexport interface InputHandlerAPI {\n    readonly beforeInput: HandlerList<InputEventParams>;\n    readonly insertText: HandlerList<InsertTextParams>;\n    readonly afterInput: HandlerList<EventParams>;\n    readonly pointerDown: HandlerList<{ event: PointerEvent }>;\n    readonly specialKey: HandlerList<SpecialKeyParams>;\n}\n\n/**\n * An interface that allows Svelte components to attach event listeners via triggers.\n * They will be attached to the component(s) that install the manager.\n * Prevents that too many event listeners are attached and allows for some\n * coordination between them.\n */\nfunction useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {\n    const beforeInput = new HandlerList<InputEventParams>();\n    const insertText = new HandlerList<InsertTextParams>();\n    const afterInput = new HandlerList<EventParams>();\n\n    async function onBeforeInput(this: Element, event: InputEvent): Promise<void> {\n        const selection = getSelection(this)!;\n        const range = getRange(selection);\n\n        await beforeInput.dispatch({ event });\n\n        if (\n            !range\n            || !event.inputType.startsWith(\"insert\")\n            || insertText.length === 0\n        ) {\n            return;\n        }\n\n        event.preventDefault();\n\n        const content = !event.data || event.data === \" \" ? nbsp : event.data;\n        const text = new Text(content);\n\n        range.deleteContents();\n        range.insertNode(text);\n        range.selectNode(text);\n        range.collapse(false);\n\n        await insertText.dispatch({ event, text });\n\n        range.commonAncestorContainer.normalize();\n\n        // We emulate the after input event here, because we prevent\n        // the default behavior earlier\n        await afterInput.dispatch({ event });\n    }\n\n    async function onInput(this: Element, event: Event): Promise<void> {\n        await afterInput.dispatch({ event });\n    }\n\n    const pointerDown = new HandlerList<{ event: PointerEvent }>();\n\n    function clearInsertText(): void {\n        insertText.clear();\n    }\n\n    function onPointerDown(event: PointerEvent): void {\n        pointerDown.dispatch({ event });\n        clearInsertText();\n    }\n\n    const specialKey = new HandlerList<SpecialKeyParams>();\n\n    async function onKeyDown(this: Element, event: KeyboardEvent): Promise<void> {\n        if (isArrowDown(event)) {\n            specialKey.dispatch({ event, action: \"caretDown\" });\n        } else if (isArrowUp(event)) {\n            specialKey.dispatch({ event, action: \"caretUp\" });\n        } else if (isArrowRight(event)) {\n            specialKey.dispatch({ event, action: \"caretRight\" });\n        } else if (isArrowLeft(event)) {\n            specialKey.dispatch({ event, action: \"caretLeft\" });\n        } else if (event.key === \"Enter\") {\n            specialKey.dispatch({ event, action: \"enter\" });\n        } else if (event.code === \"Tab\") {\n            specialKey.dispatch({ event, action: \"tab\" });\n        }\n    }\n\n    function setupHandler(element: HTMLElement): { destroy(): void } {\n        const destroy = singleCallback(\n            on(element, \"beforeinput\", onBeforeInput),\n            on(element, \"input\", onInput),\n            on(element, \"blur\", clearInsertText),\n            on(element, \"pointerdown\", onPointerDown),\n            on(element, \"keydown\", onKeyDown),\n            on(document, \"selectionchange\", clearInsertText),\n        );\n\n        return { destroy };\n    }\n\n    return [\n        {\n            beforeInput,\n            insertText,\n            afterInput,\n            specialKey,\n            pointerDown,\n        },\n        setupHandler,\n    ];\n}\n\nexport default useInputHandler;\n"
  },
  {
    "path": "ts/lib/sveltelib/lifecycle-hooks.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Callback } from \"@tslib/helpers\";\nimport { removeItem } from \"@tslib/helpers\";\nimport { onDestroy as svelteOnDestroy, onMount as svelteOnMount } from \"svelte\";\n\ntype ComponentAPIMount<T> = (api: T) => Callback | void;\ntype ComponentAPIDestroy<T> = (api: T) => void;\n\ntype SetLifecycleHooksAction<T> = (api: T) => void;\n\nexport interface LifecycleHooks<T> {\n    onMount(callback: ComponentAPIMount<T>): Callback | Promise<Callback>;\n    onDestroy(callback: ComponentAPIDestroy<T>): Callback | Promise<Callback>;\n}\n\n/**\n * Makes the Svelte lifecycle hooks accessible to add-ons.\n * Currently we expose onMount and onDestroy in here, but it is fully\n * thinkable to expose the others as well, given a good use case.\n */\nfunction lifecycleHooks<T>(): [LifecycleHooks<T>, T[], SetLifecycleHooksAction<T>] {\n    const instances: T[] = [];\n    const mountCallbacks: ComponentAPIMount<T>[] = [];\n    const destroyCallbacks: ComponentAPIDestroy<T>[] = [];\n\n    function setup(api: T): void {\n        svelteOnMount(() => {\n            const cleanups: Promise<void | Callback>[] = [];\n\n            for (const mountCallback of mountCallbacks) {\n                // Promise.resolve doesn't care whether it's a promise or sync callback\n                cleanups.push(\n                    Promise.resolve(mountCallback).then((callback) => {\n                        return callback(api);\n                    }),\n                );\n            }\n\n            // onMount seems to be called in reverse order\n            instances.unshift(api);\n\n            return async () => {\n                for (const cleanup of await Promise.all(cleanups)) {\n                    if (cleanup) {\n                        cleanup();\n                    }\n                }\n            };\n        });\n\n        svelteOnDestroy(() => {\n            removeItem(instances, api);\n\n            for (const destroyCallback of destroyCallbacks) {\n                Promise.resolve(destroyCallback).then((callback) => {\n                    callback(api);\n                });\n            }\n        });\n    }\n\n    function onMount(callback: ComponentAPIMount<T>): Callback {\n        mountCallbacks.push(callback);\n        return () => removeItem(mountCallbacks, callback);\n    }\n\n    function onDestroy(callback: ComponentAPIDestroy<T>): Callback {\n        destroyCallbacks.push(callback);\n        return () => removeItem(mountCallbacks, callback);\n    }\n\n    const lifecycle = {\n        onMount,\n        onDestroy,\n    };\n\n    return [lifecycle, instances, setup];\n}\n\nexport default lifecycleHooks;\n"
  },
  {
    "path": "ts/lib/sveltelib/modal-closing.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { on } from \"@tslib/events\";\n\ninterface ModalClosingHandler {\n    set: (value: boolean) => void;\n    remove: () => void;\n}\n\n/**\n * Register a keydown handler on the document that can optionally stop propagation to other handlers if Escape is pressed and the associated flag is set.\n * Intended to override the general handler in webview.py when a modal is open.\n */\nfunction registerModalClosingHandler(callback?: () => void): ModalClosingHandler {\n    let modalIsOpen = false;\n\n    function set(value: boolean) {\n        modalIsOpen = value;\n    }\n\n    const remove = on(document, \"keydown\", (event) => {\n        if (event.key === \"Escape\" && modalIsOpen) {\n            event.stopImmediatePropagation();\n            if (callback) {\n                callback();\n            }\n        }\n    }, { capture: true });\n\n    return { set, remove };\n}\n\nexport { registerModalClosingHandler };\n"
  },
  {
    "path": "ts/lib/sveltelib/node-store.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { noop } from \"@tslib/functional\";\nimport type { Subscriber, Unsubscriber, Updater, Writable } from \"svelte/store\";\n\nexport interface NodeStore<T extends Node> extends Writable<T> {\n    setUnprocessed(node: T): void;\n}\n\nexport function nodeStore<T extends Node>(\n    node?: T,\n    preprocess: (node: T) => void = noop,\n): NodeStore<T> {\n    const subscribers: Set<Subscriber<T>> = new Set();\n\n    function setUnprocessed(newNode: T): void {\n        if (node && node.isEqualNode(newNode)) {\n            return;\n        }\n\n        node = newNode;\n        for (const subscriber of subscribers) {\n            subscriber(node);\n        }\n    }\n\n    function set(newNode: T): void {\n        preprocess(newNode);\n        setUnprocessed(newNode);\n    }\n\n    function update(fn: Updater<T>): void {\n        set(fn(node!));\n    }\n\n    function subscribe(subscriber: Subscriber<T>): Unsubscriber {\n        subscribers.add(subscriber);\n\n        if (node) {\n            subscriber(node);\n        }\n\n        return () => subscribers.delete(subscriber);\n    }\n\n    return { set, setUnprocessed, update, subscribe };\n}\n\nexport default nodeStore;\n"
  },
  {
    "path": "ts/lib/sveltelib/position/auto-update.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { FloatingElement, ReferenceElement } from \"@floating-ui/dom\";\nimport { autoUpdate as floatingUiAutoUpdate } from \"@floating-ui/dom\";\nimport type { Callback } from \"@tslib/typing\";\nimport type { ActionReturn } from \"svelte/action\";\n\n/**\n * The interface of `autoUpdate` of floating-ui.\n * This means PositioningCallback can be used with that, but also invoked as it is.\n *\n * @example ```\n * // Invoke the positioning algorithm handily\n * position(myReference, (_, _, callback) => {\n *     callback();\n * })`\n */\nexport type PositioningCallback = (\n    reference: ReferenceElement,\n    floating: FloatingElement,\n    position: Callback,\n) => Callback;\n\n/**\n * The interface of a function that calls `computePosition` of floating-ui.\n */\nexport type PositionFunc = (\n    reference: ReferenceElement,\n    callback: PositioningCallback,\n) => Callback;\n\nfunction autoUpdate(\n    reference: ReferenceElement,\n    /**\n     * The method to position the floating element.\n     */\n    position: PositionFunc,\n): ActionReturn<PositionFunc> {\n    let cleanup: Callback;\n\n    function destroy() {\n        cleanup?.();\n    }\n\n    function update(position: PositionFunc): void {\n        destroy();\n        cleanup = position(reference, floatingUiAutoUpdate);\n    }\n\n    update(position);\n\n    return { destroy, update };\n}\n\nexport default autoUpdate;\n"
  },
  {
    "path": "ts/lib/sveltelib/position/position-algorithm.d.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { FloatingElement, Placement, ReferenceElement } from \"@floating-ui/dom\";\n\n/**\n * The interface of a function that calls `computePosition` of floating-ui.\n */\nexport type PositionAlgorithm = (\n    reference: ReferenceElement,\n    floating: FloatingElement,\n) => Promise<Placement>;\n"
  },
  {
    "path": "ts/lib/sveltelib/position/position-floating.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { ComputePositionConfig, FloatingElement, Middleware, Placement, ReferenceElement } from \"@floating-ui/dom\";\nimport { arrow, computePosition, flip, hide, inline, offset, shift } from \"@floating-ui/dom\";\n\nimport type { PositionAlgorithm } from \"./position-algorithm\";\n\nexport interface PositionFloatingArgs {\n    placement: Placement;\n    arrow: HTMLElement;\n    shift: number;\n    offset: number;\n    inline: boolean;\n    hideIfEscaped: boolean;\n    hideIfReferenceHidden: boolean;\n    hideCallback: (reason: string) => void;\n}\n\nfunction positionFloating({\n    placement,\n    arrow: arrowElement,\n    shift: shiftArg,\n    offset: offsetArg,\n    inline: inlineArg,\n    hideIfEscaped,\n    hideIfReferenceHidden,\n    hideCallback,\n}: PositionFloatingArgs): PositionAlgorithm {\n    return async function(\n        reference: ReferenceElement,\n        floating: FloatingElement,\n    ): Promise<Placement> {\n        const middleware: Middleware[] = [\n            flip(),\n            offset(offsetArg),\n            shift({ padding: shiftArg }),\n            arrow({ element: arrowElement, padding: 5 }),\n        ];\n\n        if (inlineArg) {\n            middleware.unshift(inline());\n        }\n\n        const computeArgs: Partial<ComputePositionConfig> = {\n            middleware,\n            placement,\n        };\n\n        if (hideIfEscaped) {\n            middleware.push(hide({ strategy: \"escaped\" }));\n        }\n\n        if (hideIfReferenceHidden) {\n            middleware.push(hide({ strategy: \"referenceHidden\" }));\n        }\n\n        const {\n            x,\n            y,\n            middlewareData,\n            placement: computedPlacement,\n        } = await computePosition(reference, floating, computeArgs);\n\n        if (middlewareData.hide?.escaped) {\n            hideCallback(\"escaped\");\n            return computedPlacement;\n        }\n\n        if (middlewareData.hide?.referenceHidden) {\n            hideCallback(\"referenceHidden\");\n            return computedPlacement;\n        }\n\n        Object.assign(floating.style, {\n            left: `${x}px`,\n            top: `${y}px`,\n        });\n\n        let rotation: number;\n        let arrowX: number | undefined;\n        let arrowY: number | undefined;\n\n        if (computedPlacement.startsWith(\"bottom\")) {\n            rotation = 45;\n            arrowX = middlewareData.arrow?.x;\n            arrowY = -5;\n        } else if (computedPlacement.startsWith(\"left\")) {\n            rotation = 135;\n            arrowX = floating.offsetWidth - 5;\n            arrowY = middlewareData.arrow?.y;\n        } else if (computedPlacement.startsWith(\"top\")) {\n            rotation = 225;\n            arrowX = middlewareData.arrow?.x;\n            arrowY = floating.offsetHeight - 5;\n        } /* if (computedPlacement.startsWith(\"right\")) */ else {\n            rotation = 315;\n            arrowX = -5;\n            arrowY = middlewareData.arrow?.y;\n        }\n\n        Object.assign(arrowElement.style, {\n            left: arrowX ? `${arrowX}px` : \"\",\n            top: arrowY ? `${arrowY}px` : \"\",\n            transform: `rotate(${rotation}deg)`,\n        });\n\n        return computedPlacement;\n    };\n}\n\nexport default positionFloating;\n"
  },
  {
    "path": "ts/lib/sveltelib/position/position-overlay.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { ComputePositionConfig, FloatingElement, Middleware, Placement, ReferenceElement } from \"@floating-ui/dom\";\nimport { computePosition, inline, offset } from \"@floating-ui/dom\";\n\nimport type { PositionAlgorithm } from \"./position-algorithm\";\n\nexport interface PositionOverlayArgs {\n    padding: number;\n    inline: boolean;\n    hideCallback: (reason: string) => void;\n}\n\nfunction positionOverlay({\n    padding,\n    inline: inlineArg,\n    hideCallback,\n}: PositionOverlayArgs): PositionAlgorithm {\n    return async function(\n        reference: ReferenceElement,\n        floating: FloatingElement,\n    ): Promise<Placement> {\n        const middleware: Middleware[] = inlineArg ? [inline()] : [];\n\n        const { width, height } = reference.getBoundingClientRect();\n\n        middleware.push(\n            offset({\n                mainAxis: -(height + padding),\n            }),\n        );\n\n        const computeArgs: Partial<ComputePositionConfig> = {\n            middleware,\n        };\n\n        const { x, y, middlewareData, placement } = await computePosition(\n            reference,\n            floating,\n            computeArgs,\n        );\n\n        // console.log(x, y)\n\n        if (middlewareData.hide?.escaped) {\n            hideCallback(\"escaped\");\n        }\n\n        if (middlewareData.hide?.referenceHidden) {\n            hideCallback(\"referenceHidden\");\n        }\n\n        Object.assign(floating.style, {\n            left: `${x}px`,\n            top: `${y}px`,\n            width: `${width + 2 * padding}px`,\n            height: `${height + 2 * padding}px`,\n        });\n\n        return placement;\n    };\n}\n\nexport default positionOverlay;\n"
  },
  {
    "path": "ts/lib/sveltelib/preferences.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\n/** Automatically saves to the backend on modification. */\nexport type PreferenceStore<T> = Writable<T>;\n\n/** Creates a store out of a preference getter, calling the setter when\n * changes are made. */\nexport async function autoSavingPrefs<T>(\n    getter: () => Promise<T>,\n    setter: (msg: T) => Promise<unknown>,\n): Promise<PreferenceStore<T>> {\n    let currentValue = await getter() as T;\n    const { subscribe, set: origSet } = writable(currentValue);\n\n    function set(value: T): void {\n        currentValue = value;\n        origSet(value);\n        setter(value);\n    }\n\n    function update(updater: (value: T) => T): void {\n        set(updater(currentValue));\n    }\n\n    return {\n        subscribe,\n        set,\n        update,\n    };\n}\n"
  },
  {
    "path": "ts/lib/sveltelib/resize-store.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Callback } from \"@tslib/typing\";\nimport type { Readable, Subscriber } from \"svelte/store\";\nimport { readable } from \"svelte/store\";\n\ninterface ResizeObserverArgs {\n    entries: ResizeObserverEntry[];\n    observer: ResizeObserver;\n}\n\nexport type ResizeStore = Readable<ResizeObserverArgs>;\n\n/**\n * A store wrapping a ResizeObserver. Automatically observes the target upon\n * first/last subscriber.\n *\n * @remarks\n * Should probably always be used in conjunction with `subscribeToUpdates`.\n */\nfunction resizeStore(target: Element): ResizeStore {\n    let setter: (args: ResizeObserverArgs) => void;\n\n    const observer = new ResizeObserver(\n        (entries: ResizeObserverEntry[], observer: ResizeObserver): void =>\n            setter({\n                entries,\n                observer,\n            }),\n    );\n\n    return readable(\n        { entries: [], observer },\n        (set: Subscriber<ResizeObserverArgs>): Callback => {\n            setter = set;\n            observer.observe(target);\n\n            return () => observer.unobserve(target);\n        },\n    );\n}\n\nexport default resizeStore;\n"
  },
  {
    "path": "ts/lib/sveltelib/shortcut.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { RegisterShortcutRestParams } from \"@tslib/shortcuts\";\nimport { registerShortcut } from \"@tslib/shortcuts\";\n\ninterface ShortcutParams {\n    action: (event: KeyboardEvent) => void;\n    keyCombination: string;\n    params?: RegisterShortcutRestParams;\n}\n\nexport function shortcut(\n    _node: Node,\n    { action, keyCombination, params }: ShortcutParams,\n): { destroy: () => void } {\n    const deregister = registerShortcut(action, keyCombination, params);\n\n    return {\n        destroy: deregister,\n    };\n}\n\nexport default shortcut;\n"
  },
  {
    "path": "ts/lib/sveltelib/store-subscribe.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Readable, Unsubscriber } from \"svelte/store\";\n\ninterface StoreAccessors {\n    subscribe: () => void;\n    unsubscribe: () => void;\n}\n\n/**\n * Helper function to prevent double (un)subscriptions\n */\nfunction storeSubscribe<T>(\n    store: Readable<T>,\n    callback: (value: T) => void,\n    start = true,\n): StoreAccessors {\n    function subscribe(): Unsubscriber {\n        return store.subscribe(callback);\n    }\n\n    let unsubscribe: Unsubscriber | null = start ? subscribe() : null;\n\n    function resubscribe(): void {\n        if (!unsubscribe) {\n            unsubscribe = subscribe();\n        }\n    }\n\n    function doUnsubscribe() {\n        unsubscribe?.();\n        unsubscribe = null;\n    }\n\n    return {\n        subscribe: resubscribe,\n        unsubscribe: doUnsubscribe,\n    };\n}\n\nexport default storeSubscribe;\n"
  },
  {
    "path": "ts/lib/sveltelib/subscribe-updates.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Readable, Subscriber, Unsubscriber } from \"svelte/store\";\n\n/**\n * In some cases, we only care for updates, and not the initial\n * value of a store, e.g. when the store wraps events.\n * This also means, we can not use the special store syntax.\n */\nfunction subscribeToUpdates<T>(\n    store: Readable<T>,\n    subscription: Subscriber<T>,\n): Unsubscriber {\n    let first = true;\n\n    return store.subscribe((value: T): void => {\n        if (first) {\n            first = false;\n        } else {\n            subscription(value);\n        }\n    });\n}\n\nexport default subscribeToUpdates;\n"
  },
  {
    "path": "ts/lib/sveltelib/theme.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { registerPackage } from \"@tslib/runtime-require\";\nimport { get, readable } from \"svelte/store\";\n\ninterface ThemeInfo {\n    isDark: boolean;\n}\n\nfunction getThemeFromRoot(): ThemeInfo {\n    return {\n        isDark: document.documentElement.classList.contains(\"night-mode\"),\n    };\n}\n\nlet setPageTheme: ((theme: ThemeInfo) => void) | null = null;\n/** The current theme that applies to this document/shadow root. When\npreviewing cards in the card layout screen, this may not match the\ntheme Anki is using in its UI. */\nexport const pageTheme = readable(getThemeFromRoot(), (set) => {\n    setPageTheme = set;\n});\n// ensure setPageTheme is set immediately\nget(pageTheme);\n\n// Update theme when root element's class changes.\nconst observer = new MutationObserver((_mutationsList, _observer) => {\n    setPageTheme!(getThemeFromRoot());\n});\nobserver.observe(document.documentElement, { attributeFilter: [\"class\"] });\n\nregisterPackage(\"anki/theme\", {\n    pageTheme,\n});\n"
  },
  {
    "path": "ts/lib/sveltelib/toggleable.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Writable } from \"svelte/store\";\nimport { writable } from \"svelte/store\";\n\nexport interface Toggleable extends Writable<boolean> {\n    toggle: () => void;\n    on: () => void;\n    off: () => void;\n}\n\nfunction toggleable(defaultValue: boolean): Toggleable {\n    const store = writable(defaultValue) as Toggleable;\n\n    function toggle(): void {\n        store.update((value) => !value);\n    }\n\n    store.toggle = toggle;\n\n    function on(): void {\n        store.set(true);\n    }\n\n    store.on = on;\n\n    function off(): void {\n        store.set(false);\n    }\n\n    store.off = off;\n\n    return store;\n}\n\nexport default toggleable;\n"
  },
  {
    "path": "ts/lib/tag-editor/AutocompleteItem.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let selected = false;\n    export let active = false;\n    export let suggestion: string; // used by add-ons to target individual suggestions\n\n    let buttonRef: HTMLElement;\n\n    $: if (selected && buttonRef) {\n        /* buttonRef.scrollIntoView({ behavior: \"smooth\", block: \"start\" }); */\n        /* TODO will not work on Gecko */\n        (buttonRef as any).scrollIntoViewIfNeeded({\n            behavior: \"smooth\",\n            block: \"start\",\n        });\n    }\n</script>\n\n<div\n    bind:this={buttonRef}\n    tabindex=\"-1\"\n    class=\"autocomplete-item\"\n    class:selected\n    class:active\n    data-addon-suggestion={suggestion}\n    on:mousedown|preventDefault\n    on:mouseup\n    on:mouseenter\n    on:mouseleave\n    role=\"button\"\n>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    @use \"../sass/button-mixins\" as button;\n\n    .autocomplete-item {\n        padding: 4px 8px;\n\n        text-align: start;\n        white-space: nowrap;\n        flex-grow: 1;\n        border-radius: 0;\n        border: 1px solid transparent;\n        &:not(:first-child) {\n            border-top-color: var(--border-subtle);\n        }\n\n        &:hover {\n            @include button.base($with-disabled: false, $active-class: active);\n        }\n        &.selected {\n            @include button.base(\n                $primary: true,\n                $with-disabled: false,\n                $active-class: active\n            );\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/Tag.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    let className: string = \"\";\n    export { className as class };\n\n    export let tagName: string; // used by add-ons to target individual tag elements\n    export let tooltip: string | undefined = undefined;\n    export let selected = false;\n\n    const dispatch = createEventDispatcher();\n\n    let flashing: boolean = false;\n\n    export function flash(): void {\n        flashing = true;\n        setTimeout(() => (flashing = false), 300);\n    }\n\n    let button: HTMLButtonElement;\n\n    onMount(() => dispatch(\"mount\", { button }));\n</script>\n\n<button\n    bind:this={button}\n    class=\"tag d-inline-flex align-items-center text-nowrap ps-2 pe-1 {className}\"\n    class:selected\n    class:flashing\n    tabindex=\"-1\"\n    title={tooltip}\n    data-addon-tag={tagName}\n    on:mousemove\n    on:click\n>\n    <slot />\n</button>\n\n<style lang=\"scss\">\n    @use \"../sass/button-mixins\" as button;\n\n    @keyframes flash {\n        0% {\n            filter: invert(0);\n        }\n        50% {\n            filter: invert(0.4);\n        }\n        100% {\n            filter: invert(0);\n        }\n    }\n\n    .tag {\n        @include button.base($with-active: false, $with-disabled: false);\n\n        vertical-align: middle;\n        font-size: var(--font-size);\n        padding: 0;\n\n        --border-color: var(--border);\n\n        border: 1px solid var(--border-color) !important;\n        border-radius: 5px;\n\n        &:focus,\n        &:active {\n            outline: none;\n            box-shadow: none;\n        }\n\n        &.flashing {\n            animation: flash 0.3s linear;\n        }\n\n        &.selected {\n            box-shadow: 0 0 0 2px var(--border-focus);\n            --border-color: var(--border-focus);\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagDeleteBadge.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { deleteIcon } from \"$lib/components/icons\";\n\n    let className: string = \"\";\n    export { className as class };\n</script>\n\n<Badge class=\"d-flex align-items-center ms-1 {className}\" on:click iconSize={80}>\n    <Icon icon={deleteIcon} />\n</Badge>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagEditMode.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher } from \"svelte\";\n\n    import TagDeleteBadge from \"./TagDeleteBadge.svelte\";\n    import TagWithTooltip from \"./TagWithTooltip.svelte\";\n\n    export let name: string;\n    let className: string = \"\";\n    export { className as class };\n\n    export let tooltip: string;\n\n    export let selected: boolean;\n    export let active: boolean;\n    export let shorten: boolean;\n\n    export let flash: () => void;\n\n    const dispatch = createEventDispatcher();\n\n    function deleteTag(): void {\n        dispatch(\"tagdelete\");\n    }\n</script>\n\n<TagWithTooltip\n    {name}\n    class={className}\n    {tooltip}\n    {selected}\n    {active}\n    {shorten}\n    {flash}\n    on:tagrange\n    on:tagselect\n    on:tagclick={() => dispatch(\"tagedit\")}\n    let:selectMode\n    let:hoverClass\n>\n    <TagDeleteBadge\n        class={hoverClass}\n        on:click={(evt) => {\n            if (!selectMode) {\n                deleteTag();\n                evt.stopPropagation();\n            }\n        }}\n    />\n</TagWithTooltip>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { completeTag } from \"@generated/backend\";\n    import { tagActionsShortcutsKey } from \"@tslib/context-keys\";\n    import { isArrowDown, isArrowUp } from \"@tslib/keys\";\n    import { createEventDispatcher, setContext, tick } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n    import { writable } from \"svelte/store\";\n\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import { execCommand } from \"$lib/domlib\";\n\n    import { TagOptionsButton } from \"./tag-options-button\";\n    import TagEditMode from \"./TagEditMode.svelte\";\n    import TagInput from \"./TagInput.svelte\";\n    import type { Tag as TagType } from \"./tags\";\n    import {\n        attachId,\n        getName,\n        replaceWithColons,\n        replaceWithUnicodeSeparator,\n    } from \"./tags\";\n    import TagSpacer from \"./TagSpacer.svelte\";\n    import WithAutocomplete from \"./WithAutocomplete.svelte\";\n\n    export let tags: Writable<string[]>;\n    export let keyCombination: string = \"Control+Shift+T\";\n\n    const selectAllShortcut = \"Control+A\";\n    const copyShortcut = \"Control+C\";\n    const removeShortcut = \"Backspace\";\n    setContext(tagActionsShortcutsKey, {\n        selectAllShortcut,\n        copyShortcut,\n        removeShortcut,\n    });\n\n    let tagTypes: TagType[];\n    function tagsToTagTypes(tags: string[]): void {\n        tagTypes = tags.map(\n            (tag: string): TagType => attachId(replaceWithUnicodeSeparator(tag)),\n        );\n    }\n\n    $: tagsToTagTypes($tags);\n\n    const show = writable(false);\n    const dispatch = createEventDispatcher();\n    const noSuggestions = Promise.resolve([]);\n    let suggestionsPromise: Promise<string[]> = noSuggestions;\n\n    function saveTags(): void {\n        const tags = tagTypes.map((tag: TagType) => tag.name).map(replaceWithColons);\n        dispatch(\"tagsupdate\", { tags });\n\n        suggestionsPromise = noSuggestions;\n    }\n\n    let active: number | null = null;\n    let activeAfterBlur: number | null = null;\n    let activeName = \"\";\n    let activeInput: HTMLInputElement;\n\n    let autocomplete: any;\n    let autocompleteDisabled: boolean = false;\n\n    async function fetchSuggestions(input: string): Promise<string[]> {\n        const { tags } = await completeTag({ input, matchLimit: 500 });\n        return tags;\n    }\n\n    const withoutSingleColonAtStartOrEnd = /^:?([^:].*?[^:]):?$/;\n\n    function updateSuggestions(): void {\n        const activeTag = tagTypes[active!];\n        const activeName = activeTag!.name;\n\n        autocompleteDisabled = activeName.length === 0;\n\n        if (autocompleteDisabled) {\n            suggestionsPromise = noSuggestions;\n        } else {\n            const withColons = replaceWithColons(activeName);\n            const withoutSingleColons = withoutSingleColonAtStartOrEnd.test(withColons)\n                ? withColons.replace(withoutSingleColonAtStartOrEnd, \"$1\")\n                : withColons;\n\n            suggestionsPromise = fetchSuggestions(withoutSingleColons).then(\n                (names: string[]): string[] => {\n                    autocompleteDisabled = names.length === 0;\n                    return names.map(replaceWithUnicodeSeparator);\n                },\n            );\n        }\n    }\n\n    function onAutocomplete(selected: string): void {\n        const activeTag = tagTypes[active!];\n\n        activeName = selected ?? activeTag.name;\n        const inputEnd = activeInput.value.length;\n        activeInput.setSelectionRange(inputEnd, inputEnd);\n    }\n\n    async function updateTagName(tag: TagType): Promise<void> {\n        tag.name = activeName;\n        tagTypes = tagTypes;\n\n        await tick();\n        if (activeInput) {\n            autocomplete.update();\n        }\n    }\n\n    function setActiveAfterBlur(value: number): void {\n        if (activeAfterBlur === null) {\n            activeAfterBlur = value;\n        }\n    }\n\n    export function appendEmptyTag(): void {\n        // used by tag badge and tag spacer\n        deselect();\n        const lastTag = tagTypes[tagTypes.length - 1];\n\n        if (!lastTag || lastTag.name.length > 0) {\n            appendTagAndFocusAt(tagTypes.length - 1, \"\");\n        }\n\n        const tagsHadFocus = active === null;\n        active = null;\n\n        if (tagsHadFocus) {\n            decideNextActive();\n        }\n    }\n\n    function appendTagAndFocusAt(index: number, name: string): void {\n        tagTypes.splice(index + 1, 0, attachId(name));\n        tagTypes = tagTypes;\n        setActiveAfterBlur(index + 1);\n    }\n\n    function isActiveNameUniqueAt(index: number): boolean {\n        const names = tagTypes.map(getName);\n        names.splice(index, 1);\n\n        const contained = names.indexOf(activeName);\n        if (contained >= 0) {\n            tagTypes[contained >= index ? contained + 1 : contained].flash();\n            return false;\n        }\n\n        return true;\n    }\n\n    async function splitTag(index: number, start: number, end: number): Promise<void> {\n        const current = activeName.slice(0, start);\n        const splitOff = activeName.slice(end);\n\n        activeName = current;\n        // await tag to update its name, so it can normalize correctly\n        await tick();\n\n        appendTagAndFocusAt(index, splitOff);\n        active = null;\n        await tick();\n\n        if (index === active) {\n            // splitOff tag was rejected\n            return;\n        }\n        activeInput.setSelectionRange(0, 0);\n    }\n\n    function insertTagKeepFocus(index: number): void {\n        if (isActiveNameUniqueAt(index)) {\n            tagTypes.splice(index, 0, attachId(activeName));\n            active!++;\n            tagTypes = tagTypes;\n        }\n    }\n\n    function deleteTagAt(index: number): TagType {\n        const deleted = tagTypes.splice(index, 1)[0];\n        tagTypes = tagTypes;\n\n        if (activeAfterBlur !== null && activeAfterBlur > index) {\n            activeAfterBlur--;\n        }\n\n        return deleted;\n    }\n\n    function isFirst(index: number): boolean {\n        return index === 0;\n    }\n\n    function isLast(index: number): boolean {\n        return index === tagTypes.length - 1;\n    }\n\n    function joinWithPreviousTag(index: number): void {\n        if (isFirst(index)) {\n            return;\n        }\n\n        const deleted = deleteTagAt(index - 1);\n        activeName = deleted.name + activeName;\n        active!--;\n        updateTagName(tagTypes[active!]);\n    }\n\n    function joinWithNextTag(index: number): void {\n        if (isLast(index)) {\n            return;\n        }\n\n        const deleted = deleteTagAt(index + 1);\n        activeName = activeName + deleted.name;\n        updateTagName(tagTypes[active!]);\n    }\n\n    function moveToPreviousTag(index: number): void {\n        if (isFirst(index)) {\n            return;\n        }\n\n        activeAfterBlur = index - 1;\n        active = null;\n        activeInput.blur();\n    }\n\n    async function moveToNextTag(index: number): Promise<void> {\n        if (isLast(index)) {\n            if (activeName.length !== 0) {\n                appendTagAndFocusAt(index, \"\");\n                active = null;\n            }\n            return;\n        }\n\n        activeAfterBlur = index + 1;\n        active = null;\n        activeInput.blur();\n\n        await tick();\n        activeInput.setSelectionRange(0, 0);\n    }\n\n    function deleteTagIfNotUnique(tag: TagType, index: number): void {\n        if (!tagTypes.includes(tag)) {\n            // already deleted\n            return;\n        }\n\n        if (!isActiveNameUniqueAt(index)) {\n            deleteTagAt(index);\n        }\n    }\n\n    function decideNextActive() {\n        active = activeAfterBlur;\n        activeAfterBlur = null;\n    }\n\n    function onKeydown(event: KeyboardEvent): void {\n        if (isArrowUp(event)) {\n            autocomplete.selectPrevious();\n            event.preventDefault();\n            return;\n        } else if (isArrowDown(event)) {\n            autocomplete.selectNext();\n            event.preventDefault();\n            return;\n        }\n\n        switch (event.code) {\n            case \"Tab\":\n                if (!$show) {\n                    break;\n                } else if (event.shiftKey) {\n                    autocomplete.selectPrevious();\n                } else {\n                    autocomplete.selectNext();\n                }\n                event.preventDefault();\n                break;\n\n            case \"Enter\":\n                autocomplete.chooseSelected();\n                event.preventDefault();\n                break;\n        }\n    }\n\n    let selectionAnchor: number | null = null;\n    let selectionFocus: number | null = null;\n\n    function select(index: number) {\n        tagTypes[index].selected = !tagTypes[index].selected;\n        tagTypes = tagTypes;\n\n        selectionAnchor = index;\n    }\n\n    function selectRange(index: number) {\n        if (selectionAnchor === null) {\n            select(index);\n            return;\n        }\n\n        selectionFocus = index;\n\n        const from = Math.min(selectionAnchor, selectionFocus);\n        const to = Math.max(selectionAnchor, selectionFocus);\n\n        for (let index = from; index <= to; index++) {\n            tagTypes[index].selected = true;\n        }\n\n        tagTypes = tagTypes;\n    }\n\n    function deselect() {\n        tagTypes = tagTypes.map(\n            (tag: TagType): TagType => ({ ...tag, selected: false }),\n        );\n        selectionAnchor = null;\n        selectionFocus = null;\n    }\n\n    function deselectIfLeave(event: FocusEvent) {\n        const toolbar = event.currentTarget as HTMLDivElement;\n        if (\n            event.relatedTarget === null ||\n            !toolbar.contains(event.relatedTarget as Node)\n        ) {\n            deselect();\n        }\n    }\n\n    /* TODO replace with navigator.clipboard once available */\n    function copyToClipboard(content: string): void {\n        const textarea = document.createElement(\"textarea\");\n        textarea.value = content;\n        textarea.setAttribute(\"readonly\", \"\");\n        textarea.style.position = \"absolute\";\n        textarea.style.left = \"-9999px\";\n        document.body.appendChild(textarea);\n        textarea.select();\n        execCommand(\"copy\");\n        document.body.removeChild(textarea);\n    }\n\n    function selectAllTags() {\n        for (const tag of tagTypes) {\n            tag.selected = true;\n        }\n\n        tagTypes = tagTypes;\n    }\n\n    function copySelectedTags() {\n        const content = tagTypes\n            .filter((tag) => tag.selected)\n            .map((tag) => replaceWithColons(tag.name))\n            .join(\"\\n\");\n        copyToClipboard(content);\n        deselect();\n    }\n\n    function deleteSelectedTags() {\n        tagTypes\n            .map((tag, index): [boolean, number] => [tag.selected, index])\n            .filter(([selected]) => selected)\n            .reverse()\n            .forEach(([, index]) => deleteTagAt(index));\n        deselect();\n        saveTags();\n    }\n\n    let height: number;\n    let badgeHeight: number;\n\n    // typically correct for rows < 7\n    $: assumedRows = Math.floor(height / badgeHeight);\n    $: shortenTags = shortenTags || assumedRows > 2;\n    $: anyTagsSelected = tagTypes.some((tag) => tag.selected);\n</script>\n\n{#if anyTagsSelected}\n    <Shortcut keyCombination={selectAllShortcut} on:action={selectAllTags} />\n    <Shortcut keyCombination={copyShortcut} on:action={copySelectedTags} />\n    <Shortcut keyCombination={removeShortcut} on:action={deleteSelectedTags} />\n{/if}\n\n<div class=\"tag-editor\" on:focusout={deselectIfLeave} bind:offsetHeight={height}>\n    <TagOptionsButton\n        bind:badgeHeight\n        tagsSelected={anyTagsSelected}\n        on:tagselectall={selectAllTags}\n        on:tagcopy={copySelectedTags}\n        on:tagdelete={deleteSelectedTags}\n        on:tagappend={appendEmptyTag}\n        {keyCombination}\n        --icon-align=\"baseline\"\n    />\n\n    {#each tagTypes as tag, index (tag.id)}\n        <div class=\"tag-relative\" class:hide-tag={index === active}>\n            <TagEditMode\n                class=\"ms-0\"\n                name={index === active ? activeName : tag.name}\n                tooltip={tag.name}\n                active={index === active}\n                shorten={shortenTags}\n                bind:flash={tag.flash}\n                bind:selected={tag.selected}\n                on:tagedit={() => {\n                    active = index;\n                    deselect();\n                }}\n                on:tagselect={() => select(index)}\n                on:tagrange={() => selectRange(index)}\n                on:tagdelete={() => {\n                    deselect();\n                    deleteTagAt(index);\n                    saveTags();\n                }}\n            />\n\n            {#if index === active}\n                <WithAutocomplete\n                    {suggestionsPromise}\n                    {show}\n                    on:update={updateSuggestions}\n                    on:select={({ detail }) => onAutocomplete(detail.selected)}\n                    on:choose={({ detail }) => {\n                        onAutocomplete(detail.chosen);\n                        splitTag(index, detail.chosen.length, detail.chosen.length);\n                    }}\n                    let:createAutocomplete\n                >\n                    <TagInput\n                        id={tag.id}\n                        class=\"position-absolute start-0 top-0 bottom-0 ps-2 py-0\"\n                        disabled={autocompleteDisabled}\n                        bind:name={activeName}\n                        bind:input={activeInput}\n                        on:focus={() => {\n                            dispatch(\"tagsFocused\");\n                            activeName = tag.name;\n                            autocomplete = createAutocomplete();\n                        }}\n                        on:keydown={onKeydown}\n                        on:keyup={() => {\n                            if (activeName.length === 0) {\n                                show?.set(false);\n                            }\n                        }}\n                        on:taginput={() => updateTagName(tag)}\n                        on:tagsplit={({ detail }) =>\n                            splitTag(index, detail.start, detail.end)}\n                        on:tagadd={() => insertTagKeepFocus(index)}\n                        on:tagdelete={() => deleteTagAt(index)}\n                        on:tagselectall={async () => {\n                            if (tagTypes.length <= 1) {\n                                // Noop if no other tags exist\n                                return;\n                            }\n\n                            activeInput.blur();\n                            // Ensure blur events are processed first\n                            await tick();\n\n                            selectAllTags();\n                        }}\n                        on:tagjoinprevious={() => joinWithPreviousTag(index)}\n                        on:tagjoinnext={() => joinWithNextTag(index)}\n                        on:tagmoveprevious={() => moveToPreviousTag(index)}\n                        on:tagmovenext={() => moveToNextTag(index)}\n                        on:tagaccept={() => {\n                            deleteTagIfNotUnique(tag, index);\n                            if (tag) {\n                                updateTagName(tag);\n                            }\n                            saveTags();\n                            decideNextActive();\n                        }}\n                    />\n                </WithAutocomplete>\n            {/if}\n        </div>\n    {/each}\n\n    <TagSpacer on:click={appendEmptyTag} />\n</div>\n\n<style lang=\"scss\">\n    .tag-editor {\n        display: flex;\n        flex-grow: 1;\n        flex-flow: row wrap;\n        align-items: flex-end;\n        background: var(--canvas-elevated);\n        border: 1px solid var(--border);\n        border-radius: var(--border-radius);\n        padding: 6px;\n        margin: 1px 3px 3px 1px;\n\n        &:focus-within {\n            outline-offset: -1px;\n            outline: 2px solid var(--border-focus);\n        }\n    }\n\n    .tag-relative {\n        position: relative;\n        padding: 0 1px;\n    }\n\n    .hide-tag :global(.tag) {\n        visibility: hidden;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    import { derived, writable } from \"svelte/store\";\n\n    export const currentTagInput = writable<HTMLInputElement | null>(null);\n\n    export const commitTagEdits = derived<typeof currentTagInput, () => void>(\n        currentTagInput,\n        ($currentTagInput) => () => $currentTagInput?.blur(),\n    );\n</script>\n\n<script lang=\"ts\">\n    import { tagActionsShortcutsKey } from \"@tslib/context-keys\";\n    import { isArrowLeft, isArrowRight } from \"@tslib/keys\";\n    import { registerShortcut } from \"@tslib/shortcuts\";\n    import { createEventDispatcher, getContext, onMount, tick } from \"svelte\";\n    import type { ActionReturn } from \"svelte/action\";\n\n    import {\n        delimChar,\n        normalizeTagname,\n        replaceWithColons,\n        replaceWithUnicodeSeparator,\n    } from \"./tags\";\n\n    export let id: string | undefined = undefined;\n    let className: string = \"\";\n    export { className as class };\n\n    export let name: string;\n    export let input: HTMLInputElement;\n    export let disabled: boolean;\n\n    const dispatch = createEventDispatcher();\n\n    function isCollapsed(): boolean {\n        return input.selectionStart === input.selectionEnd;\n    }\n\n    function caretAtStart(): boolean {\n        return input.selectionStart === 0 && input.selectionEnd === 0;\n    }\n\n    function caretAtEnd(): boolean {\n        return (\n            input.selectionStart === input.value.length &&\n            input.selectionEnd === input.value.length\n        );\n    }\n\n    function setPosition(position: number): void {\n        input.setSelectionRange(position, position);\n    }\n\n    function isEmpty(): boolean {\n        return name.length === 0;\n    }\n\n    async function joinWithPreviousTag(event: Event): Promise<void> {\n        const length = input.value.length;\n        dispatch(\"tagjoinprevious\");\n\n        await tick();\n        setPosition(input.value.length - length);\n\n        event.preventDefault();\n    }\n\n    async function maybeDeleteDelimiter(event: Event, position: number): Promise<void> {\n        if (position > name.length) {\n            return;\n        }\n\n        const nameUptoCaret = name.slice(0, position);\n\n        if (nameUptoCaret.endsWith(delimChar)) {\n            name = name.slice(0, position - 1) + name.slice(position, name.length);\n            await tick();\n\n            event.preventDefault();\n            setPosition(position - 1);\n            dispatch(\"taginput\");\n        }\n    }\n\n    function onBackspace(event: KeyboardEvent): void {\n        if (caretAtStart()) {\n            joinWithPreviousTag(event);\n        } else {\n            maybeDeleteDelimiter(event, input.selectionStart!);\n        }\n    }\n\n    async function joinWithNextTag(event: Event): Promise<void> {\n        const length = input.value.length;\n        dispatch(\"tagjoinnext\");\n\n        await tick();\n        setPosition(length);\n\n        event.preventDefault();\n    }\n\n    function onDelete(event: KeyboardEvent): void {\n        if (caretAtEnd()) {\n            joinWithNextTag(event);\n        } else {\n            maybeDeleteDelimiter(event, input.selectionStart! + 2);\n        }\n    }\n\n    function onBlur(): void {\n        name = normalizeTagname(name);\n\n        if (name.length === 0) {\n            dispatch(\"tagdelete\");\n        }\n\n        dispatch(\"tagaccept\");\n    }\n\n    function onEnter(event: Event): void {\n        dispatch(\"tagsplit\", { start: input.selectionStart, end: input.selectionEnd });\n        event.preventDefault();\n    }\n\n    async function onDelimiter(event: Event, single: boolean = false): Promise<void> {\n        const positionStart = input.selectionStart!;\n        const positionEnd = input.selectionEnd!;\n\n        const before = name.slice(0, positionStart);\n        const after = name.slice(positionEnd, name.length);\n\n        if (before.endsWith(delimChar)) {\n            event.preventDefault();\n            event.stopPropagation();\n            return;\n        } else if (before.endsWith(\":\")) {\n            event.preventDefault();\n            name = `${before.slice(0, -1)}${delimChar}${name.slice(\n                positionEnd,\n                name.length,\n            )}`;\n\n            await tick();\n            setPosition(positionStart);\n            dispatch(\"taginput\");\n            return;\n        } else if (after.startsWith(\":\")) {\n            event.preventDefault();\n            name = `${before}${delimChar}${name.slice(positionEnd + 1, name.length)}`;\n        } else if (single) {\n            return;\n        } else {\n            event.preventDefault();\n            name = `${before}${delimChar}${after}`;\n        }\n\n        await tick();\n        setPosition(positionStart + 1);\n        dispatch(\"taginput\");\n    }\n\n    function onKeydown(event: KeyboardEvent): void {\n        switch (event.key) {\n            case \"Enter\":\n                onEnter(event);\n                break;\n\n            case \"Backspace\":\n                if (isCollapsed()) {\n                    onBackspace(event);\n                }\n                break;\n\n            case \"Delete\":\n                if (isCollapsed()) {\n                    onDelete(event);\n                }\n                break;\n        }\n\n        if (isArrowLeft(event)) {\n            if (isEmpty()) {\n                joinWithPreviousTag(event);\n            } else if (caretAtStart()) {\n                dispatch(\"tagmoveprevious\");\n                event.preventDefault();\n            }\n        } else if (isArrowRight(event)) {\n            if (isEmpty()) {\n                joinWithNextTag(event);\n            } else if (caretAtEnd()) {\n                dispatch(\"tagmovenext\");\n                event.preventDefault();\n            }\n        } else if (event.key === \" \") {\n            onDelimiter(event, false);\n        } else if (event.key === \":\") {\n            onDelimiter(event, true);\n        }\n    }\n\n    function onCopy(event: ClipboardEvent): void {\n        const selection = document.getSelection();\n        event.clipboardData!.setData(\n            \"text/plain\",\n            replaceWithColons(selection!.toString()),\n        );\n    }\n\n    async function onCut(event: ClipboardEvent): Promise<void> {\n        onCopy(event);\n\n        const s = input.selectionStart!;\n        const e = input.selectionEnd!;\n        name = name.slice(0, s) + name.slice(e);\n\n        await tick();\n        setPosition(s);\n        dispatch(\"taginput\");\n    }\n\n    function onPaste(event: ClipboardEvent): void {\n        if (!event.clipboardData) {\n            return;\n        }\n\n        const pasted = name + event.clipboardData.getData(\"text/plain\");\n        const splitted = pasted\n            .split(/\\s+/)\n            .map(normalizeTagname)\n            .filter((name: string) => name.length > 0)\n            .map(replaceWithUnicodeSeparator);\n\n        if (splitted.length === 0) {\n            return;\n        }\n\n        const last = splitted.pop()!;\n\n        for (const pastedName of splitted.reverse()) {\n            name = pastedName;\n            dispatch(\"tagadd\");\n        }\n\n        name = last;\n    }\n\n    function onSelectAll(event: KeyboardEvent) {\n        if (name.length === 0) {\n            event.preventDefault();\n            event.stopPropagation();\n            dispatch(\"tagselectall\");\n        }\n    }\n\n    const { selectAllShortcut } =\n        getContext<Record<string, string>>(tagActionsShortcutsKey);\n\n    onMount(() => {\n        registerShortcut(onSelectAll, selectAllShortcut, { target: input });\n        input.focus();\n    });\n\n    function updateCurrent(input: HTMLInputElement): ActionReturn<any> {\n        $currentTagInput = input;\n        return {\n            destroy(): void {\n                if ($currentTagInput === input) {\n                    $currentTagInput = null;\n                }\n            },\n        };\n    }\n</script>\n\n<input\n    {id}\n    class=\"tag-input {className}\"\n    class:disabled\n    bind:this={input}\n    bind:value={name}\n    type=\"text\"\n    tabindex=\"-1\"\n    size=\"1\"\n    on:focus\n    on:blur|preventDefault={onBlur}\n    on:keydown={onKeydown}\n    on:keydown\n    on:keyup\n    on:input={() => dispatch(\"taginput\")}\n    on:copy|preventDefault={onCopy}\n    on:cut|preventDefault={onCut}\n    on:paste|preventDefault={onPaste}\n    use:updateCurrent\n/>\n\n<style lang=\"scss\">\n    .tag-input {\n        width: 100%;\n        color: var(--fg);\n        background: none;\n        resize: none;\n        appearance: none;\n        font: inherit;\n        font-size: var(--font-size);\n        outline: none;\n        border: none;\n        margin: 0;\n    }\n\n    .tag-input {\n        /* recreates positioning of Tag component\n         * so that the text does not move when accepting */\n        border: 1px solid transparent !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagSpacer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<!-- on:click allows clicking focus to the tag editor -->\n<!-- svelte-ignore a11y-click-events-have-key-events -->\n<div class=\"tag-spacer\" on:click role=\"textbox\" tabindex=\"-1\">\n    <br />\n</div>\n\n<style lang=\"scss\">\n    .tag-spacer {\n        cursor: text;\n        flex-grow: 1;\n        align-self: stretch;\n        margin-top: 3px;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagWithTooltip.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { controlPressed, shiftPressed } from \"@tslib/keys\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import WithTooltip from \"$lib/components/WithTooltip.svelte\";\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import Tag from \"./Tag.svelte\";\n    import { delimChar } from \"./tags\";\n\n    export let name: string;\n    let className: string = \"\";\n    export { className as class };\n\n    export let tooltip: string;\n\n    export let selected: boolean;\n    export let active: boolean;\n    export let shorten: boolean;\n\n    export let flash: () => void;\n\n    const dispatch = createEventDispatcher();\n\n    let control = false;\n    let shift = false;\n\n    $: selectMode = control || shift;\n\n    function setControlShift(event: KeyboardEvent | MouseEvent): void {\n        control = controlPressed(event);\n        shift = shiftPressed(event);\n    }\n\n    function onClick(): void {\n        if (shift) {\n            dispatch(\"tagrange\");\n        } else if (control) {\n            dispatch(\"tagselect\");\n        } else {\n            dispatch(\"tagclick\");\n        }\n    }\n\n    function processTagName(name: string): string {\n        const parts = name.split(delimChar);\n\n        if (parts.length === 1) {\n            return name;\n        }\n\n        return `…${delimChar}` + parts[parts.length - 1];\n    }\n\n    function hasMultipleParts(name: string): boolean {\n        return name.split(delimChar).length > 1;\n    }\n    const hoverClass = \"tag-icon-hover\";\n</script>\n\n<svelte:body on:keydown={setControlShift} on:keyup={setControlShift} />\n\n<div\n    class:select-mode={selectMode}\n    class:night-mode={$pageTheme.isDark}\n    class:empty={name === \"\"}\n>\n    {#if active}\n        <Tag\n            class={className}\n            tagName={name}\n            on:mousemove={setControlShift}\n            on:click={onClick}\n        >\n            {name}\n            <slot {selectMode} {hoverClass} />\n        </Tag>\n    {:else if shorten && hasMultipleParts(name)}\n        <WithTooltip {tooltip} trigger=\"hover\" placement=\"top\" let:createTooltip>\n            <Tag\n                class={className}\n                tagName={name}\n                bind:flash\n                bind:selected\n                on:mousemove={setControlShift}\n                on:click={onClick}\n                on:mount={(event) => createTooltip(event.detail.button)}\n            >\n                <span>{processTagName(name)}</span>\n                <slot {selectMode} {hoverClass} />\n            </Tag>\n        </WithTooltip>\n    {:else}\n        <Tag\n            class={className}\n            tagName={name}\n            bind:flash\n            bind:selected\n            on:mousemove={setControlShift}\n            on:click={onClick}\n        >\n            <span>{name}</span>\n            <slot {selectMode} {hoverClass} />\n        </Tag>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .select-mode :global(button:hover) {\n        display: contents;\n        cursor: crosshair;\n\n        :global(.tag-icon-hover) {\n            opacity: 0;\n        }\n    }\n\n    :global(.tag-icon-hover svg:hover) {\n        border-radius: 5px;\n\n        $white-translucent: rgb(255 255 255 / 0.35);\n        $dark-translucent: rgb(0 0 0 / 0.1);\n\n        background-color: $dark-translucent;\n\n        .night-mode & {\n            background-color: $white-translucent;\n        }\n    }\n\n    .empty {\n        visibility: hidden;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/TagsRow.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { writable } from \"svelte/store\";\n\n    import Col from \"$lib/components/Col.svelte\";\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import type { Breakpoint } from \"$lib/components/types\";\n\n    import TagEditor from \"./TagEditor.svelte\";\n\n    export let tags: string[];\n    export let keyCombination: string | undefined = undefined;\n    export let breakpoint: Breakpoint = \"md\";\n\n    const tagsWritable = writable<string[]>(tags);\n</script>\n\n<Row --cols={13}>\n    <Col --col-size={7} {breakpoint}>\n        <slot />\n    </Col>\n    <Col --col-size={6} {breakpoint}>\n        <ConfigInput>\n            <TagEditor\n                tags={tagsWritable}\n                on:tagsupdate={({ detail }) => (tags = detail.tags)}\n                {keyCombination}\n            />\n            <RevertButton slot=\"revert\" bind:value={$tagsWritable} defaultValue={[]} />\n        </ConfigInput>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/lib/tag-editor/WithAutocomplete.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { isApplePlatform } from \"@tslib/platform\";\n    import { createEventDispatcher, tick } from \"svelte\";\n    import type { Writable } from \"svelte/store\";\n\n    import Popover from \"$lib/components/Popover.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    import AutocompleteItem from \"./AutocompleteItem.svelte\";\n\n    export let suggestionsPromise: Promise<string[]>;\n    export let show: Writable<boolean>;\n\n    let suggestionsItems: string[] = [];\n    $: suggestionsPromise.then((items) => {\n        show.set(items.length > 0);\n        if (isApplePlatform() && navigator.userAgent.match(/Chrome\\/77/)) {\n            items = items.slice(0, 10);\n        }\n        suggestionsItems = items;\n    });\n\n    let selected: number | null = null;\n    let active: boolean = false;\n\n    const dispatch = createEventDispatcher<{\n        update: void;\n        /* Selected should be displayed to the user, but it is not accepted */\n        select: { selected: string };\n        /* Autocompletion action should finish with \"chosen\" */\n        choose: { chosen: string };\n    }>();\n\n    /**\n     * Select as currently highlighted item\n     */\n    function incrementSelected(): void {\n        if (selected === null) {\n            selected = 0;\n        } else if (selected >= suggestionsItems.length - 1) {\n            selected = null;\n        } else {\n            selected++;\n        }\n    }\n\n    function decrementSelected(): void {\n        if (selected === null) {\n            selected = suggestionsItems.length - 1;\n        } else if (selected === 0) {\n            selected = null;\n        } else {\n            selected--;\n        }\n    }\n\n    async function updateSelected(): Promise<void> {\n        dispatch(\"select\", { selected: suggestionsItems[selected ?? -1] });\n    }\n\n    async function selectNext(): Promise<void> {\n        incrementSelected();\n        await updateSelected();\n    }\n\n    async function selectPrevious(): Promise<void> {\n        decrementSelected();\n        await updateSelected();\n    }\n\n    /**\n     * Choose as accepted suggestion\n     */\n    async function chooseSelected() {\n        if (!suggestionsItems.length) {\n            return;\n        }\n\n        active = true;\n        dispatch(\"choose\", { chosen: suggestionsItems[selected ?? -1] });\n\n        await tick();\n        show.set(false);\n    }\n\n    async function update() {\n        await tick();\n        dispatch(\"update\");\n    }\n\n    function hasSelected(): boolean {\n        return selected !== null;\n    }\n\n    function createAutocomplete() {\n        const api = {\n            selectPrevious,\n            selectNext,\n            chooseSelected,\n            update,\n            hasSelected,\n        };\n\n        return api;\n    }\n\n    function setSelected(index: number): void {\n        selected = index;\n        active = true;\n    }\n\n    function setSelectedAndActive(index: number): void {\n        setSelected(index);\n    }\n\n    async function selectIndex(index: number): Promise<void> {\n        active = false;\n        dispatch(\"select\", { selected: suggestionsItems[index] });\n    }\n\n    function selectIfMousedown(event: MouseEvent, index: number): void {\n        if (event.buttons === 1) {\n            setSelected(index);\n        }\n    }\n</script>\n\n<WithFloating\n    keepOnKeyup\n    show={$show}\n    preferredPlacement=\"top\"\n    portalTarget={document.body}\n    let:asReference\n    on:close={() => show.set(false)}\n>\n    <span class=\"autocomplete-reference\" use:asReference>\n        <slot {createAutocomplete} />\n    </span>\n\n    <Popover slot=\"floating\" --popover-padding-inline=\"0\">\n        <div class=\"autocomplete-menu\">\n            {#each suggestionsItems as suggestion, index}\n                {#if index === selected}\n                    <AutocompleteItem\n                        selected\n                        {active}\n                        {suggestion}\n                        on:mousedown={() => setSelectedAndActive(index)}\n                        on:mouseup={() => {\n                            selectIndex(index);\n                            chooseSelected();\n                        }}\n                        on:mouseenter={(event) => selectIfMousedown(event, index)}\n                        on:mouseleave={() => (active = false)}\n                    >\n                        {suggestion}\n                    </AutocompleteItem>\n                {:else}\n                    <AutocompleteItem\n                        {suggestion}\n                        on:mousedown={() => setSelectedAndActive(index)}\n                        on:mouseup={() => {\n                            selectIndex(index);\n                            chooseSelected();\n                        }}\n                        on:mouseenter={(event) => selectIfMousedown(event, index)}\n                    >\n                        {suggestion}\n                    </AutocompleteItem>\n                {/if}\n            {/each}\n        </div>\n    </Popover>\n</WithFloating>\n\n<style lang=\"scss\">\n    .autocomplete-reference {\n        position: absolute;\n        top: 0;\n        left: 0;\n        right: 0;\n        bottom: 0;\n\n        /* Make sure that text in TagInput perfectly overlaps with Tag */\n        border-left: 1px solid transparent;\n        border-bottom: 1px solid transparent;\n    }\n\n    .autocomplete-menu {\n        display: flex;\n        flex-flow: column nowrap;\n\n        width: 80vw;\n        max-height: 30vh;\n\n        font-size: 13px;\n        overflow-x: hidden;\n        text-overflow: ellipsis;\n        overflow-y: auto;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport { default as TagEditor } from \"./TagEditor.svelte\";\n"
  },
  {
    "path": "ts/lib/tag-editor/tag-options-button/TagAddButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconConstrain from \"$lib/components/IconConstrain.svelte\";\n    import { addTagIcon, tagIcon } from \"$lib/components/icons\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    import { currentTagInput } from \"../TagInput.svelte\";\n\n    export let keyCombination: string;\n\n    const dispatch = createEventDispatcher<{ tagappend: null }>();\n\n    function appendTag() {\n        dispatch(\"tagappend\");\n    }\n</script>\n\n<!-- toggle tabindex to allow Tab to move focus to the tag editor from the last field -->\n<!-- and allow Shift+Tab to move focus to the last field while inputting tag -->\n<!-- svelte-ignore a11y-click-events-have-key-events -->\n<div\n    class=\"tag-add-button\"\n    title=\"{tr.editingTagsAdd()} ({getPlatformString(keyCombination)})\"\n    role=\"button\"\n    tabindex={$currentTagInput ? -1 : 0}\n    on:click={appendTag}\n    on:focus={appendTag}\n>\n    <IconConstrain>\n        <Icon icon={tagIcon} />\n        <Icon icon={addTagIcon} />\n    </IconConstrain>\n    <span class=\"tags-info\">\n        <slot />\n    </span>\n</div>\n\n<Shortcut {keyCombination} on:action={() => dispatch(\"tagappend\")} />\n\n<style lang=\"scss\">\n    .tag-add-button {\n        line-height: 1;\n\n        :global(svg:last-child) {\n            display: none;\n        }\n\n        &:hover {\n            :global(svg:first-child) {\n                display: none;\n            }\n\n            :global(svg:last-child) {\n                display: block;\n            }\n        }\n\n        :global(svg) {\n            padding-bottom: 2px;\n            cursor: pointer;\n            fill: currentColor;\n            opacity: 0.6;\n        }\n\n        :global(svg:hover) {\n            opacity: 1;\n        }\n        .tags-info {\n            cursor: pointer;\n            color: var(--fg-subtle);\n            margin-left: 0.75rem;\n        }\n    }\n    :global([dir=\"rtl\"]) .tags-info {\n        margin-right: 0.75rem;\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/tag-options-button/TagOptionsButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import TagAddButton from \"./TagAddButton.svelte\";\n    import TagsSelectedButton from \"./TagsSelectedButton.svelte\";\n\n    export let badgeHeight: number;\n    export let tagsSelected: boolean;\n    export let keyCombination: string;\n</script>\n\n<!-- svelte-ignore a11y-no-static-element-interactions -->\n<div\n    class=\"tag-options-button gap\"\n    bind:offsetHeight={badgeHeight}\n    on:mousedown|preventDefault\n>\n    {#key tagsSelected}\n        {#if tagsSelected}\n            <TagsSelectedButton on:tagselectall on:tagcopy on:tagdelete />\n        {:else}\n            <TagAddButton on:tagappend {keyCombination} />\n        {/if}\n    {/key}\n</div>\n\n<style lang=\"scss\">\n    .tag-options-button {\n        transition: opacity 0.2s linear;\n        opacity: var(--button-opacity, 1);\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/tag-options-button/TagsSelectedButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { tagActionsShortcutsKey } from \"@tslib/context-keys\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { createEventDispatcher, getContext } from \"svelte\";\n\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconConstrain from \"$lib/components/IconConstrain.svelte\";\n    import { dotsIcon } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    const dispatch = createEventDispatcher();\n\n    let show = false;\n\n    const { selectAllShortcut, copyShortcut, removeShortcut } =\n        getContext<Record<string, string>>(tagActionsShortcutsKey);\n</script>\n\n<WithFloating\n    {show}\n    preferredPlacement=\"top\"\n    portalTarget={document.body}\n    shift={0}\n    let:asReference\n>\n    <div\n        class=\"tags-selected-button\"\n        use:asReference\n        role=\"button\"\n        tabindex=\"0\"\n        on:click={() => (show = !show)}\n        on:keydown={onEnterOrSpace(() => (show = !show))}\n    >\n        <IconConstrain><Icon icon={dotsIcon} /></IconConstrain>\n    </div>\n\n    <Popover slot=\"floating\">\n        <DropdownItem on:click={() => dispatch(\"tagselectall\")}>\n            {tr.editingTagsSelectAll()} ({getPlatformString(selectAllShortcut)})\n        </DropdownItem>\n\n        <DropdownItem on:click={() => dispatch(\"tagcopy\")}>\n            {tr.editingTagsCopy()} ({getPlatformString(copyShortcut)})\n        </DropdownItem>\n\n        <DropdownItem on:click={() => dispatch(\"tagdelete\")}>\n            {tr.editingTagsRemove()} ({getPlatformString(removeShortcut)})\n        </DropdownItem>\n    </Popover>\n</WithFloating>\n\n<style lang=\"scss\">\n    .tags-selected-button {\n        line-height: 1;\n        :global(svg) {\n            cursor: pointer;\n            fill: currentColor;\n            opacity: 0.6;\n        }\n\n        :global(svg:hover) {\n            opacity: 1;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/lib/tag-editor/tag-options-button/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport { default as TagOptionsButton } from \"./TagOptionsButton.svelte\";\n"
  },
  {
    "path": "ts/lib/tag-editor/tags.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport const delimChar = \"\\u2237\";\n\nexport function replaceWithUnicodeSeparator(name: string): string {\n    return name.replace(/::/g, delimChar);\n}\n\nexport function replaceWithColons(name: string): string {\n    return name.replace(/\\u2237/gu, \"::\");\n}\n\nexport function normalizeTagname(tagname: string): string {\n    let trimmed = tagname.trim();\n\n    while (trimmed.startsWith(\":\") || trimmed.startsWith(delimChar)) {\n        trimmed = trimmed.slice(1).trimStart();\n    }\n\n    while (trimmed.endsWith(\":\") || trimmed.endsWith(delimChar)) {\n        trimmed = trimmed.slice(0, -1).trimEnd();\n    }\n\n    return trimmed;\n}\n\nexport interface Tag {\n    id: string;\n    name: string;\n    selected: boolean;\n    flash: () => void;\n}\n\nexport function attachId(name: string): Tag {\n    return {\n        id: Math.random().toString(36).substring(2),\n        name,\n        selected: false,\n        flash: () => {\n            /* noop */\n        },\n    };\n}\n\nexport function getName(tag: Tag): string {\n    return tag.name;\n}\n"
  },
  {
    "path": "ts/lib/tslib/bridgecommand.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { registerPackage } from \"./runtime-require\";\n\ndeclare global {\n    interface Window {\n        bridgeCommand<T>(command: string, callback?: (value: T) => void): void;\n    }\n}\n\n/** HTML <a> tag pointing to a bridge command. */\nexport function bridgeLink(command: string, label: string): string {\n    return `<a href=\"javascript:bridgeCommand('${command}')\">${label}</a>`;\n}\n\nexport function bridgeCommandsAvailable(): boolean {\n    return !!window.bridgeCommand;\n}\n\nexport function bridgeCommand<T>(command: string, callback?: (value: T) => void): void {\n    window.bridgeCommand<T>(command, callback);\n}\n\nregisterPackage(\"anki/bridgecommand\", {\n    bridgeCommand,\n});\n"
  },
  {
    "path": "ts/lib/tslib/cards.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport enum CardType {\n    New = 0,\n    Learn = 1,\n    Review = 2,\n    Relearn = 3,\n}\n\nexport enum CardQueue {\n    /** due is the order cards are shown in */\n    New = 0,\n    /** due is a unix timestamp */\n    Learn = 1,\n    /** due is days since creation date */\n    Review = 2,\n    DayLearn = 3,\n    /** due is a unix timestamp. */\n    /** preview cards only placed here when failed. */\n    PreviewRepeat = 4,\n    /** cards are not due in these states */\n    Suspended = -1,\n    SchedBuried = -2,\n    UserBuried = -3,\n}\n"
  },
  {
    "path": "ts/lib/tslib/children-access.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nexport type Identifier = Element | string | number;\n\nfunction findElement<T extends Element>(\n    collection: HTMLCollection,\n    identifier: Identifier,\n): [T, number] | null {\n    let element: T;\n    let index: number;\n\n    if (identifier instanceof Element) {\n        element = identifier as T;\n        index = Array.prototype.indexOf.call(collection, element);\n        if (index < 0) {\n            return null;\n        }\n    } else if (typeof identifier === \"string\") {\n        const item = collection.namedItem(identifier);\n        if (!item) {\n            return null;\n        }\n        element = item as T;\n        index = Array.prototype.indexOf.call(collection, element);\n        if (index < 0) {\n            return null;\n        }\n    } else if (identifier < 0) {\n        index = collection.length + identifier;\n        const item = collection.item(index);\n        if (!item) {\n            return null;\n        }\n        element = item as T;\n    } else {\n        index = identifier;\n        const item = collection.item(index);\n        if (!item) {\n            return null;\n        }\n        element = item as T;\n    }\n\n    return [element, index];\n}\n\n/**\n * Creates a convenient access API for the children\n * of an element via identifiers. Identifiers can be:\n * - integers: signify the position\n * - negative integers: signify the offset from the end (-1 being the last element)\n * - strings: signify the id of an element\n * - the child directly\n */\nclass ChildrenAccess<T extends Element> {\n    parent: T;\n\n    constructor(parent: T) {\n        this.parent = parent;\n    }\n\n    insertElement(element: Element, identifier: Identifier): number {\n        const match = findElement(this.parent.children, identifier);\n\n        if (!match) {\n            return -1;\n        }\n\n        const [reference, index] = match;\n        this.parent.insertBefore(element, reference[0]);\n\n        return index;\n    }\n\n    appendElement(element: Element, identifier: Identifier): number {\n        const match = findElement(this.parent.children, identifier);\n\n        if (!match) {\n            return -1;\n        }\n\n        const [before, index] = match;\n        const reference = before.nextElementSibling ?? null;\n        this.parent.insertBefore(element, reference);\n\n        return index + 1;\n    }\n\n    updateElement(\n        f: (element: T, index: number) => void,\n        identifier: Identifier,\n    ): boolean {\n        const match = findElement<T>(this.parent.children, identifier);\n\n        if (!match) {\n            return false;\n        }\n\n        f(...match);\n        return true;\n    }\n}\n\nfunction childrenAccess<T extends Element>(parent: T): ChildrenAccess<T> {\n    return new ChildrenAccess<T>(parent);\n}\n\nexport default childrenAccess;\nexport type { ChildrenAccess };\n"
  },
  {
    "path": "ts/lib/tslib/context-keys.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport const fontFamilyKey = Symbol(\"fontFamily\");\nexport const fontSizeKey = Symbol(\"fontSize\");\nexport const directionKey = Symbol(\"direction\");\nexport const descriptionKey = Symbol(\"description\");\nexport const collapsedKey = Symbol(\"collapsed\");\nexport const tagActionsShortcutsKey = Symbol(\"tagActionsShortcuts\");\n"
  },
  {
    "path": "ts/lib/tslib/cross-browser.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/triple-slash-reference: \"off\",\n */\n/// <reference path=\"shadow-dom.d.ts\" />\n\n/**\n * Gecko has no .getSelection on ShadowRoot, only .activeElement\n */\nexport function getSelection(element: Node): Selection | null {\n    const root = element.getRootNode();\n\n    if (root.getSelection) {\n        return root.getSelection();\n    }\n\n    return document.getSelection();\n}\n\n/**\n * Browser has potential support for multiple ranges per selection built in,\n * but in reality only Gecko supports it.\n * If there are multiple ranges, the latest range is the _main_ one.\n */\nexport function getRange(selection: Selection): Range | null {\n    const rangeCount = selection.rangeCount;\n\n    return rangeCount === 0 ? null : selection.getRangeAt(rangeCount - 1);\n}\n\n/**\n * Avoid using selection.isCollapsed: it will always return\n * true in shadow root in Gecko\n * (this bug seems to also happens in Blink)\n */\nexport function isSelectionCollapsed(selection: Selection): boolean {\n    return getRange(selection)!.collapsed;\n}\n"
  },
  {
    "path": "ts/lib/tslib/dom.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getSelection } from \"./cross-browser\";\n\nexport function nodeIsElement(node: Node): node is Element {\n    return node.nodeType === Node.ELEMENT_NODE;\n}\n\n/**\n * In the web this is probably equivalent to `nodeIsElement`, but this is\n * convenient to convince Typescript.\n */\nexport function nodeIsCommonElement(node: Node): node is HTMLElement | SVGElement {\n    return node instanceof HTMLElement || node instanceof SVGElement;\n}\n\nexport function nodeIsText(node: Node): node is Text {\n    return node.nodeType === Node.TEXT_NODE;\n}\n\nexport function nodeIsComment(node: Node): node is Comment {\n    return node.nodeType === Node.COMMENT_NODE;\n}\n\n// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements\nexport const BLOCK_ELEMENTS = [\n    \"ADDRESS\",\n    \"ARTICLE\",\n    \"ASIDE\",\n    \"BLOCKQUOTE\",\n    \"DETAILS\",\n    \"DIALOG\",\n    \"DD\",\n    \"DIV\",\n    \"DL\",\n    \"DT\",\n    \"FIELDSET\",\n    \"FIGCAPTION\",\n    \"FIGURE\",\n    \"FOOTER\",\n    \"FORM\",\n    \"H1\",\n    \"H2\",\n    \"H3\",\n    \"H4\",\n    \"H5\",\n    \"H6\",\n    \"HEADER\",\n    \"HGROUP\",\n    \"HR\",\n    \"LI\",\n    \"MAIN\",\n    \"NAV\",\n    \"OL\",\n    \"P\",\n    \"PRE\",\n    \"SECTION\",\n    \"TABLE\",\n    \"UL\",\n];\n\nexport function hasBlockAttribute(element: Element): boolean {\n    return element.hasAttribute(\"block\") && element.getAttribute(\"block\") !== \"false\";\n}\n\nexport function elementIsBlock(element: Element): boolean {\n    return BLOCK_ELEMENTS.includes(element.tagName) || hasBlockAttribute(element);\n}\n\nexport const NO_SPLIT_TAGS = [\"RUBY\"];\n\nexport function elementShouldNotBeSplit(element: Element): boolean {\n    return elementIsBlock(element) || NO_SPLIT_TAGS.includes(element.tagName);\n}\n\n// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element\nexport const EMPTY_ELEMENTS = [\n    \"AREA\",\n    \"BASE\",\n    \"BR\",\n    \"COL\",\n    \"EMBED\",\n    \"HR\",\n    \"IMG\",\n    \"INPUT\",\n    \"LINK\",\n    \"META\",\n    \"PARAM\",\n    \"SOURCE\",\n    \"TRACK\",\n    \"WBR\",\n];\n\nexport function elementIsEmpty(element: Element): boolean {\n    return EMPTY_ELEMENTS.includes(element.tagName);\n}\n\nexport function nodeContainsInlineContent(node: Node): boolean {\n    for (const child of node.childNodes) {\n        if (\n            (nodeIsElement(child) && elementIsBlock(child))\n            || !nodeContainsInlineContent(child)\n        ) {\n            return false;\n        }\n    }\n\n    // empty node is trivially inline\n    return true;\n}\n\n/**\n * Consumes the input fragment.\n */\nexport function fragmentToString(fragment: DocumentFragment): string {\n    const fragmentDiv = document.createElement(\"div\");\n    fragmentDiv.appendChild(fragment);\n    const html = fragmentDiv.innerHTML;\n\n    return html;\n}\n\nconst getAnchorParent =\n    <T extends Element>(predicate: (element: Element) => element is T) => (root: Node): T | null => {\n        const anchor = getSelection(root)?.anchorNode;\n\n        if (!anchor) {\n            return null;\n        }\n\n        let anchorParent: T | null = null;\n        let element = nodeIsElement(anchor) ? anchor : anchor.parentElement;\n\n        while (element) {\n            anchorParent = anchorParent || (predicate(element) ? element : null);\n            element = element.parentElement;\n        }\n\n        return anchorParent;\n    };\n\nconst isListItem = (element: Element): element is HTMLLIElement =>\n    window.getComputedStyle(element).display === \"list-item\";\n\nexport const getListItem = getAnchorParent(isListItem);\n"
  },
  {
    "path": "ts/lib/tslib/events.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport type EventTargetToMap<A extends EventTarget> = A extends HTMLElement ? HTMLElementEventMap\n    : A extends Document ? DocumentEventMap\n    : A extends Window ? WindowEventMap\n    : A extends FileReader ? FileReaderEventMap\n    : A extends Animation ? AnimationEventMap\n    : A extends EventSource ? EventSourceEventMap\n    : A extends AbortSignal ? AbortSignalEventMap\n    : A extends AbstractWorker ? AbstractWorkerEventMap\n    : never;\n\nexport function on<T extends EventTarget, K extends keyof EventTargetToMap<T>>(\n    target: T,\n    eventType: Exclude<K, symbol | number>,\n    handler: (this: T, event: EventTargetToMap<T>[K]) => void,\n    options?: AddEventListenerOptions,\n): () => void {\n    target.addEventListener(eventType, handler as EventListener, options);\n    return () => target.removeEventListener(eventType, handler as EventListener, options);\n}\n\nexport function preventDefault(event: Event): void {\n    event.preventDefault();\n}\n"
  },
  {
    "path": "ts/lib/tslib/functional.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function noop(): void {\n    /* noop */\n}\n\nexport async function asyncNoop(): Promise<void> {\n    /* noop */\n}\n\nexport function id<T>(t: T): T {\n    return t;\n}\n\nexport function truthy<T>(t: T | void | undefined | null): t is T {\n    return Boolean(t);\n}\n"
  },
  {
    "path": "ts/lib/tslib/globals.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function globalExport(globals: Record<string, unknown>): void {\n    for (const key in globals) {\n        window[key] = globals[key];\n    }\n\n    // but also export as window.anki\n    window[\"anki\"] = globals;\n}\n"
  },
  {
    "path": "ts/lib/tslib/help-page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/** These links are checked in CI to ensure they are valid. */\nexport const HelpPage = {\n    DeckOptions: {\n        maximumInterval: \"https://docs.ankiweb.net/deck-options.html#maximum-interval\",\n        startingEase: \"https://docs.ankiweb.net/deck-options.html#starting-ease\",\n        easyBonus: \"https://docs.ankiweb.net/deck-options.html#easy-bonus\",\n        intervalModifier: \"https://docs.ankiweb.net/deck-options.html#interval-modifier\",\n        hardInterval: \"https://docs.ankiweb.net/deck-options.html#hard-interval\",\n        newInterval: \"https://docs.ankiweb.net/deck-options.html#new-interval\",\n        advanced: \"https://docs.ankiweb.net/deck-options.html#advanced\",\n        timer: \"https://docs.ankiweb.net/deck-options.html#timers\",\n        autoAdvance: \"https://docs.ankiweb.net/deck-options.html#auto-advance\",\n        learningSteps: \"https://docs.ankiweb.net/deck-options.html#learning-steps\",\n        graduatingInterval: \"https://docs.ankiweb.net/deck-options.html#graduating-interval\",\n        easyInterval: \"https://docs.ankiweb.net/deck-options.html#easy-interval\",\n        insertionOrder: \"https://docs.ankiweb.net/deck-options.html#insertion-order\",\n        newCards: \"https://docs.ankiweb.net/deck-options.html#new-cards\",\n        relearningSteps: \"https://docs.ankiweb.net/deck-options.html#relearning-steps\",\n        minimumInterval: \"https://docs.ankiweb.net/deck-options.html#minimum-interval\",\n        lapses: \"https://docs.ankiweb.net/deck-options.html#lapses\",\n        displayOrder: \"https://docs.ankiweb.net/deck-options.html#display-order\",\n        maximumReviewsday: \"https://docs.ankiweb.net/deck-options.html#maximum-reviewsday\",\n        newCardsday: \"https://docs.ankiweb.net/deck-options.html#new-cardsday\",\n        limitsFromTop: \"https://docs.ankiweb.net/deck-options.html#limits-start-from-top\",\n        dailyLimits: \"https://docs.ankiweb.net/deck-options.html#daily-limits\",\n        audio: \"https://docs.ankiweb.net/deck-options.html#audio\",\n        fsrs: \"https://docs.ankiweb.net/deck-options.html#fsrs\",\n        desiredRetention: \"https://docs.ankiweb.net/deck-options.html#desired-retention\",\n    },\n    Leeches: {\n        leeches: \"https://docs.ankiweb.net/leeches.html#leeches\",\n        waiting: \"https://docs.ankiweb.net/leeches.html#waiting\",\n    },\n    Studying: {\n        siblingsAndBurying: \"https://docs.ankiweb.net/studying.html#siblings-and-burying\",\n    },\n    PackageImporting: {\n        root: \"https://docs.ankiweb.net/importing/packaged-decks.html\",\n        updating: \"https://docs.ankiweb.net/importing/packaged-decks.html#updating\",\n        scheduling: \"https://docs.ankiweb.net/importing/packaged-decks.html#scheduling\",\n    },\n    TextImporting: {\n        root: \"https://docs.ankiweb.net/importing/text-files.html\",\n        updating: \"https://docs.ankiweb.net/importing/text-files.html#duplicates-and-updating\",\n        html: \"https://docs.ankiweb.net/importing/text-files.html#html\",\n    },\n};\n"
  },
  {
    "path": "ts/lib/tslib/helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { marked } from \"marked\";\n\nexport type Callback = () => void;\n\nexport function removeItem<T>(items: T[], item: T): void {\n    const index = items.findIndex((i: T): boolean => i === item);\n\n    if (index >= 0) {\n        items.splice(index, 1);\n    }\n}\n\nexport function renderMarkdown(text: string): string {\n    return marked(text, { mangle: false, headerIds: false });\n}\n"
  },
  {
    "path": "ts/lib/tslib/i18n/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport { ModuleName } from \"@generated/ftl\";\n// eslint-disable\nexport * from \"./utils\";\n"
  },
  {
    "path": "ts/lib/tslib/i18n/utils.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"intl-pluralrules\";\n\nimport { i18nResources } from \"@generated/backend\";\nimport type { ModuleName } from \"@generated/ftl\";\nimport { FluentBundle, FluentResource } from \"@generated/ftl\";\nimport { firstLanguage, setBundles } from \"@generated/ftl\";\n\nexport function supportsVerticalText(): boolean {\n    const firstLang = firstLanguage();\n    return (\n        firstLang.startsWith(\"ja\")\n        || firstLang.startsWith(\"zh\")\n        || firstLang.startsWith(\"ko\")\n    );\n}\n\nexport function direction(): \"ltr\" | \"rtl\" {\n    const firstLang = firstLanguage();\n    if (\n        firstLang.startsWith(\"ar\")\n        || firstLang.startsWith(\"he\")\n        || firstLang.startsWith(\"fa\")\n    ) {\n        return \"rtl\";\n    } else {\n        return \"ltr\";\n    }\n}\n\nexport function weekdayLabel(n: number): string {\n    const firstLang = firstLanguage();\n    const now = new Date();\n    const daysFromToday = -now.getDay() + n;\n    const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000);\n    return desiredDay.toLocaleDateString(firstLang, {\n        weekday: \"narrow\",\n    });\n}\n\nlet langs: string[] = [];\n\nexport function localizedDate(\n    date: Date,\n    options?: Intl.DateTimeFormatOptions,\n): string {\n    return date.toLocaleDateString(langs, options);\n}\n\nexport function localizedNumber(n: number, precision = 2): string {\n    const round = Math.pow(10, precision);\n    const rounded = Math.round(n * round) / round;\n    return rounded.toLocaleString(langs);\n}\n\nexport function createLocaleNumberFormat(options?: Intl.NumberFormatOptions): Intl.NumberFormat {\n    return new Intl.NumberFormat(langs, options);\n}\n\nexport function localeCompare(\n    first: string,\n    second: string,\n    options?: Intl.CollatorOptions,\n): number {\n    return first.localeCompare(second, langs, options);\n}\n\n/** Treat text like HTML, merging multiple spaces and converting\n newlines to spaces. */\nexport function withCollapsedWhitespace(s: string): string {\n    return s.replace(/\\s+/g, \" \");\n}\n\nexport function withoutUnicodeIsolation(s: string): string {\n    return s.replace(/[\\u2068-\\u2069]+/g, \"\");\n}\n\nexport async function setupI18n(args: { modules: ModuleName[] }): Promise<void> {\n    const resources = await i18nResources(args);\n    const json = JSON.parse(new TextDecoder().decode(resources.json));\n\n    const newBundles: FluentBundle[] = [];\n    for (const res in json.resources) {\n        const text = json.resources[res];\n        const lang = json.langs[res];\n        const bundle = new FluentBundle([lang, \"en-US\"]);\n        const resource = new FluentResource(text);\n        bundle.addResource(resource);\n        newBundles.push(bundle);\n    }\n\n    setBundles(newBundles);\n    langs = json.langs;\n\n    document.dir = direction();\n}\n\nlet globalI18n: Promise<void> | undefined;\n\nexport async function setupGlobalI18n(): Promise<void> {\n    if (!globalI18n) {\n        globalI18n = setupI18n({\n            modules: [],\n        });\n    }\n    await globalI18n;\n}\n"
  },
  {
    "path": "ts/lib/tslib/image-import.d.ts",
    "content": "declare module \"*.svg\";\n"
  },
  {
    "path": "ts/lib/tslib/keys.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\n\nimport { isApplePlatform } from \"./platform\";\n\n// those are the modifiers that Anki works with\nexport type Modifier = \"Control\" | \"Alt\" | \"Shift\" | \"Meta\";\nconst allModifiers: Modifier[] = [\"Control\", \"Alt\", \"Shift\", \"Meta\"];\n\nconst platformModifiers: string[] = isApplePlatform()\n    ? [\"Meta\", \"Alt\", \"Shift\", \"Control\"]\n    : [\"Control\", \"Alt\", \"Shift\", \"OS\"];\n\nfunction translateModifierToPlatform(modifier: Modifier): string {\n    return platformModifiers[allModifiers.indexOf(modifier)];\n}\n\nexport function checkIfModifierKey(event: KeyboardEvent): boolean {\n    // At least the web view on Desktop Anki gives out the wrong values for\n    // `event.location`, which is why we do it like this.\n    let isInputKey = false;\n\n    for (const modifier of allModifiers) {\n        isInputKey ||= event.code.startsWith(modifier);\n    }\n\n    return isInputKey;\n}\n\nexport function keyboardEventIsPrintableKey(event: KeyboardEvent): boolean {\n    return event.key.length === 1;\n}\n\nexport const checkModifiers = (required: Modifier[], optional: Modifier[] = []) => (event: KeyboardEvent): boolean => {\n    return allModifiers.reduce(\n        (\n            matches: boolean,\n            currentModifier: Modifier,\n            currentIndex: number,\n        ): boolean =>\n            matches\n            && (optional.includes(currentModifier as Modifier)\n                || event.getModifierState(platformModifiers[currentIndex])\n                    === required.includes(currentModifier)),\n        true,\n    );\n};\n\nconst modifierPressed = (modifier: Modifier) => (event: MouseEvent | KeyboardEvent): boolean => {\n    const translated = translateModifierToPlatform(modifier);\n    const state = event.getModifierState(translated);\n    return event.type === \"keyup\"\n        ? state && (event as KeyboardEvent).key !== translated\n        : state;\n};\n\nexport const controlPressed = modifierPressed(\"Control\");\nexport const shiftPressed = modifierPressed(\"Shift\");\nexport const altPressed = modifierPressed(\"Alt\");\nexport const metaPressed = modifierPressed(\"Meta\");\n\nexport function modifiersToPlatformString(modifiers: string[]): string {\n    const displayModifiers = isApplePlatform()\n        ? [\"^\", \"⌥\", \"⇧\", \"⌘\"]\n        : [`${tr.keyboardCtrl()}+`, \"Alt+\", `${tr.keyboardShift()}+`, \"Win+\"];\n\n    let result = \"\";\n\n    for (const modifier of modifiers) {\n        result += displayModifiers[platformModifiers.indexOf(modifier)];\n    }\n\n    return result;\n}\n\nexport function keyToPlatformString(key: string): string {\n    switch (key) {\n        case \"Backspace\":\n            return \"⌫\";\n        case \"Delete\":\n            return \"⌦\";\n        case \"Escape\":\n            return \"⎋\";\n\n        default:\n            return key;\n    }\n}\n\nexport function isArrowLeft(event: KeyboardEvent): boolean {\n    if (event.key === \"ArrowLeft\") {\n        return true;\n    }\n\n    return isApplePlatform() && metaPressed(event) && event.code === \"KeyB\";\n}\n\nexport function isArrowRight(event: KeyboardEvent): boolean {\n    if (event.key === \"ArrowRight\") {\n        return true;\n    }\n\n    return isApplePlatform() && metaPressed(event) && event.code === \"KeyF\";\n}\n\nexport function isArrowUp(event: KeyboardEvent): boolean {\n    if (event.key === \"ArrowUp\") {\n        return true;\n    }\n\n    return isApplePlatform() && metaPressed(event) && event.code === \"KeyP\";\n}\n\nexport function isArrowDown(event: KeyboardEvent): boolean {\n    if (event.key === \"ArrowDown\") {\n        return true;\n    }\n\n    return isApplePlatform() && metaPressed(event) && event.code === \"KeyN\";\n}\n\nexport function onEnterOrSpace(callback: () => void): (event: KeyboardEvent) => void {\n    return (event: KeyboardEvent) => {\n        switch (event.code) {\n            case \"Enter\":\n            case \"Space\":\n                callback();\n                break;\n        }\n    };\n}\n"
  },
  {
    "path": "ts/lib/tslib/nightmode.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/** Add night-mode class to documentElement if hash location is #night, and\n    return true if added. */\nexport function checkNightMode(): boolean {\n    const nightMode = window.location.hash == \"#night\";\n    if (nightMode) {\n        document.documentElement.className = \"night-mode\";\n        document.documentElement.dataset.bsTheme = \"dark\";\n    }\n    return nightMode;\n}\n"
  },
  {
    "path": "ts/lib/tslib/node.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function isOnlyChild(node: Node): boolean {\n    return node.parentNode!.childNodes.length === 1;\n}\n\nexport function hasOnlyChild(node: Node): boolean {\n    return node.childNodes.length === 1;\n}\n\nexport function ascend(node: Node): Node {\n    return node.parentNode!;\n}\n"
  },
  {
    "path": "ts/lib/tslib/parsing.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * Parsing with or without this dummy structure changes the output\n * for both `DOMParser.parseAsString` and range.createContextualFragment`.\n * Parsing without means that comments or meaningless html elements are dropped,\n * which we want to avoid.\n */\nexport function createDummyDoc(html: string): string {\n    return `<html><head></head><body>${html}</body></html>`;\n}\n"
  },
  {
    "path": "ts/lib/tslib/platform.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function isApplePlatform(): boolean {\n    // avoid deprecation warning\n    const platform = window.navigator[\"platform\" + \"\"];\n    return (\n        platform.startsWith(\"Mac\")\n        || platform.startsWith(\"iP\")\n    );\n}\n\nexport function isDesktop(): boolean {\n    return !(/iphone|ipad|ipod|android/i.test(window.navigator.userAgent));\n}\n\nexport function chromiumVersion(): number | null {\n    const userAgent = window.navigator.userAgent;\n\n    // Check if it's a Chromium-based browser (Chrome, Edge, Opera, etc.)\n    // but exclude Safari which also contains \"Chrome\" in its user agent\n    if (userAgent.includes(\"Safari\") && !userAgent.includes(\"Chrome\")) {\n        return null; // Safari\n    }\n\n    const chromeMatch = userAgent.match(/Chrome\\/(\\d+)/);\n    if (chromeMatch) {\n        return parseInt(chromeMatch[1], 10);\n    }\n\n    return null; // Not a Chromium-based browser\n}\n"
  },
  {
    "path": "ts/lib/tslib/progress.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Progress } from \"@generated/anki/collection_pb\";\nimport { latestProgress } from \"@generated/backend\";\n\nexport async function runWithBackendProgress<T>(\n    callback: () => Promise<T>,\n    onUpdate: (progress: Progress) => void,\n): Promise<T> {\n    let done = false;\n    async function progressCallback() {\n        const progress = await latestProgress({});\n        onUpdate(progress);\n        if (done) {\n            return;\n        }\n        setTimeout(progressCallback, 100);\n    }\n    setTimeout(progressCallback, 100);\n    try {\n        return await callback();\n    } finally {\n        done = true;\n    }\n}\n"
  },
  {
    "path": "ts/lib/tslib/promise.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function promiseWithResolver<T>(): [Promise<T>, (value: T) => void] {\n    let resolve: (object: T) => void;\n    const promise = new Promise<T>((res) => (resolve = res));\n\n    return [promise, resolve!];\n}\n"
  },
  {
    "path": "ts/lib/tslib/runtime-require.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * Names of anki packages\n *\n * @privateRemarks\n * Originally this was more strictly typed as a record:\n * ```ts\n * type AnkiPackages = {\n *     \"anki/NoteEditor\": NoteEditorPackage,\n * }\n * ```\n * This would be very useful for `require`: the result could be strictly typed.\n * However cross-module type imports currently don't work.\n */\ntype AnkiPackages =\n    | \"anki/NoteEditor\"\n    | \"anki/EditorField\"\n    | \"anki/PlainTextInput\"\n    | \"anki/RichTextInput\"\n    | \"anki/TemplateButtons\"\n    | \"anki/packages\"\n    | \"anki/bridgecommand\"\n    | \"anki/shortcuts\"\n    | \"anki/theme\"\n    | \"anki/location\"\n    | \"anki/surround\"\n    | \"anki/ui\"\n    | \"anki/reviewer\";\ntype PackageDeprecation<T extends Record<string, unknown>> = {\n    [key in keyof T]?: string;\n};\n\n/** This can be extended to allow require() calls at runtime, for packages\nthat are not included at bundling time. */\nconst runtimePackages: Partial<Record<AnkiPackages, Record<string, unknown>>> = {};\nconst prohibit = () => false;\n\n/**\n * Packages registered with this function escape the typing provided by `AnkiPackages`\n */\nexport function registerPackageRaw(\n    name: string,\n    entries: Record<string, unknown>,\n): void {\n    runtimePackages[name] = entries;\n}\n\nexport function registerPackage<\n    T extends AnkiPackages,\n    U extends Record<string, unknown>,\n>(name: T, entries: U, deprecation?: PackageDeprecation<U>): void {\n    const pack = deprecation\n        ? new Proxy(entries, {\n            set: prohibit,\n            defineProperty: prohibit,\n            deleteProperty: prohibit,\n            get: (target, name: string) => {\n                if (name in deprecation) {\n                    console.log(`anki: ${name} is deprecated: ${deprecation[name]}`);\n                }\n\n                return target[name];\n            },\n        })\n        : entries;\n\n    registerPackageRaw(name, pack);\n}\n\nfunction require<T extends AnkiPackages>(name: T): Record<string, unknown> | undefined {\n    if (!(name in runtimePackages)) {\n        throw new Error(`Cannot require \"${name}\" at runtime.`);\n    } else {\n        return runtimePackages[name];\n    }\n}\n\nfunction listPackages(): string[] {\n    return Object.keys(runtimePackages);\n}\n\nfunction hasPackages(...names: string[]): boolean {\n    for (const name of names) {\n        if (!(name in runtimePackages)) {\n            return false;\n        }\n    }\n\n    return true;\n}\n\n// Export require() as a global.\nObject.assign(globalThis, { require });\n\nregisterPackage(\"anki/packages\", {\n    // We also register require here, so add-ons can have a type-save variant of require (TODO, see AnkiPackages above)\n    require,\n    listPackages,\n    hasPackages,\n});\n"
  },
  {
    "path": "ts/lib/tslib/shadow-dom.d.ts",
    "content": "export {};\n\ndeclare global {\n    interface DocumentOrShadowRoot {\n        getSelection(): Selection | null;\n    }\n\n    interface Node {\n        getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot;\n    }\n}\n"
  },
  {
    "path": "ts/lib/tslib/shortcuts.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { on } from \"./events\";\nimport type { Modifier } from \"./keys\";\nimport { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from \"./keys\";\nimport { registerPackage } from \"./runtime-require\";\n\nconst keyCodeLookup = {\n    Backspace: 8,\n    Delete: 46,\n    Tab: 9,\n    Enter: 13,\n    F1: 112,\n    F2: 113,\n    F3: 114,\n    F4: 115,\n    F5: 116,\n    F6: 117,\n    F7: 118,\n    F8: 119,\n    F9: 120,\n    F10: 121,\n    F11: 122,\n    F12: 123,\n    \"=\": 187,\n    \"-\": 189,\n    \"[\": 219,\n    \"]\": 221,\n    \"\\\\\": 220,\n    \";\": 186,\n    \"'\": 222,\n    \",\": 188,\n    \".\": 190,\n    \"/\": 191,\n    \"`\": 192,\n};\n\nfunction isRequiredModifier(modifier: string): boolean {\n    return !modifier.endsWith(\"?\");\n}\n\nfunction splitKeyCombinationString(keyCombinationString: string): string[][] {\n    return keyCombinationString.split(\", \").map((segment) => segment.split(\"+\"));\n}\n\nfunction toPlatformString(keyCombination: string[]): string {\n    return (\n        modifiersToPlatformString(\n            keyCombination.slice(0, -1).filter(isRequiredModifier),\n        ) + keyToPlatformString(keyCombination[keyCombination.length - 1])\n    );\n}\n\nexport function getPlatformString(keyCombinationString: string): string {\n    return splitKeyCombinationString(keyCombinationString)\n        .map(toPlatformString)\n        .join(\", \");\n}\n\nfunction checkKey(event: KeyboardEvent, key: number): boolean {\n    // avoid deprecation warning\n    const which = event[\"which\" + \"\"];\n    return which === key;\n}\n\nfunction partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {\n    const trueItems: T[] = [];\n    const falseItems: T[] = [];\n\n    items.forEach((t) => {\n        const target = predicate(t) ? trueItems : falseItems;\n        target.push(t);\n    });\n\n    return [trueItems, falseItems];\n}\n\nfunction removeTrailing(modifier: string): string {\n    return modifier.substring(0, modifier.length - 1);\n}\n\nfunction separateRequiredOptionalModifiers(\n    modifiers: string[],\n): [Modifier[], Modifier[]] {\n    const [requiredModifiers, otherModifiers] = partition(\n        isRequiredModifier,\n        modifiers,\n    );\n\n    const optionalModifiers = otherModifiers.map(removeTrailing);\n    return [requiredModifiers as Modifier[], optionalModifiers as Modifier[]];\n}\n\nconst check =\n    (keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) =>\n    (event: KeyboardEvent): boolean => {\n        return (\n            checkKey(event, keyCode)\n            && checkModifiers(requiredModifiers, optionalModifiers)(event)\n        );\n    };\n\nfunction keyToCode(key: string): number {\n    return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);\n}\n\nfunction keyCombinationToCheck(\n    keyCombination: string[],\n): (event: KeyboardEvent) => boolean {\n    const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);\n    const [required, optional] = separateRequiredOptionalModifiers(\n        keyCombination.slice(0, -1),\n    );\n\n    return check(keyCode, required, optional);\n}\n\nfunction innerShortcut(\n    target: EventTarget | Document,\n    lastEvent: KeyboardEvent,\n    callback: (event: KeyboardEvent) => void,\n    ...checks: ((event: KeyboardEvent) => boolean)[]\n): void {\n    if (checks.length === 0) {\n        return callback(lastEvent);\n    }\n\n    const [nextCheck, ...restChecks] = checks;\n    const remove = on(document, \"keydown\", handler, { once: true });\n\n    function handler(event: KeyboardEvent): void {\n        if (nextCheck(event)) {\n            innerShortcut(target, event, callback, ...restChecks);\n        } else if (!checkIfModifierKey(event)) {\n            // Any non-modifier key will cancel the shortcut sequence\n            remove();\n        }\n    }\n}\n\nexport interface RegisterShortcutRestParams {\n    target: EventTarget;\n    /** There might be no good reason to use `keyup` other\n    than to circumvent Qt bugs */\n    event: \"keydown\" | \"keyup\";\n}\n\nconst defaultRegisterShortcutRestParams = {\n    target: document,\n    event: \"keydown\" as const,\n};\n\nexport function registerShortcut(\n    callback: (event: KeyboardEvent) => void,\n    keyCombinationString: string,\n    restParams: Partial<RegisterShortcutRestParams> = defaultRegisterShortcutRestParams,\n): () => void {\n    const {\n        target = defaultRegisterShortcutRestParams.target,\n        event = defaultRegisterShortcutRestParams.event,\n    } = restParams;\n\n    const [check, ...restChecks] = splitKeyCombinationString(keyCombinationString).map(keyCombinationToCheck);\n\n    function handler(event: KeyboardEvent): void {\n        if (check(event)) {\n            innerShortcut(target, event, callback, ...restChecks);\n        }\n    }\n\n    return on(target, event, handler);\n}\n\nregisterPackage(\"anki/shortcuts\", {\n    registerShortcut,\n    getPlatformString,\n});\n"
  },
  {
    "path": "ts/lib/tslib/styling.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/**\n * @returns True, if element has no style attribute (anymore).\n */\nfunction removeEmptyStyle(element: HTMLElement | SVGElement): boolean {\n    if (element.style.cssText.length === 0) {\n        element.removeAttribute(\"style\");\n        // Calling `.hasAttribute` right after `.removeAttribute` might return true.\n        return true;\n    }\n\n    return false;\n}\n\n/**\n * Will remove the style attribute, if all properties were removed.\n *\n * @returns True, if element has no style attributes anymore\n */\nexport function removeStyleProperties(\n    element: HTMLElement | SVGElement,\n    ...props: string[]\n): boolean {\n    for (const prop of props) {\n        element.style.removeProperty(prop);\n    }\n\n    return removeEmptyStyle(element);\n}\n"
  },
  {
    "path": "ts/lib/tslib/time.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { expect, test } from \"vitest\";\n\nimport { naturalUnit, naturalWholeUnit, TimespanUnit } from \"./time\";\n\ntest(\"natural unit\", () => {\n    expect(naturalUnit(5)).toBe(TimespanUnit.Seconds);\n    expect(naturalUnit(59)).toBe(TimespanUnit.Seconds);\n    expect(naturalUnit(60)).toBe(TimespanUnit.Minutes);\n    expect(naturalUnit(60 * 60 - 1)).toBe(TimespanUnit.Minutes);\n    expect(naturalUnit(60 * 60)).toBe(TimespanUnit.Hours);\n    expect(naturalUnit(60 * 60 * 24)).toBe(TimespanUnit.Days);\n    expect(naturalUnit(60 * 60 * 24 * 31)).toBe(TimespanUnit.Months);\n});\n\ntest(\"natural whole unit\", () => {\n    expect(naturalWholeUnit(5)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(59)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(60)).toBe(TimespanUnit.Minutes);\n    expect(naturalWholeUnit(61)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(90)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(60 * 60 - 1)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(60 * 60 + 1)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(60 * 60)).toBe(TimespanUnit.Hours);\n    expect(naturalWholeUnit(24 * 60 * 60 - 1)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(24 * 60 * 60 + 1)).toBe(TimespanUnit.Seconds);\n    expect(naturalWholeUnit(24 * 60 * 60)).toBe(TimespanUnit.Days);\n});\n"
  },
  {
    "path": "ts/lib/tslib/time.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\n\nexport const SECOND = 1.0;\nexport const MINUTE = 60.0 * SECOND;\nexport const HOUR = 60.0 * MINUTE;\nexport const DAY = 24.0 * HOUR;\nexport const YEAR = 365.0 * DAY;\nexport const MONTH = YEAR / 12;\n\nexport enum TimespanUnit {\n    Seconds,\n    Minutes,\n    Hours,\n    Days,\n    Months,\n    Years,\n}\n\nexport function unitName(unit: TimespanUnit): string {\n    switch (unit) {\n        case TimespanUnit.Seconds:\n            return \"seconds\";\n        case TimespanUnit.Minutes:\n            return \"minutes\";\n        case TimespanUnit.Hours:\n            return \"hours\";\n        case TimespanUnit.Days:\n            return \"days\";\n        case TimespanUnit.Months:\n            return \"months\";\n        case TimespanUnit.Years:\n            return \"years\";\n    }\n}\n\nexport function naturalUnit(secs: number): TimespanUnit {\n    secs = Math.abs(secs);\n    if (secs < MINUTE) {\n        return TimespanUnit.Seconds;\n    } else if (secs < HOUR) {\n        return TimespanUnit.Minutes;\n    } else if (secs < DAY) {\n        return TimespanUnit.Hours;\n    } else if (secs < MONTH) {\n        return TimespanUnit.Days;\n    } else if (secs < YEAR) {\n        return TimespanUnit.Months;\n    } else {\n        return TimespanUnit.Years;\n    }\n}\n\n/** Number of seconds in a given unit. */\nexport function unitSeconds(unit: TimespanUnit): number {\n    switch (unit) {\n        case TimespanUnit.Seconds:\n            return SECOND;\n        case TimespanUnit.Minutes:\n            return MINUTE;\n        case TimespanUnit.Hours:\n            return HOUR;\n        case TimespanUnit.Days:\n            return DAY;\n        case TimespanUnit.Months:\n            return MONTH;\n        case TimespanUnit.Years:\n            return YEAR;\n    }\n}\n\nexport function unitAmount(unit: TimespanUnit, secs: number): number {\n    return secs / unitSeconds(unit);\n}\n\n/** Largest unit provided seconds can be divided by without a remainder. */\nexport function naturalWholeUnit(secs: number): TimespanUnit {\n    let unit = naturalUnit(secs);\n    while (unit != TimespanUnit.Seconds) {\n        const amount = Math.round(unitAmount(unit, secs));\n        if (Math.abs(secs - amount * unitSeconds(unit)) < Number.EPSILON) {\n            return unit;\n        }\n        unit -= 1;\n    }\n    return unit;\n}\n\nexport function studiedToday(cards: number, secs: number): string {\n    const unit = Math.min(naturalUnit(secs), TimespanUnit.Minutes);\n    const amount = unitAmount(unit, secs);\n    const name = unitName(unit);\n\n    let secsPer = 0;\n    if (cards > 0) {\n        secsPer = secs / cards;\n    }\n    return tr.statisticsStudiedToday({\n        unit: name,\n        secsPerCard: secsPer,\n        cards,\n        amount,\n    });\n}\n\nfunction i18nFuncForUnit(\n    unit: TimespanUnit,\n    short: boolean,\n): (_: { amount: number }) => string {\n    if (short) {\n        switch (unit) {\n            case TimespanUnit.Seconds:\n                return tr.statisticsElapsedTimeSeconds;\n            case TimespanUnit.Minutes:\n                return tr.statisticsElapsedTimeMinutes;\n            case TimespanUnit.Hours:\n                return tr.statisticsElapsedTimeHours;\n            case TimespanUnit.Days:\n                return tr.statisticsElapsedTimeDays;\n            case TimespanUnit.Months:\n                return tr.statisticsElapsedTimeMonths;\n            case TimespanUnit.Years:\n                return tr.statisticsElapsedTimeYears;\n        }\n    } else {\n        switch (unit) {\n            case TimespanUnit.Seconds:\n                return tr.schedulingTimeSpanSeconds;\n            case TimespanUnit.Minutes:\n                return tr.schedulingTimeSpanMinutes;\n            case TimespanUnit.Hours:\n                return tr.schedulingTimeSpanHours;\n            case TimespanUnit.Days:\n                return tr.schedulingTimeSpanDays;\n            case TimespanUnit.Months:\n                return tr.schedulingTimeSpanMonths;\n            case TimespanUnit.Years:\n                return tr.schedulingTimeSpanYears;\n        }\n    }\n}\n\n/** Describe the given seconds using the largest appropriate unit.\nIf precise is true, show to two decimal places, eg\neg 70 seconds -> \"1.17 minutes\"\nIf false, seconds and days are shown without decimals. */\nexport function timeSpan(\n    seconds: number,\n    short = false,\n    precise = true,\n    maxUnit: TimespanUnit = TimespanUnit.Years,\n): string {\n    const unit = Math.min(naturalUnit(seconds), maxUnit);\n    let amount = unitAmount(unit, seconds);\n    if (!precise && unit < TimespanUnit.Months) {\n        amount = Math.round(amount);\n    }\n    return i18nFuncForUnit(unit, short)({ amount });\n}\n\nexport function dayLabel(daysStart: number, daysEnd: number): string {\n    const larger = Math.max(Math.abs(daysStart), Math.abs(daysEnd));\n    const smaller = Math.min(Math.abs(daysStart), Math.abs(daysEnd));\n    if (larger - smaller <= 1) {\n        // singular\n        if (daysStart >= 0) {\n            return tr.statisticsInDaysSingle({ days: daysStart });\n        } else {\n            return tr.statisticsDaysAgoSingle({ days: -daysStart });\n        }\n    } else {\n        // range\n        if (daysStart >= 0) {\n            return tr.statisticsInDaysRange({\n                daysStart,\n                daysEnd: daysEnd - 1,\n            });\n        } else {\n            return tr.statisticsDaysAgoRange({\n                daysStart: Math.abs(daysEnd - 1),\n                daysEnd: -daysStart,\n            });\n        }\n    }\n}\n\n/** Helper for converting Unix timestamps to date strings. */\nexport class Timestamp {\n    private date: Date;\n\n    constructor(seconds: number) {\n        this.date = new Date(seconds * 1000);\n    }\n\n    /** YYYY-MM-DD */\n    dateString(): string {\n        const year = this.date.getFullYear();\n        const month = (\"0\" + (this.date.getMonth() + 1)).slice(-2);\n        const date = (\"0\" + this.date.getDate()).slice(-2);\n        return `${year}-${month}-${date}`;\n    }\n\n    /** HH:MM */\n    timeString(): string {\n        const hours = (\"0\" + this.date.getHours()).slice(-2);\n        const minutes = (\"0\" + this.date.getMinutes()).slice(-2);\n        return `${hours}:${minutes}`;\n    }\n}\n"
  },
  {
    "path": "ts/lib/tslib/typing.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function assertUnreachable(x: never): never {\n    throw new Error(`unreachable: ${x}`);\n}\n\nexport type Callback = () => void;\nexport type AsyncCallback = () => Promise<void>;\n\nexport function singleCallback(...callbacks: Callback[]): Callback {\n    return () => {\n        for (const cb of callbacks) {\n            cb();\n        }\n    };\n}\n"
  },
  {
    "path": "ts/lib/tslib/ui.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { promiseWithResolver } from \"./promise\";\nimport { registerPackage } from \"./runtime-require\";\n\nconst [loaded, uiResolve] = promiseWithResolver();\n\nregisterPackage(\"anki/ui\", {\n    loaded,\n});\n\nexport { uiResolve };\n"
  },
  {
    "path": "ts/lib/tslib/wrap.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { getRange, getSelection } from \"./cross-browser\";\n\nfunction wrappedExceptForWhitespace(text: string, front: string, back: string): string {\n    const normalizedText = text\n        .replace(/&nbsp;/g, \" \")\n        .replace(/&#160;/g, \" \")\n        .replace(/\\u00A0/g, \" \");\n\n    const match = normalizedText.match(/^(\\s*)([^]*?)(\\s*)$/)!;\n    return match[1] + front + match[2] + back + match[3];\n}\n\nfunction moveCursorInside(selection: Selection, postfix: string): void {\n    const range = getRange(selection)!;\n\n    range.setEnd(range.endContainer, range.endOffset - postfix.length);\n    range.collapse(false);\n\n    selection.removeAllRanges();\n    selection.addRange(range);\n}\n\nexport function wrapInternal(\n    base: Element,\n    front: string,\n    back: string,\n    plainText: boolean,\n): void {\n    const selection = getSelection(base)!;\n    const range = getRange(selection);\n\n    if (!range) {\n        return;\n    }\n\n    const wasCollapsed = range.collapsed;\n    const content = range.cloneContents();\n    const span = document.createElement(\"span\");\n    span.appendChild(content);\n\n    if (plainText) {\n        const new_ = wrappedExceptForWhitespace(span.innerText, front, back);\n        document.execCommand(\"inserttext\", false, new_);\n    } else {\n        const new_ = wrappedExceptForWhitespace(span.innerHTML, front, back);\n        document.execCommand(\"inserthtml\", false, new_);\n    }\n\n    if (\n        wasCollapsed\n        /* ugly solution: treat <anki-mathjax> differently than other wraps */ && !front.includes(\n            \"<anki-mathjax\",\n        )\n    ) {\n        moveCursorInside(selection, back);\n    }\n}\n"
  },
  {
    "path": "ts/licenses.json",
    "content": "{\n    \"@bufbuild/protobuf@1.10.0\": {\n        \"licenses\": \"(Apache-2.0 AND BSD-3-Clause)\",\n        \"repository\": \"https://github.com/bufbuild/protobuf-es\",\n        \"path\": \"node_modules/@bufbuild/protobuf\",\n        \"licenseFile\": \"node_modules/@bufbuild/protobuf/README.md\"\n    },\n    \"@floating-ui/core@1.6.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/floating-ui/floating-ui\",\n        \"publisher\": \"atomiks\",\n        \"path\": \"node_modules/@floating-ui/core\",\n        \"licenseFile\": \"node_modules/@floating-ui/core/LICENSE\"\n    },\n    \"@floating-ui/dom@1.6.11\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/floating-ui/floating-ui\",\n        \"publisher\": \"atomiks\",\n        \"path\": \"node_modules/@floating-ui/dom\",\n        \"licenseFile\": \"node_modules/@floating-ui/dom/LICENSE\"\n    },\n    \"@floating-ui/utils@0.2.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/floating-ui/floating-ui\",\n        \"publisher\": \"atomiks\",\n        \"path\": \"node_modules/@floating-ui/utils\",\n        \"licenseFile\": \"node_modules/@floating-ui/utils/LICENSE\"\n    },\n    \"@fluent/bundle@0.18.0\": {\n        \"licenses\": \"Apache-2.0\",\n        \"repository\": \"https://github.com/projectfluent/fluent.js\",\n        \"publisher\": \"Mozilla\",\n        \"email\": \"l10n-drivers@mozilla.org\",\n        \"path\": \"node_modules/@fluent/bundle\",\n        \"licenseFile\": \"node_modules/@fluent/bundle/README.md\"\n    },\n    \"@mdi/svg@7.4.47\": {\n        \"licenses\": \"Apache-2.0\",\n        \"repository\": \"https://github.com/Templarian/MaterialDesign-SVG\",\n        \"publisher\": \"Austin Andrews\",\n        \"path\": \"node_modules/@mdi/svg\",\n        \"licenseFile\": \"node_modules/@mdi/svg/LICENSE\"\n    },\n    \"@popperjs/core@2.11.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/popperjs/popper-core\",\n        \"publisher\": \"Federico Zivolo\",\n        \"email\": \"federico.zivolo@gmail.com\",\n        \"path\": \"node_modules/@popperjs/core\",\n        \"licenseFile\": \"node_modules/@popperjs/core/LICENSE.md\"\n    },\n    \"@tootallnate/once@2.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/TooTallNate/once\",\n        \"publisher\": \"Nathan Rajlich\",\n        \"email\": \"nathan@tootallnate.net\",\n        \"path\": \"node_modules/@tootallnate/once\",\n        \"licenseFile\": \"node_modules/@tootallnate/once/LICENSE\"\n    },\n    \"abab@2.0.6\": {\n        \"licenses\": \"BSD-3-Clause\",\n        \"repository\": \"https://github.com/jsdom/abab\",\n        \"publisher\": \"Jeff Carpenter\",\n        \"email\": \"gcarpenterv@gmail.com\",\n        \"path\": \"node_modules/abab\",\n        \"licenseFile\": \"node_modules/abab/LICENSE.md\"\n    },\n    \"acorn-globals@6.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ForbesLindesay/acorn-globals\",\n        \"publisher\": \"ForbesLindesay\",\n        \"path\": \"node_modules/acorn-globals\",\n        \"licenseFile\": \"node_modules/acorn-globals/LICENSE\"\n    },\n    \"acorn-walk@7.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/acornjs/acorn\",\n        \"path\": \"node_modules/acorn-walk\",\n        \"licenseFile\": \"node_modules/acorn-walk/LICENSE\"\n    },\n    \"acorn@7.4.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/acornjs/acorn\",\n        \"path\": \"node_modules/acorn-globals/node_modules/acorn\",\n        \"licenseFile\": \"node_modules/acorn-globals/node_modules/acorn/LICENSE\"\n    },\n    \"acorn@8.13.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/acornjs/acorn\",\n        \"path\": \"node_modules/acorn\",\n        \"licenseFile\": \"node_modules/acorn/LICENSE\"\n    },\n    \"agent-base@6.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/TooTallNate/node-agent-base\",\n        \"publisher\": \"Nathan Rajlich\",\n        \"email\": \"nathan@tootallnate.net\",\n        \"path\": \"node_modules/https-proxy-agent/node_modules/agent-base\",\n        \"licenseFile\": \"node_modules/https-proxy-agent/node_modules/agent-base/README.md\"\n    },\n    \"asynckit@0.4.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/alexindigo/asynckit\",\n        \"publisher\": \"Alex Indigo\",\n        \"email\": \"iam@alexindigo.com\",\n        \"path\": \"node_modules/asynckit\",\n        \"licenseFile\": \"node_modules/asynckit/LICENSE\"\n    },\n    \"bootstrap-icons@1.11.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/twbs/icons\",\n        \"publisher\": \"mdo\",\n        \"path\": \"node_modules/bootstrap-icons\",\n        \"licenseFile\": \"node_modules/bootstrap-icons/LICENSE\"\n    },\n    \"bootstrap@5.3.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/twbs/bootstrap\",\n        \"publisher\": \"The Bootstrap Authors\",\n        \"path\": \"node_modules/bootstrap\",\n        \"licenseFile\": \"node_modules/bootstrap/LICENSE\"\n    },\n    \"browser-process-hrtime@1.0.0\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/kumavis/browser-process-hrtime\",\n        \"publisher\": \"kumavis\",\n        \"path\": \"node_modules/browser-process-hrtime\",\n        \"licenseFile\": \"node_modules/browser-process-hrtime/LICENSE\"\n    },\n    \"call-bind-apply-helpers@1.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/call-bind-apply-helpers\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/call-bind-apply-helpers\",\n        \"licenseFile\": \"node_modules/call-bind-apply-helpers/LICENSE\"\n    },\n    \"codemirror@5.65.18\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/codemirror/CodeMirror\",\n        \"publisher\": \"Marijn Haverbeke\",\n        \"email\": \"marijn@haverbeke.berlin\",\n        \"path\": \"node_modules/codemirror\",\n        \"licenseFile\": \"node_modules/codemirror/LICENSE\"\n    },\n    \"combined-stream@1.0.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/felixge/node-combined-stream\",\n        \"publisher\": \"Felix Geisendörfer\",\n        \"email\": \"felix@debuggable.com\",\n        \"path\": \"node_modules/combined-stream\",\n        \"licenseFile\": \"node_modules/combined-stream/License\"\n    },\n    \"commander@7.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/tj/commander.js\",\n        \"publisher\": \"TJ Holowaychuk\",\n        \"email\": \"tj@vision-media.ca\",\n        \"path\": \"node_modules/commander\",\n        \"licenseFile\": \"node_modules/commander/LICENSE\"\n    },\n    \"cssom@0.3.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/NV/CSSOM\",\n        \"publisher\": \"Nikita Vasilyev\",\n        \"email\": \"me@elv1s.ru\",\n        \"path\": \"node_modules/cssstyle/node_modules/cssom\",\n        \"licenseFile\": \"node_modules/cssstyle/node_modules/cssom/LICENSE.txt\"\n    },\n    \"cssom@0.5.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/NV/CSSOM\",\n        \"publisher\": \"Nikita Vasilyev\",\n        \"email\": \"me@elv1s.ru\",\n        \"path\": \"node_modules/cssom\",\n        \"licenseFile\": \"node_modules/cssom/LICENSE.txt\"\n    },\n    \"cssstyle@2.3.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/cssstyle\",\n        \"path\": \"node_modules/cssstyle\",\n        \"licenseFile\": \"node_modules/cssstyle/LICENSE\"\n    },\n    \"d3-array@3.2.4\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-array\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-array\",\n        \"licenseFile\": \"node_modules/d3-array/LICENSE\"\n    },\n    \"d3-axis@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-axis\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-axis\",\n        \"licenseFile\": \"node_modules/d3-axis/LICENSE\"\n    },\n    \"d3-brush@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-brush\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-brush\",\n        \"licenseFile\": \"node_modules/d3-brush/LICENSE\"\n    },\n    \"d3-chord@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-chord\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-chord\",\n        \"licenseFile\": \"node_modules/d3-chord/LICENSE\"\n    },\n    \"d3-color@3.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-color\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-color\",\n        \"licenseFile\": \"node_modules/d3-color/LICENSE\"\n    },\n    \"d3-contour@4.0.2\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-contour\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-contour\",\n        \"licenseFile\": \"node_modules/d3-contour/LICENSE\"\n    },\n    \"d3-delaunay@6.0.4\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-delaunay\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-delaunay\",\n        \"licenseFile\": \"node_modules/d3-delaunay/LICENSE\"\n    },\n    \"d3-dispatch@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-dispatch\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-dispatch\",\n        \"licenseFile\": \"node_modules/d3-dispatch/LICENSE\"\n    },\n    \"d3-drag@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-drag\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-drag\",\n        \"licenseFile\": \"node_modules/d3-drag/LICENSE\"\n    },\n    \"d3-dsv@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-dsv\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-dsv\",\n        \"licenseFile\": \"node_modules/d3-dsv/LICENSE\"\n    },\n    \"d3-ease@3.0.1\": {\n        \"licenses\": \"BSD-3-Clause\",\n        \"repository\": \"https://github.com/d3/d3-ease\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-ease\",\n        \"licenseFile\": \"node_modules/d3-ease/LICENSE\"\n    },\n    \"d3-fetch@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-fetch\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-fetch\",\n        \"licenseFile\": \"node_modules/d3-fetch/LICENSE\"\n    },\n    \"d3-force@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-force\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-force\",\n        \"licenseFile\": \"node_modules/d3-force/LICENSE\"\n    },\n    \"d3-format@3.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-format\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-format\",\n        \"licenseFile\": \"node_modules/d3-format/LICENSE\"\n    },\n    \"d3-geo@3.1.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-geo\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-geo\",\n        \"licenseFile\": \"node_modules/d3-geo/LICENSE\"\n    },\n    \"d3-hierarchy@3.1.2\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-hierarchy\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-hierarchy\",\n        \"licenseFile\": \"node_modules/d3-hierarchy/LICENSE\"\n    },\n    \"d3-interpolate@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-interpolate\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-interpolate\",\n        \"licenseFile\": \"node_modules/d3-interpolate/LICENSE\"\n    },\n    \"d3-path@3.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-path\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-path\",\n        \"licenseFile\": \"node_modules/d3-path/LICENSE\"\n    },\n    \"d3-polygon@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-polygon\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-polygon\",\n        \"licenseFile\": \"node_modules/d3-polygon/LICENSE\"\n    },\n    \"d3-quadtree@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-quadtree\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-quadtree\",\n        \"licenseFile\": \"node_modules/d3-quadtree/LICENSE\"\n    },\n    \"d3-random@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-random\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-random\",\n        \"licenseFile\": \"node_modules/d3-random/LICENSE\"\n    },\n    \"d3-scale-chromatic@3.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-scale-chromatic\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-scale-chromatic\",\n        \"licenseFile\": \"node_modules/d3-scale-chromatic/LICENSE\"\n    },\n    \"d3-scale@4.0.2\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-scale\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-scale\",\n        \"licenseFile\": \"node_modules/d3-scale/LICENSE\"\n    },\n    \"d3-selection@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-selection\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-selection\",\n        \"licenseFile\": \"node_modules/d3-selection/LICENSE\"\n    },\n    \"d3-shape@3.2.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-shape\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-shape\",\n        \"licenseFile\": \"node_modules/d3-shape/LICENSE\"\n    },\n    \"d3-time-format@4.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-time-format\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-time-format\",\n        \"licenseFile\": \"node_modules/d3-time-format/LICENSE\"\n    },\n    \"d3-time@3.1.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-time\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-time\",\n        \"licenseFile\": \"node_modules/d3-time/LICENSE\"\n    },\n    \"d3-timer@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-timer\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-timer\",\n        \"licenseFile\": \"node_modules/d3-timer/LICENSE\"\n    },\n    \"d3-transition@3.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-transition\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-transition\",\n        \"licenseFile\": \"node_modules/d3-transition/LICENSE\"\n    },\n    \"d3-zoom@3.0.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3-zoom\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3-zoom\",\n        \"licenseFile\": \"node_modules/d3-zoom/LICENSE\"\n    },\n    \"d3@7.9.0\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/d3/d3\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/d3\",\n        \"licenseFile\": \"node_modules/d3/LICENSE\"\n    },\n    \"data-urls@3.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/data-urls\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/data-urls\",\n        \"licenseFile\": \"node_modules/data-urls/LICENSE.txt\"\n    },\n    \"debug@4.3.7\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/debug-js/debug\",\n        \"publisher\": \"Josh Junon\",\n        \"path\": \"node_modules/debug\",\n        \"licenseFile\": \"node_modules/debug/LICENSE\"\n    },\n    \"decimal.js@10.4.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/MikeMcl/decimal.js\",\n        \"publisher\": \"Michael Mclaughlin\",\n        \"email\": \"M8ch88l@gmail.com\",\n        \"path\": \"node_modules/decimal.js\",\n        \"licenseFile\": \"node_modules/decimal.js/LICENCE.md\"\n    },\n    \"delaunator@5.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/mapbox/delaunator\",\n        \"publisher\": \"Vladimir Agafonkin\",\n        \"path\": \"node_modules/delaunator\",\n        \"licenseFile\": \"node_modules/delaunator/LICENSE\"\n    },\n    \"delayed-stream@1.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/felixge/node-delayed-stream\",\n        \"publisher\": \"Felix Geisendörfer\",\n        \"email\": \"felix@debuggable.com\",\n        \"path\": \"node_modules/delayed-stream\",\n        \"licenseFile\": \"node_modules/delayed-stream/License\"\n    },\n    \"domexception@4.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/domexception\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/domexception\",\n        \"licenseFile\": \"node_modules/domexception/LICENSE.txt\"\n    },\n    \"dunder-proto@1.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/es-shims/dunder-proto\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/dunder-proto\",\n        \"licenseFile\": \"node_modules/dunder-proto/LICENSE\"\n    },\n    \"empty-npm-package@1.0.0\": {\n        \"licenses\": \"ISC\",\n        \"path\": \"node_modules/canvas\"\n    },\n    \"es-define-property@1.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/es-define-property\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-set-tostringtag/node_modules/es-define-property\",\n        \"licenseFile\": \"node_modules/es-set-tostringtag/node_modules/es-define-property/LICENSE\"\n    },\n    \"es-errors@1.3.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/es-errors\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-errors\",\n        \"licenseFile\": \"node_modules/es-errors/LICENSE\"\n    },\n    \"es-object-atoms@1.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/es-object-atoms\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-object-atoms\",\n        \"licenseFile\": \"node_modules/es-object-atoms/LICENSE\"\n    },\n    \"es-object-atoms@1.1.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/es-object-atoms\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-set-tostringtag/node_modules/es-object-atoms\",\n        \"licenseFile\": \"node_modules/es-set-tostringtag/node_modules/es-object-atoms/LICENSE\"\n    },\n    \"es-set-tostringtag@2.1.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/es-shims/es-set-tostringtag\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-set-tostringtag\",\n        \"licenseFile\": \"node_modules/es-set-tostringtag/LICENSE\"\n    },\n    \"escodegen@2.1.0\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/estools/escodegen\",\n        \"path\": \"node_modules/escodegen\",\n        \"licenseFile\": \"node_modules/escodegen/LICENSE.BSD\"\n    },\n    \"esprima@4.0.1\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/jquery/esprima\",\n        \"publisher\": \"Ariya Hidayat\",\n        \"email\": \"ariya.hidayat@gmail.com\",\n        \"path\": \"node_modules/esprima\",\n        \"licenseFile\": \"node_modules/esprima/LICENSE.BSD\"\n    },\n    \"estraverse@5.3.0\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/estools/estraverse\",\n        \"path\": \"node_modules/estraverse\",\n        \"licenseFile\": \"node_modules/estraverse/LICENSE.BSD\"\n    },\n    \"esutils@2.0.3\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/estools/esutils\",\n        \"path\": \"node_modules/esutils\",\n        \"licenseFile\": \"node_modules/esutils/LICENSE.BSD\"\n    },\n    \"fabric@5.4.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/fabricjs/fabric.js\",\n        \"publisher\": \"Juriy Zaytsev\",\n        \"email\": \"kangax@gmail.com\",\n        \"path\": \"node_modules/fabric\",\n        \"licenseFile\": \"node_modules/fabric/LICENSE\"\n    },\n    \"form-data@4.0.4\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/form-data/form-data\",\n        \"publisher\": \"Felix Geisendörfer\",\n        \"email\": \"felix@debuggable.com\",\n        \"path\": \"node_modules/form-data\",\n        \"licenseFile\": \"node_modules/form-data/License\"\n    },\n    \"function-bind@1.1.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/Raynos/function-bind\",\n        \"publisher\": \"Raynos\",\n        \"email\": \"raynos2@gmail.com\",\n        \"path\": \"node_modules/function-bind\",\n        \"licenseFile\": \"node_modules/function-bind/LICENSE\"\n    },\n    \"get-intrinsic@1.3.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/get-intrinsic\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-set-tostringtag/node_modules/get-intrinsic\",\n        \"licenseFile\": \"node_modules/es-set-tostringtag/node_modules/get-intrinsic/LICENSE\"\n    },\n    \"get-proto@1.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/get-proto\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/get-proto\",\n        \"licenseFile\": \"node_modules/get-proto/LICENSE\"\n    },\n    \"gopd@1.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ljharb/gopd\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/dunder-proto/node_modules/gopd\",\n        \"licenseFile\": \"node_modules/dunder-proto/node_modules/gopd/LICENSE\"\n    },\n    \"hammerjs@2.0.8\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/hammerjs/hammer.js\",\n        \"publisher\": \"Jorik Tangelder\",\n        \"email\": \"j.tangelder@gmail.com\",\n        \"path\": \"node_modules/hammerjs\",\n        \"licenseFile\": \"node_modules/hammerjs/LICENSE.md\"\n    },\n    \"has-symbols@1.0.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/inspect-js/has-symbols\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/has-symbols\",\n        \"licenseFile\": \"node_modules/has-symbols/LICENSE\"\n    },\n    \"has-symbols@1.1.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/inspect-js/has-symbols\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/es-set-tostringtag/node_modules/has-symbols\",\n        \"licenseFile\": \"node_modules/es-set-tostringtag/node_modules/has-symbols/LICENSE\"\n    },\n    \"has-tostringtag@1.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/inspect-js/has-tostringtag\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/has-tostringtag\",\n        \"licenseFile\": \"node_modules/has-tostringtag/LICENSE\"\n    },\n    \"hasown@2.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/inspect-js/hasOwn\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/hasown\",\n        \"licenseFile\": \"node_modules/hasown/LICENSE\"\n    },\n    \"html-encoding-sniffer@3.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/html-encoding-sniffer\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/html-encoding-sniffer\",\n        \"licenseFile\": \"node_modules/html-encoding-sniffer/LICENSE.txt\"\n    },\n    \"http-proxy-agent@5.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/TooTallNate/node-http-proxy-agent\",\n        \"publisher\": \"Nathan Rajlich\",\n        \"email\": \"nathan@tootallnate.net\",\n        \"path\": \"node_modules/http-proxy-agent\",\n        \"licenseFile\": \"node_modules/http-proxy-agent/README.md\"\n    },\n    \"https-proxy-agent@5.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/TooTallNate/node-https-proxy-agent\",\n        \"publisher\": \"Nathan Rajlich\",\n        \"email\": \"nathan@tootallnate.net\",\n        \"path\": \"node_modules/https-proxy-agent\",\n        \"licenseFile\": \"node_modules/https-proxy-agent/README.md\"\n    },\n    \"iconv-lite@0.6.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ashtuchkin/iconv-lite\",\n        \"publisher\": \"Alexander Shtuchkin\",\n        \"email\": \"ashtuchkin@gmail.com\",\n        \"path\": \"node_modules/iconv-lite\",\n        \"licenseFile\": \"node_modules/iconv-lite/LICENSE\"\n    },\n    \"internmap@2.0.3\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/mbostock/internmap\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/internmap\",\n        \"licenseFile\": \"node_modules/internmap/LICENSE\"\n    },\n    \"intl-pluralrules@2.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/eemeli/intl-pluralrules\",\n        \"publisher\": \"Eemeli Aro\",\n        \"email\": \"eemeli@gmail.com\",\n        \"path\": \"node_modules/intl-pluralrules\",\n        \"licenseFile\": \"node_modules/intl-pluralrules/LICENSE\"\n    },\n    \"is-potential-custom-element-name@1.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/mathiasbynens/is-potential-custom-element-name\",\n        \"publisher\": \"Mathias Bynens\",\n        \"path\": \"node_modules/is-potential-custom-element-name\",\n        \"licenseFile\": \"node_modules/is-potential-custom-element-name/LICENSE-MIT.txt\"\n    },\n    \"jquery-ui-dist@1.13.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jquery/jquery-ui\",\n        \"publisher\": \"OpenJS Foundation and other contributors\",\n        \"path\": \"node_modules/jquery-ui-dist\",\n        \"licenseFile\": \"node_modules/jquery-ui-dist/LICENSE.txt\"\n    },\n    \"jquery@3.7.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jquery/jquery\",\n        \"publisher\": \"OpenJS Foundation and other contributors\",\n        \"path\": \"node_modules/jquery\",\n        \"licenseFile\": \"node_modules/jquery/LICENSE.txt\"\n    },\n    \"jsdom@19.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/jsdom\",\n        \"path\": \"node_modules/jsdom\",\n        \"licenseFile\": \"node_modules/jsdom/LICENSE.txt\"\n    },\n    \"lodash-es@4.17.23\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/lodash/lodash\",\n        \"publisher\": \"John-David Dalton\",\n        \"email\": \"john.david.dalton@gmail.com\",\n        \"path\": \"node_modules/lodash-es\",\n        \"licenseFile\": \"node_modules/lodash-es/LICENSE\"\n    },\n    \"lru-cache@10.4.3\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/isaacs/node-lru-cache\",\n        \"publisher\": \"Isaac Z. Schlueter\",\n        \"email\": \"i@izs.me\",\n        \"path\": \"node_modules/lru-cache\",\n        \"licenseFile\": \"node_modules/lru-cache/LICENSE\"\n    },\n    \"marked@5.1.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/markedjs/marked\",\n        \"publisher\": \"Christopher Jeffrey\",\n        \"path\": \"node_modules/marked\",\n        \"licenseFile\": \"node_modules/marked/LICENSE.md\"\n    },\n    \"math-intrinsics@1.1.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/es-shims/math-intrinsics\",\n        \"publisher\": \"Jordan Harband\",\n        \"email\": \"ljharb@gmail.com\",\n        \"path\": \"node_modules/math-intrinsics\",\n        \"licenseFile\": \"node_modules/math-intrinsics/LICENSE\"\n    },\n    \"mathjax@3.2.2\": {\n        \"licenses\": \"Apache-2.0\",\n        \"repository\": \"https://github.com/mathjax/MathJax\",\n        \"path\": \"node_modules/mathjax\",\n        \"licenseFile\": \"node_modules/mathjax/LICENSE\"\n    },\n    \"mime-db@1.52.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jshttp/mime-db\",\n        \"path\": \"node_modules/mime-db\",\n        \"licenseFile\": \"node_modules/mime-db/LICENSE\"\n    },\n    \"mime-types@2.1.35\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jshttp/mime-types\",\n        \"path\": \"node_modules/mime-types\",\n        \"licenseFile\": \"node_modules/mime-types/LICENSE\"\n    },\n    \"ms@2.1.3\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/vercel/ms\",\n        \"path\": \"node_modules/ms\",\n        \"licenseFile\": \"node_modules/ms/license.md\"\n    },\n    \"nwsapi@2.2.13\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/dperini/nwsapi\",\n        \"publisher\": \"Diego Perini\",\n        \"email\": \"diego.perini@gmail.com\",\n        \"path\": \"node_modules/nwsapi\",\n        \"licenseFile\": \"node_modules/nwsapi/LICENSE\"\n    },\n    \"parse5@6.0.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/inikulin/parse5\",\n        \"publisher\": \"Ivan Nikulin\",\n        \"email\": \"ifaaan@gmail.com\",\n        \"path\": \"node_modules/parse5\",\n        \"licenseFile\": \"node_modules/parse5/LICENSE\"\n    },\n    \"psl@1.9.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/lupomontero/psl\",\n        \"publisher\": \"Lupo Montero\",\n        \"email\": \"lupomontero@gmail.com\",\n        \"path\": \"node_modules/psl\",\n        \"licenseFile\": \"node_modules/psl/LICENSE\"\n    },\n    \"punycode@2.3.1\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/mathiasbynens/punycode.js\",\n        \"publisher\": \"Mathias Bynens\",\n        \"path\": \"node_modules/punycode\",\n        \"licenseFile\": \"node_modules/punycode/LICENSE-MIT.txt\"\n    },\n    \"querystringify@2.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/unshiftio/querystringify\",\n        \"publisher\": \"Arnout Kazemier\",\n        \"path\": \"node_modules/querystringify\",\n        \"licenseFile\": \"node_modules/querystringify/LICENSE\"\n    },\n    \"requires-port@1.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/unshiftio/requires-port\",\n        \"publisher\": \"Arnout Kazemier\",\n        \"path\": \"node_modules/requires-port\",\n        \"licenseFile\": \"node_modules/requires-port/LICENSE\"\n    },\n    \"robust-predicates@3.0.2\": {\n        \"licenses\": \"Unlicense\",\n        \"repository\": \"https://github.com/mourner/robust-predicates\",\n        \"publisher\": \"Vladimir Agafonkin\",\n        \"path\": \"node_modules/robust-predicates\",\n        \"licenseFile\": \"node_modules/robust-predicates/LICENSE\"\n    },\n    \"rw@1.3.3\": {\n        \"licenses\": \"BSD-3-Clause\",\n        \"repository\": \"https://github.com/mbostock/rw\",\n        \"publisher\": \"Mike Bostock\",\n        \"path\": \"node_modules/rw\",\n        \"licenseFile\": \"node_modules/rw/LICENSE\"\n    },\n    \"safer-buffer@2.1.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/ChALkeR/safer-buffer\",\n        \"publisher\": \"Nikita Skovoroda\",\n        \"email\": \"chalkerx@gmail.com\",\n        \"path\": \"node_modules/safer-buffer\",\n        \"licenseFile\": \"node_modules/safer-buffer/LICENSE\"\n    },\n    \"saxes@5.0.1\": {\n        \"licenses\": \"ISC\",\n        \"repository\": \"https://github.com/lddubeau/saxes\",\n        \"publisher\": \"Louis-Dominique Dubeau\",\n        \"email\": \"ldd@lddubeau.com\",\n        \"path\": \"node_modules/saxes\",\n        \"licenseFile\": \"node_modules/saxes/README.md\"\n    },\n    \"source-map@0.6.1\": {\n        \"licenses\": \"BSD-3-Clause\",\n        \"repository\": \"https://github.com/mozilla/source-map\",\n        \"publisher\": \"Nick Fitzgerald\",\n        \"email\": \"nfitzgerald@mozilla.com\",\n        \"path\": \"node_modules/source-map\",\n        \"licenseFile\": \"node_modules/source-map/LICENSE\"\n    },\n    \"symbol-tree@3.2.4\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/js-symbol-tree\",\n        \"publisher\": \"Joris van der Wel\",\n        \"email\": \"joris@jorisvanderwel.com\",\n        \"path\": \"node_modules/symbol-tree\",\n        \"licenseFile\": \"node_modules/symbol-tree/LICENSE\"\n    },\n    \"tough-cookie@4.1.4\": {\n        \"licenses\": \"BSD-3-Clause\",\n        \"repository\": \"https://github.com/salesforce/tough-cookie\",\n        \"publisher\": \"Jeremy Stashewsky\",\n        \"email\": \"jstash@gmail.com\",\n        \"path\": \"node_modules/tough-cookie\",\n        \"licenseFile\": \"node_modules/tough-cookie/LICENSE\"\n    },\n    \"tr46@3.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/tr46\",\n        \"publisher\": \"Sebastian Mayr\",\n        \"email\": \"npm@smayr.name\",\n        \"path\": \"node_modules/tr46\",\n        \"licenseFile\": \"node_modules/tr46/LICENSE.md\"\n    },\n    \"universalify@0.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/RyanZim/universalify\",\n        \"publisher\": \"Ryan Zimmerman\",\n        \"email\": \"opensrc@ryanzim.com\",\n        \"path\": \"node_modules/universalify\",\n        \"licenseFile\": \"node_modules/universalify/LICENSE\"\n    },\n    \"url-parse@1.5.10\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/unshiftio/url-parse\",\n        \"publisher\": \"Arnout Kazemier\",\n        \"path\": \"node_modules/url-parse\",\n        \"licenseFile\": \"node_modules/url-parse/LICENSE\"\n    },\n    \"w3c-hr-time@1.0.2\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/w3c-hr-time\",\n        \"publisher\": \"Timothy Gu\",\n        \"email\": \"timothygu99@gmail.com\",\n        \"path\": \"node_modules/w3c-hr-time\",\n        \"licenseFile\": \"node_modules/w3c-hr-time/LICENSE.md\"\n    },\n    \"w3c-xmlserializer@3.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/w3c-xmlserializer\",\n        \"path\": \"node_modules/w3c-xmlserializer\",\n        \"licenseFile\": \"node_modules/w3c-xmlserializer/LICENSE.md\"\n    },\n    \"webidl-conversions@7.0.0\": {\n        \"licenses\": \"BSD-2-Clause\",\n        \"repository\": \"https://github.com/jsdom/webidl-conversions\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/webidl-conversions\",\n        \"licenseFile\": \"node_modules/webidl-conversions/LICENSE.md\"\n    },\n    \"whatwg-encoding@2.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/whatwg-encoding\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/whatwg-encoding\",\n        \"licenseFile\": \"node_modules/whatwg-encoding/LICENSE.txt\"\n    },\n    \"whatwg-mimetype@3.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/whatwg-mimetype\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/whatwg-mimetype\",\n        \"licenseFile\": \"node_modules/whatwg-mimetype/LICENSE.txt\"\n    },\n    \"whatwg-url@10.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/whatwg-url\",\n        \"publisher\": \"Sebastian Mayr\",\n        \"email\": \"github@smayr.name\",\n        \"path\": \"node_modules/jsdom/node_modules/whatwg-url\",\n        \"licenseFile\": \"node_modules/jsdom/node_modules/whatwg-url/LICENSE.txt\"\n    },\n    \"whatwg-url@11.0.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/jsdom/whatwg-url\",\n        \"publisher\": \"Sebastian Mayr\",\n        \"email\": \"github@smayr.name\",\n        \"path\": \"node_modules/whatwg-url\",\n        \"licenseFile\": \"node_modules/whatwg-url/LICENSE.txt\"\n    },\n    \"ws@8.18.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/websockets/ws\",\n        \"publisher\": \"Einar Otto Stangvik\",\n        \"email\": \"einaros@gmail.com\",\n        \"path\": \"node_modules/ws\",\n        \"licenseFile\": \"node_modules/ws/LICENSE\"\n    },\n    \"xml-name-validator@4.0.0\": {\n        \"licenses\": \"Apache-2.0\",\n        \"repository\": \"https://github.com/jsdom/xml-name-validator\",\n        \"publisher\": \"Domenic Denicola\",\n        \"email\": \"d@domenic.me\",\n        \"path\": \"node_modules/xml-name-validator\",\n        \"licenseFile\": \"node_modules/xml-name-validator/LICENSE.txt\"\n    },\n    \"xmlchars@2.2.0\": {\n        \"licenses\": \"MIT\",\n        \"repository\": \"https://github.com/lddubeau/xmlchars\",\n        \"publisher\": \"Louis-Dominique Dubeau\",\n        \"email\": \"ldd@lddubeau.com\",\n        \"path\": \"node_modules/xmlchars\",\n        \"licenseFile\": \"node_modules/xmlchars/LICENSE\"\n    }\n}\n\n"
  },
  {
    "path": "ts/mathjax/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/// <reference types=\"./mathjax-types\" />\n\nconst packages = [\"noerrors\", \"mathtools\"];\n\nfunction packagesForLoading(packages: string[]): string[] {\n    return packages.map((value: string): string => `[tex]/${value}`);\n}\n\nwindow.MathJax = {\n    tex: {\n        displayMath: [[\"\\\\[\", \"\\\\]\"]],\n        processEscapes: false,\n        processEnvironments: false,\n        processRefs: false,\n        packages: {\n            \"[+]\": packages,\n            \"[-]\": [\"textmacros\"],\n        },\n    },\n    loader: {\n        load: packagesForLoading(packages),\n        paths: {\n            mathjax: \"/_anki/js/vendor/mathjax\",\n        },\n    },\n    startup: {\n        typeset: false,\n    },\n};\n"
  },
  {
    "path": "ts/mathjax/mathjax-types.d.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nexport {};\n\ndeclare global {\n    interface Window {\n        // Mathjax does not provide a full type\n        MathJax: { [name: string]: any };\n    }\n}\n"
  },
  {
    "path": "ts/page.html",
    "content": "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"utf-8\" />\n        <meta name=\"viewport\" id=\"viewport\" content=\"width=device-width\" />\n        <link href=\"{PAGE}.css\" rel=\"stylesheet\" />\n        <script src=\"{PAGE}.js\" defer></script>\n    </head>\n    <body></body>\n</html>\n"
  },
  {
    "path": "ts/reviewer/answering.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { JsonValue } from \"@bufbuild/protobuf\";\nimport type { SchedulingStatesWithContext } from \"@generated/anki/frontend_pb\";\nimport type { SchedulingContext } from \"@generated/anki/scheduler_pb\";\nimport { SchedulingStates } from \"@generated/anki/scheduler_pb\";\nimport { getSchedulingStatesWithContext, setSchedulingStates } from \"@generated/backend\";\n\ninterface CustomDataStates {\n    again: Record<string, unknown>;\n    hard: Record<string, unknown>;\n    good: Record<string, unknown>;\n    easy: Record<string, unknown>;\n}\n\nfunction unpackCustomData(states: SchedulingStates): CustomDataStates {\n    const toObject = (s: string): Record<string, unknown> => {\n        try {\n            return JSON.parse(s);\n        } catch {\n            return {};\n        }\n    };\n    return {\n        again: toObject(states.current!.customData!),\n        hard: toObject(states.current!.customData!),\n        good: toObject(states.current!.customData!),\n        easy: toObject(states.current!.customData!),\n    };\n}\n\nfunction packCustomData(\n    states: SchedulingStates,\n    customData: CustomDataStates,\n) {\n    states.again!.customData = JSON.stringify(customData.again);\n    states.hard!.customData = JSON.stringify(customData.hard);\n    states.good!.customData = JSON.stringify(customData.good);\n    states.easy!.customData = JSON.stringify(customData.easy);\n}\n\ntype StateMutatorFn = (states: JsonValue, customData: CustomDataStates, ctx: SchedulingContext) => Promise<void>;\n\nexport async function mutateNextCardStates(\n    key: string,\n    transform: StateMutatorFn,\n): Promise<void> {\n    const statesWithContext = await getSchedulingStatesWithContext({});\n    const updatedStates = await applyStateTransform(statesWithContext, transform);\n    await setSchedulingStates({ key, states: updatedStates });\n}\n\n/** Exported only for tests */\nexport async function applyStateTransform(\n    states: SchedulingStatesWithContext,\n    transform: StateMutatorFn,\n): Promise<SchedulingStates> {\n    // convert to JSON, which is the format existing transforms expect\n    const statesJson = states.states!.toJson({ emitDefaultValues: true });\n\n    // decode customData and put it into each state\n    const customData = unpackCustomData(states.states!);\n\n    // run the user function on the JSON\n    await transform(statesJson, customData, states.context!);\n\n    // convert the JSON back into proto form, and pack the custom data in\n    const updatedStates = SchedulingStates.fromJson(statesJson);\n    packCustomData(updatedStates, customData);\n\n    return updatedStates;\n}\n"
  },
  {
    "path": "ts/reviewer/browser_selector.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport function addBrowserClasses() {\n    const ua = navigator.userAgent.toLowerCase();\n\n    function addClass(className: string) {\n        document.documentElement.classList.add(className);\n    }\n\n    function test(regex: RegExp): boolean {\n        return regex.test(ua);\n    }\n\n    if (test(/ipad/)) {\n        addClass(\"ipad\");\n    } else if (test(/iphone/)) {\n        addClass(\"iphone\");\n    } else if (test(/android/)) {\n        addClass(\"android\");\n    }\n\n    if (test(/ipad|iphone|ipod/)) {\n        addClass(\"ios\");\n    }\n\n    if (test(/ipad|iphone|ipod|android/)) {\n        addClass(\"mobile\");\n    } else if (test(/linux/)) {\n        addClass(\"linux\");\n    } else if (test(/windows/)) {\n        addClass(\"win\");\n    } else if (test(/mac/)) {\n        addClass(\"mac\");\n    }\n\n    if (test(/firefox\\//)) {\n        addClass(\"firefox\");\n    } else if (test(/chrome\\//)) {\n        addClass(\"chrome\");\n    } else if (test(/safari\\//)) {\n        addClass(\"safari\");\n    }\n}\n"
  },
  {
    "path": "ts/reviewer/images.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { noop } from \"lodash-es\";\n\nconst template = document.createElement(\"template\");\n\nexport function allImagesLoaded(): Promise<void[]> {\n    return Promise.all(\n        Array.from(document.getElementsByTagName(\"img\")).map(imageLoaded),\n    );\n}\n\nfunction imageLoaded(img: HTMLImageElement): Promise<void> {\n    return img.complete\n        ? Promise.resolve()\n        : new Promise((resolve) => {\n            img.addEventListener(\"load\", () => resolve());\n            img.addEventListener(\"error\", () => resolve());\n        });\n}\n\nfunction extractImageSrcs(fragment: DocumentFragment): string[] {\n    const srcs = [...fragment.querySelectorAll<HTMLImageElement>(\"img[src]\")].map(\n        (img) => img.src,\n    );\n    return srcs;\n}\n\nfunction createImage(src: string): HTMLImageElement {\n    const img = new Image();\n    img.decoding = \"async\";\n    img.src = src;\n    img.decode().catch(noop);\n    return img;\n}\n\nexport function preloadAnswerImages(html: string): void {\n    template.innerHTML = html;\n    extractImageSrcs(template.content).forEach(createImage);\n}\n\n/** Prevent flickering & layout shift on image load */\nexport function preloadImages(fragment: DocumentFragment): Promise<void>[] {\n    return extractImageSrcs(fragment).map(createImage).map(imageLoaded);\n}\n"
  },
  {
    "path": "ts/reviewer/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nexport { default as $, default as jQuery } from \"jquery/dist/jquery\";\n\nimport { imageOcclusionAPI } from \"../routes/image-occlusion/review\";\nimport { mutateNextCardStates } from \"./answering\";\nimport { addBrowserClasses } from \"./browser_selector\";\n\nglobalThis.anki = globalThis.anki || {};\nglobalThis.anki.mutateNextCardStates = mutateNextCardStates;\nglobalThis.anki.imageOcclusion = imageOcclusionAPI;\nglobalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated\n\nimport { bridgeCommand } from \"@tslib/bridgecommand\";\nimport { registerPackage } from \"@tslib/runtime-require\";\n\nimport { allImagesLoaded, preloadAnswerImages } from \"./images\";\nimport { preloadResources } from \"./preload\";\n\ndeclare const MathJax: any;\n\ntype Callback = () => void | Promise<void>;\n\nexport const onUpdateHook: Array<Callback> = [];\nexport const onShownHook: Array<Callback> = [];\n\nexport const ankiPlatform = \"desktop\";\nlet typeans: HTMLInputElement | undefined;\n\nexport function getTypedAnswer(): string | null {\n    return typeans?.value ?? null;\n}\n\nfunction _runHook(\n    hooks: Array<Callback>,\n): Promise<PromiseSettledResult<void | Promise<void>>[]> {\n    const promises: (Promise<void> | void)[] = [];\n\n    for (const hook of hooks) {\n        try {\n            const result = hook();\n            promises.push(result);\n        } catch (error) {\n            console.log(\"Hook failed: \", error);\n        }\n    }\n\n    return Promise.allSettled(promises);\n}\n\nlet _updatingQueue: Promise<void> = Promise.resolve();\n\nexport function _queueAction(action: Callback): void {\n    _updatingQueue = _updatingQueue.then(action);\n}\n\n// Setting innerHTML does not evaluate the contents of script tags, so we need\n// to add them again in order to trigger the download/evaluation. Promise resolves\n// when download/evaluation has completed.\nfunction replaceScript(oldScript: HTMLScriptElement): Promise<void> {\n    return new Promise((resolve) => {\n        const newScript = document.createElement(\"script\");\n        let mustWaitForNetwork = true;\n        if (oldScript.src) {\n            newScript.addEventListener(\"load\", () => resolve());\n            newScript.addEventListener(\"error\", () => resolve());\n        } else {\n            mustWaitForNetwork = false;\n        }\n\n        for (const attribute of oldScript.attributes) {\n            newScript.setAttribute(attribute.name, attribute.value);\n        }\n\n        newScript.appendChild(document.createTextNode(oldScript.innerHTML));\n        oldScript.replaceWith(newScript);\n\n        if (!mustWaitForNetwork) {\n            resolve();\n        }\n    });\n}\n\nasync function setInnerHTML(element: Element, html: string): Promise<void> {\n    for (const oldVideo of element.getElementsByTagName(\"video\")) {\n        oldVideo.pause();\n\n        while (oldVideo.firstChild) {\n            oldVideo.removeChild(oldVideo.firstChild);\n        }\n\n        oldVideo.load();\n    }\n\n    element.innerHTML = html;\n\n    for (const oldScript of element.getElementsByTagName(\"script\")) {\n        await replaceScript(oldScript);\n    }\n}\n\nconst renderError = (type: string) => (error: unknown): string => {\n    const errorMessage = String(error).substring(0, 2000);\n    let errorStack: string;\n    if (error instanceof Error) {\n        errorStack = String(error.stack).substring(0, 2000);\n    } else {\n        errorStack = \"\";\n    }\n    return `<div>Invalid ${type} on card: ${errorMessage}\\n${errorStack}</div>`.replace(\n        /\\n/g,\n        \"<br>\",\n    );\n};\n\nexport async function _updateQA(\n    html: string,\n    _unusused: unknown,\n    onupdate: Callback,\n    onshown: Callback,\n): Promise<void> {\n    onUpdateHook.length = 0;\n    onUpdateHook.push(onupdate);\n\n    onShownHook.length = 0;\n    onShownHook.push(onshown);\n\n    const qa = document.getElementById(\"qa\")!;\n\n    await preloadResources(html);\n\n    qa.style.opacity = \"0\";\n\n    try {\n        await setInnerHTML(qa, html);\n    } catch (error) {\n        await setInnerHTML(qa, renderError(\"html\")(error));\n    }\n\n    await _runHook(onUpdateHook);\n\n    // dynamic toolbar background\n    bridgeCommand(\"updateToolbar\");\n\n    // wait for mathjax to ready\n    await MathJax.startup.promise\n        .then(() => {\n            // clear MathJax buffers from previous typesets\n            MathJax.typesetClear();\n\n            return MathJax.typesetPromise([qa]);\n        })\n        .catch(renderError(\"MathJax\"));\n\n    qa.style.opacity = \"1\";\n\n    await _runHook(onShownHook);\n}\n\nexport function _showQuestion(q: string, a: string, bodyclass: string): void {\n    _queueAction(() =>\n        _updateQA(\n            q,\n            null,\n            function() {\n                // return to top of window\n                window.scrollTo(0, 0);\n\n                document.body.className = bodyclass;\n            },\n            function() {\n                // focus typing area if visible\n                typeans = document.getElementById(\"typeans\") as HTMLInputElement;\n                if (typeans) {\n                    typeans.focus();\n                }\n                // preload images\n                allImagesLoaded().then(() => preloadAnswerImages(a));\n            },\n        )\n    );\n}\n\nfunction scrollToAnswer(): void {\n    document.getElementById(\"answer\")?.scrollIntoView();\n}\n\nexport function _showAnswer(a: string, bodyclass: string): void {\n    _queueAction(() =>\n        _updateQA(\n            a,\n            null,\n            function() {\n                if (bodyclass) {\n                    //  when previewing\n                    document.body.className = bodyclass;\n                }\n\n                // avoid scrolling to the answer until images load\n                allImagesLoaded().then(scrollToAnswer);\n            },\n            function() {\n                /* noop */\n            },\n        )\n    );\n}\n\nexport function _drawFlag(flag: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): void {\n    const elem = document.getElementById(\"_flag\")!;\n    elem.toggleAttribute(\"hidden\", flag === 0);\n    elem.style.color = `var(--flag-${flag})`;\n}\n\nexport function _drawMark(mark: boolean): void {\n    document.getElementById(\"_mark\")!.toggleAttribute(\"hidden\", !mark);\n}\n\nexport function _typeAnsPress(): void {\n    const key = (window.event as KeyboardEvent).key;\n    if (key === \"Enter\") {\n        bridgeCommand(\"ans\");\n    }\n}\n\nexport function _emulateMobile(enabled: boolean): void {\n    document.documentElement.classList.toggle(\"mobile\", enabled);\n}\n\n// Block Qt's default drag & drop behavior by default\nexport function _blockDefaultDragDropBehavior(): void {\n    function handler(evt: DragEvent) {\n        evt.preventDefault();\n    }\n    document.ondragenter = handler;\n    document.ondragover = handler;\n    document.ondrop = handler;\n}\n\n// work around WebEngine/IME bug in Qt6\n// https://github.com/ankitects/anki/issues/1952\nconst dummyButton = document.createElement(\"button\");\ndummyButton.style.position = \"absolute\";\ndummyButton.style.opacity = \"0\";\ndocument.addEventListener(\"focusout\", (event) => {\n    // Prevent type box from losing focus when switching IMEs\n    if (!document.hasFocus()) {\n        return;\n    }\n\n    const target = event.target;\n    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {\n        dummyButton.style.left = `${window.scrollX}px`;\n        dummyButton.style.top = `${window.scrollY}px`;\n        document.body.appendChild(dummyButton);\n        dummyButton.focus();\n        document.body.removeChild(dummyButton);\n    }\n});\n\naddBrowserClasses();\n\nregisterPackage(\"anki/reviewer\", {\n    // If you append a function to this each time the question or answer\n    // is shown, it will be called before MathJax has been rendered.\n    onUpdateHook,\n    // If you append a function to this each time the question or answer\n    // is shown, it will be called after images have been preloaded and\n    // MathJax has been rendered.\n    onShownHook,\n});\n"
  },
  {
    "path": "ts/reviewer/index_wrapper.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// extend the global namespace with our exports - not sure if there's a better way with esbuild\nimport * as globals from \"./index\";\n\nfor (const key in globals) {\n    window[key] = globals[key];\n}\n"
  },
  {
    "path": "ts/reviewer/lib.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { SchedulingStatesWithContext } from \"@generated/anki/frontend_pb\";\nimport { SchedulingContext, SchedulingStates } from \"@generated/anki/scheduler_pb\";\nimport { expect, test } from \"vitest\";\n\nimport { applyStateTransform } from \"./answering\";\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nfunction exampleInput(): SchedulingStatesWithContext {\n    return SchedulingStatesWithContext.fromJson(\n        {\n            \"states\": {\n                \"current\": {\n                    \"normal\": {\n                        \"review\": {\n                            \"scheduledDays\": 1,\n                            \"elapsedDays\": 2,\n                            \"easeFactor\": 1.850000023841858,\n                            \"lapses\": 4,\n                            \"leeched\": false,\n                        },\n                    },\n                    \"customData\": \"{\\\"v\\\":\\\"v3.20.0\\\",\\\"seed\\\":2104,\\\"d\\\":5.39,\\\"s\\\":11.06}\",\n                },\n                \"again\": {\n                    \"normal\": {\n                        \"relearning\": {\n                            \"review\": {\n                                \"scheduledDays\": 1,\n                                \"elapsedDays\": 0,\n                                \"easeFactor\": 1.649999976158142,\n                                \"lapses\": 5,\n                                \"leeched\": false,\n                            },\n                            \"learning\": {\n                                \"remainingSteps\": 1,\n                                \"scheduledSecs\": 600,\n                            },\n                        },\n                    },\n                },\n                \"hard\": {\n                    \"normal\": {\n                        \"review\": {\n                            \"scheduledDays\": 2,\n                            \"elapsedDays\": 0,\n                            \"easeFactor\": 1.7000000476837158,\n                            \"lapses\": 4,\n                            \"leeched\": false,\n                        },\n                    },\n                },\n                \"good\": {\n                    \"normal\": {\n                        \"review\": {\n                            \"scheduledDays\": 4,\n                            \"elapsedDays\": 0,\n                            \"easeFactor\": 1.850000023841858,\n                            \"lapses\": 4,\n                            \"leeched\": false,\n                        },\n                    },\n                },\n                \"easy\": {\n                    \"normal\": {\n                        \"review\": {\n                            \"scheduledDays\": 6,\n                            \"elapsedDays\": 0,\n                            \"easeFactor\": 2,\n                            \"lapses\": 4,\n                            \"leeched\": false,\n                        },\n                    },\n                },\n            },\n            \"context\": { \"deckName\": \"hello\", \"seed\": 123 },\n        },\n    );\n}\n\ntest(\"can change oneof\", () => {\n    let states = exampleInput().states!;\n    const jsonStates = states.toJson({ \"emitDefaultValues\": true });\n    // again should be a relearning state\n    const inner = states.again?.kind?.value?.kind;\n    assert(inner?.case === \"relearning\");\n    expect(inner.value.learning?.remainingSteps).toBe(1);\n    // change it to a review state\n    jsonStates.again.normal = { \"review\": jsonStates.again.normal.relearning.review };\n    states = SchedulingStates.fromJson(jsonStates);\n    const inner2 = states.again?.kind?.value?.kind;\n    assert(inner2?.case === \"review\");\n    // however, it's not valid to have multiple oneofs set\n    jsonStates.again.normal = { \"review\": jsonStates.again.normal.review, \"learning\": {} };\n    expect(() => {\n        SchedulingStates.fromJson(jsonStates);\n    }).toThrow();\n});\n\ntest(\"no-op transform\", async () => {\n    const input = exampleInput();\n    const output = await applyStateTransform(input, async (states: any, customData, ctx) => {\n        expect(ctx.deckName).toBe(\"hello\");\n        expect(customData.easy.seed).toBe(2104);\n        expect(states!.again!.normal!.relearning!.learning!.remainingSteps).toBe(1);\n    });\n    // the input only has customData set on `current`, so we need to update it\n    // before we compare the two as equal\n    input.states!.again!.customData = input.states!.current!.customData;\n    input.states!.hard!.customData = input.states!.current!.customData;\n    input.states!.good!.customData = input.states!.current!.customData;\n    input.states!.easy!.customData = input.states!.current!.customData;\n    expect(output).toStrictEqual(input.states);\n});\n\ntest(\"custom data change\", async () => {\n    const output = await applyStateTransform(exampleInput(), async (_states: any, customData, _ctx) => {\n        customData.easy = { foo: \"hello world\" };\n    });\n    expect(output!.hard!.customData).not.toMatch(/hello world/);\n    expect(output!.easy!.customData).toBe(\"{\\\"foo\\\":\\\"hello world\\\"}\");\n});\n\ntest(\"adjust interval\", async () => {\n    const output = await applyStateTransform(exampleInput(), async (states: any, _customData, _ctx) => {\n        states.good.normal.review.scheduledDays = 10;\n    });\n    const kind = output.good?.kind?.value?.kind;\n    assert(kind?.case === \"review\");\n    expect(kind.value.scheduledDays).toBe(10);\n});\n\ntest(\"default context values exist\", async () => {\n    const ctx = SchedulingContext.fromBinary(new Uint8Array());\n    expect(ctx.deckName).toBe(\"\");\n    expect(ctx.seed).toBe(0n);\n});\n\nfunction assert(condition: boolean): asserts condition {\n    if (!condition) {\n        throw new Error();\n    }\n}\n"
  },
  {
    "path": "ts/reviewer/preload.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { preloadImages } from \"./images\";\n\nconst template = document.createElement(\"template\");\nconst htmlDoc = document.implementation.createHTMLDocument();\nconst fontURLPattern = /url\\s*\\(\\s*(?<quote>[\"']?)(?<url>\\S.*?)\\k<quote>\\s*\\)/g;\nconst cachedFonts = new Set<string>();\n\ntype CSSElement = HTMLStyleElement | HTMLLinkElement;\n\nfunction loadResource(element: HTMLElement): Promise<void> {\n    return new Promise((resolve) => {\n        function resolveAndRemove(): void {\n            resolve();\n            document.head.removeChild(element);\n        }\n        element.addEventListener(\"load\", resolveAndRemove);\n        element.addEventListener(\"error\", resolveAndRemove);\n        document.head.appendChild(element);\n    });\n}\n\nfunction createPreloadLink(href: string, as: string): HTMLLinkElement {\n    const link = document.createElement(\"link\");\n    link.rel = \"preload\";\n    link.href = href;\n    link.as = as;\n    if (as === \"font\") {\n        link.crossOrigin = \"\";\n    }\n    return link;\n}\n\nfunction extractExternalStyleSheets(fragment: DocumentFragment): CSSElement[] {\n    return [...fragment.querySelectorAll<CSSElement>(\"style, link\")]\n        .filter((css) =>\n            (css instanceof HTMLStyleElement && css.innerHTML.includes(\"@import\"))\n            || (css instanceof HTMLLinkElement && css.rel === \"stylesheet\")\n        );\n}\n\n/** Prevent FOUC */\nfunction preloadStyleSheets(fragment: DocumentFragment): Promise<void>[] {\n    const promises = extractExternalStyleSheets(fragment).map((css) => {\n        // prevent the CSS from affecting the page rendering\n        css.media = \"print\";\n\n        return loadResource(css);\n    });\n    return promises;\n}\n\nfunction extractFontFaceRules(style: HTMLStyleElement): CSSFontFaceRule[] {\n    htmlDoc.head.innerHTML = \"\";\n    // must be attached to an HTMLDocument to access 'sheet' property\n    htmlDoc.head.appendChild(style);\n\n    const fontFaceRules: CSSFontFaceRule[] = [];\n    if (style.sheet) {\n        for (const rule of style.sheet.cssRules) {\n            if (rule instanceof CSSFontFaceRule) {\n                fontFaceRules.push(rule);\n            }\n        }\n    }\n    return fontFaceRules;\n}\n\nfunction extractFontURLs(rule: CSSFontFaceRule): string[] {\n    const src = rule.style.getPropertyValue(\"src\");\n    const matches = src.matchAll(fontURLPattern);\n    return [...matches].map((m) => (m.groups?.url ? m.groups.url : \"\")).filter(Boolean);\n}\n\nfunction preloadFonts(fragment: DocumentFragment): Promise<void>[] {\n    const styles = fragment.querySelectorAll(\"style\");\n    const fonts: string[] = [];\n    for (const style of styles) {\n        for (const rule of extractFontFaceRules(style)) {\n            fonts.push(...extractFontURLs(rule));\n        }\n    }\n    const newFonts = fonts.filter((font) => !cachedFonts.has(font));\n    newFonts.forEach((font) => cachedFonts.add(font));\n    const promises = newFonts.map((font) => {\n        const link = createPreloadLink(font, \"font\");\n        return loadResource(link);\n    });\n    return promises;\n}\n\nexport async function preloadResources(html: string): Promise<void> {\n    template.innerHTML = html;\n    const fragment = template.content;\n    const styleSheets = preloadStyleSheets(fragment.cloneNode(true) as DocumentFragment);\n    const images = preloadImages(fragment.cloneNode(true) as DocumentFragment);\n    const fonts = preloadFonts(fragment.cloneNode(true) as DocumentFragment);\n\n    let timeout: number;\n    if (fonts.length) {\n        timeout = 800;\n    } else if (styleSheets.length) {\n        timeout = 500;\n    } else if (images.length) {\n        timeout = 200;\n    } else {\n        return;\n    }\n\n    await Promise.race([\n        Promise.all([...styleSheets, ...images, ...fonts]),\n        new Promise((r) => setTimeout(r, timeout)),\n    ]);\n}\n"
  },
  {
    "path": "ts/reviewer/reviewer.scss",
    "content": "/* Copyright: Ankitects Pty Ltd and contributors\n * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */\n\n@use \"../lib/sass/vars\";\n@use \"../routes/image-occlusion/review\";\n\nhr {\n    background-color: vars.palette(darkgray, 0);\n    margin: 1em 0;\n    border: none;\n    height: 1px;\n}\n\nbody {\n    margin: 20px;\n    overflow-wrap: break-word;\n    // default background setting to fit with toolbar\n    background-size: cover;\n    background-repeat: no-repeat;\n    background-position: top;\n    background-attachment: fixed;\n}\n\n// explicit nightMode definition required\n// to override default .card styling\nbody.nightMode {\n    background-color: var(--canvas);\n    color: var(--fg);\n}\n\nimg {\n    max-width: 100%;\n    max-height: 95vh;\n}\n\nli {\n    text-align: start;\n}\n\npre {\n    text-align: left;\n}\n\n#_flag {\n    position: fixed;\n    [dir=\"ltr\"] & {\n        right: 10px;\n    }\n    [dir=\"rtl\"] & {\n        left: 10px;\n    }\n    top: 0;\n    font-size: 30px;\n    -webkit-text-stroke-width: 1px;\n    -webkit-text-stroke-color: black;\n}\n\n#_mark {\n    position: fixed;\n    [dir=\"ltr\"] & {\n        left: 10px;\n    }\n    [dir=\"rtl\"] & {\n        right: 10px;\n    }\n    top: 0;\n    font-size: 30px;\n    color: yellow;\n    -webkit-text-stroke-width: 1px;\n    -webkit-text-stroke-color: black;\n}\n\n#typeans {\n    width: 100%;\n    // https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething\n    box-sizing: border-box;\n    line-height: 1.75;\n}\n\ncode#typeans {\n    white-space: pre-wrap;\n    font-variant-ligatures: none;\n}\n\n.typeGood {\n    background: #afa;\n    color: black;\n}\n\n.typeBad {\n    color: black;\n    background: #faa;\n}\n\n.typeMissed {\n    color: black;\n    background: #ccc;\n}\n\nbutton {\n    margin: 1em 0.5em;\n}\n\n.replay-button {\n    text-decoration: none;\n    display: inline-flex;\n    vertical-align: middle;\n    margin: 3px;\n\n    svg {\n        width: 40px;\n        height: 40px;\n\n        circle {\n            fill: #fff;\n            stroke: #414141;\n        }\n\n        path {\n            fill: #414141;\n        }\n    }\n}\n\n.nightMode {\n    .latex {\n        filter: invert(100%);\n    }\n}\n\n.drawing {\n    zoom: 50%;\n}\n.nightMode img.drawing {\n    filter: unquote(\"invert(1) hue-rotate(180deg)\");\n}\n"
  },
  {
    "path": "ts/reviewer/reviewer_extras.scss",
    "content": "@use \"ts/routes/image-occlusion/review\";\n"
  },
  {
    "path": "ts/reviewer/reviewer_extras.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\n// A standalone bundle that adds mutateNextCardStates and the image occlusion API\n// to the anki namespace. When all clients are using reviewer.js directly, we\n// can get rid of this.\n\nimport { imageOcclusionAPI } from \"$lib/../routes/image-occlusion/review\";\n\nimport { mutateNextCardStates } from \"./answering\";\nimport { addBrowserClasses } from \"./browser_selector\";\n\nglobalThis.anki = globalThis.anki || {};\nglobalThis.anki.mutateNextCardStates = mutateNextCardStates;\nglobalThis.anki.imageOcclusion = imageOcclusionAPI;\nglobalThis.anki.setupImageCloze = imageOcclusionAPI.setup; // deprecated\nglobalThis.anki.addBrowserClasses = addBrowserClasses;\n"
  },
  {
    "path": "ts/routes/+error.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { page } from \"$app/state\";\n\n    $: message = page.error!.message;\n</script>\n\n{message}\n"
  },
  {
    "path": "ts/routes/+layout.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import \"./base.scss\";\n\n    import { setContext } from \"svelte\";\n\n    import { modalsKey, touchDeviceKey } from \"$lib/components/context-keys\";\n\n    setContext(modalsKey, new Map());\n    setContext(touchDeviceKey, \"ontouchstart\" in document.documentElement);\n</script>\n\n<slot />\n"
  },
  {
    "path": "ts/routes/+layout.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { setupGlobalI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport type { LayoutLoad } from \"./$types\";\n\nexport const ssr = false;\nexport const prerender = false;\n\nexport const load: LayoutLoad = async () => {\n    checkNightMode();\n    await setupGlobalI18n();\n};\n"
  },
  {
    "path": "ts/routes/base.scss",
    "content": "@import \"$lib/sass/base\";\n\n// override Bootstrap transition duration\n$carousel-transition: var(--transition);\n\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/carousel\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/badge\";\n@import \"$lib/sass/bootstrap-forms\";\n@import \"$lib/sass/bootstrap-tooltip\";\n\ninput[type=\"text\"],\ninput[type=\"date\"],\ntextarea {\n    padding-inline: 0.5rem;\n    background: var(--canvas-inset);\n}\n\ninput {\n    color: var(--fg);\n}\n\n// Setting 100% height causes the sticky element to hide as you scroll down on Safari.\nhtml {\n    height: initial;\n}\n\n[dir=\"rtl\"] .modal-header .btn-close {\n    padding: 1rem 1rem !important;\n    margin: -1rem auto -1rem -1rem !important;\n}\n"
  },
  {
    "path": "ts/routes/card-info/CardInfo.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { CardStatsResponse } from \"@generated/anki/stats_pb\";\n\n    import Container from \"$lib/components/Container.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n\n    import CardInfoPlaceholder from \"./CardInfoPlaceholder.svelte\";\n    import CardStats from \"./CardStats.svelte\";\n    import Revlog from \"./Revlog.svelte\";\n    import ForgettingCurve from \"./ForgettingCurve.svelte\";\n\n    export let stats: CardStatsResponse | null = null;\n    export let showRevlog: boolean = true;\n    export let showCurve: boolean = true;\n\n    $: fsrsEnabled = stats?.memoryState != null;\n    $: desiredRetention = stats?.desiredRetention ?? 0.9;\n    $: decay = (() => {\n        const paramsLength = stats?.fsrsParams?.length ?? 0;\n        if (paramsLength === 0) {\n            return 0.1542; // default decay for FSRS-6\n        }\n        if (paramsLength < 21) {\n            return 0.5; // default decay for FSRS-4.5 and FSRS-5\n        }\n        return stats?.fsrsParams?.[20] ?? 0.1542;\n    })();\n</script>\n\n<Container breakpoint=\"md\" --gutter-inline=\"1rem\" --gutter-block=\"0.5rem\">\n    {#if stats}\n        <Row>\n            <CardStats {stats} />\n        </Row>\n\n        {#if showRevlog}\n            <Row>\n                <Revlog revlog={stats.revlog} {fsrsEnabled} />\n            </Row>\n        {/if}\n        {#if fsrsEnabled && showCurve}\n            <Row>\n                <ForgettingCurve revlog={stats.revlog} {desiredRetention} {decay} />\n            </Row>\n        {/if}\n    {:else}\n        <CardInfoPlaceholder />\n    {/if}\n</Container>\n"
  },
  {
    "path": "ts/routes/card-info/CardInfoPlaceholder.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n</script>\n\n<div class=\"card-info-placeholder\">{tr.cardStatsNoCard()}</div>\n\n<style lang=\"scss\">\n    .card-info-placeholder {\n        margin: 0;\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        transform: translate(-50%, -50%);\n        color: grey;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/card-info/CardStats.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { CardStatsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr2 from \"@generated/ftl\";\n    import { DAY, timeSpan, TimespanUnit, Timestamp } from \"@tslib/time\";\n\n    export let stats: CardStatsResponse;\n\n    function dateString(timestamp: bigint): string {\n        return new Timestamp(Number(timestamp)).dateString();\n    }\n\n    interface StatsRow {\n        label: string;\n        value: string | number | bigint;\n    }\n\n    function rowsFromStats(stats: CardStatsResponse): StatsRow[] {\n        const statsRows: StatsRow[] = [];\n\n        statsRows.push({ label: tr2.cardStatsAdded(), value: dateString(stats.added) });\n\n        if (stats.firstReview != null) {\n            statsRows.push({\n                label: tr2.cardStatsFirstReview(),\n                value: dateString(stats.firstReview),\n            });\n        }\n        if (stats.latestReview != null) {\n            statsRows.push({\n                label: tr2.cardStatsLatestReview(),\n                value: dateString(stats.latestReview),\n            });\n        }\n\n        if (stats.dueDate != null) {\n            statsRows.push({\n                label: tr2.statisticsDueDate(),\n                value: dateString(stats.dueDate),\n            });\n        }\n        if (stats.duePosition != null) {\n            statsRows.push({\n                label: tr2.cardStatsNewCardPosition(),\n                value: stats.duePosition,\n            });\n        }\n\n        if (stats.interval) {\n            statsRows.push({\n                label: tr2.cardStatsInterval(),\n                value: timeSpan(stats.interval * DAY),\n            });\n        }\n        if (stats.memoryState) {\n            let stability = timeSpan(stats.memoryState.stability * 86400, false, false);\n            if (stats.memoryState.stability > 31) {\n                const nativeStability = timeSpan(\n                    stats.memoryState.stability * 86400,\n                    false,\n                    false,\n                    TimespanUnit.Days,\n                );\n                stability += ` (${nativeStability})`;\n            }\n            statsRows.push({\n                label: tr2.cardStatsFsrsStability(),\n                value: stability,\n            });\n            const difficulty = (\n                ((stats.memoryState.difficulty - 1.0) / 9.0) *\n                100.0\n            ).toFixed(0);\n            statsRows.push({\n                label: tr2.cardStatsFsrsDifficulty(),\n                value: `${difficulty}%`,\n            });\n            if (stats.fsrsRetrievability) {\n                const retrievability = (stats.fsrsRetrievability * 100).toFixed(0);\n                statsRows.push({\n                    label: tr2.cardStatsFsrsRetrievability(),\n                    value: `${retrievability}%`,\n                });\n            }\n        } else {\n            if (stats.ease) {\n                statsRows.push({\n                    label: tr2.cardStatsEase(),\n                    value: `${stats.ease / 10}%`,\n                });\n            }\n        }\n\n        statsRows.push({ label: tr2.cardStatsReviewCount(), value: stats.reviews });\n        statsRows.push({ label: tr2.cardStatsLapseCount(), value: stats.lapses });\n\n        if (stats.totalSecs) {\n            statsRows.push({\n                label: tr2.cardStatsAverageTime(),\n                value: timeSpan(stats.averageSecs),\n            });\n            statsRows.push({\n                label: tr2.cardStatsTotalTime(),\n                value: timeSpan(stats.totalSecs),\n            });\n        }\n\n        statsRows.push({ label: tr2.cardStatsCardTemplate(), value: stats.cardType });\n        statsRows.push({ label: tr2.cardStatsNoteType(), value: stats.notetype });\n        let deck: string;\n        if (stats.originalDeck) {\n            deck = `${stats.deck} (${stats.originalDeck})`;\n        } else {\n            deck = stats.deck;\n        }\n        statsRows.push({ label: tr2.cardStatsDeckName(), value: deck });\n        statsRows.push({ label: tr2.cardStatsPreset(), value: stats.preset });\n\n        statsRows.push({ label: tr2.cardStatsCardId(), value: stats.cardId });\n        statsRows.push({ label: tr2.cardStatsNoteId(), value: stats.noteId });\n\n        if (stats.customData) {\n            let value: string;\n            try {\n                const obj = JSON.parse(stats.customData);\n                value = Object.entries(obj)\n                    .map(([k, v]) => `${k}=${v}`)\n                    .join(\" \");\n            } catch (exc) {\n                value = stats.customData;\n            }\n            statsRows.push({\n                label: tr2.cardStatsCustomData(),\n                value: value,\n            });\n        }\n\n        return statsRows;\n    }\n\n    let statsRows: StatsRow[];\n    $: statsRows = rowsFromStats(stats);\n</script>\n\n<table class=\"stats-table align-start\">\n    <tbody>\n        {#each statsRows as row}\n            <tr>\n                <th class=\"align-start\">{row.label}</th>\n                <td>{row.value}</td>\n            </tr>\n        {/each}\n    </tbody>\n</table>\n\n<style>\n    .stats-table {\n        width: 100%;\n        border-spacing: 1em 0;\n        border-collapse: collapse;\n    }\n\n    .align-start {\n        text-align: start;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/card-info/ForgettingCurve.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { type CardStatsResponse_StatsRevlogEntry as RevlogEntry } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import Graph from \"../graphs/Graph.svelte\";\n    import NoDataOverlay from \"../graphs/NoDataOverlay.svelte\";\n    import AxisTicks from \"../graphs/AxisTicks.svelte\";\n    import { writable, type Writable } from \"svelte/store\";\n    import InputBox from \"../graphs/InputBox.svelte\";\n    import {\n        renderForgettingCurve,\n        TimeRange,\n        calculateMaxDays,\n        filterRevlog,\n    } from \"./forgetting-curve\";\n    import { defaultGraphBounds } from \"../graphs/graph-helpers\";\n    import HoverColumns from \"../graphs/HoverColumns.svelte\";\n\n    export let revlog: RevlogEntry[];\n    export let desiredRetention: number;\n    export let decay: number;\n    let svg: HTMLElement | SVGElement | null = null;\n    const bounds = defaultGraphBounds();\n    const title = tr.cardStatsFsrsForgettingCurveTitle();\n\n    $: filteredRevlog = filterRevlog(revlog);\n    $: maxDays = calculateMaxDays(filteredRevlog, TimeRange.AllTime);\n\n    let defaultTimeRange = TimeRange.Week;\n    const timeRange: Writable<TimeRange> = writable(defaultTimeRange);\n\n    $: if (maxDays > 365) {\n        defaultTimeRange = TimeRange.AllTime;\n    } else if (maxDays > 30) {\n        defaultTimeRange = TimeRange.Year;\n    } else if (maxDays > 7) {\n        defaultTimeRange = TimeRange.Month;\n    }\n\n    $: $timeRange = defaultTimeRange;\n\n    $: renderForgettingCurve(\n        filteredRevlog,\n        $timeRange,\n        svg as SVGElement,\n        bounds,\n        desiredRetention,\n        decay,\n    );\n</script>\n\n<div class=\"forgetting-curve\">\n    {#if maxDays > 7}\n        <InputBox>\n            <div class=\"time-range-selector\">\n                <label>\n                    <input\n                        type=\"radio\"\n                        bind:group={$timeRange}\n                        value={TimeRange.Week}\n                    />\n                    {tr.cardStatsFsrsForgettingCurveFirstWeek()}\n                </label>\n                <label>\n                    <input\n                        type=\"radio\"\n                        bind:group={$timeRange}\n                        value={TimeRange.Month}\n                    />\n                    {tr.cardStatsFsrsForgettingCurveFirstMonth()}\n                </label>\n                {#if maxDays > 30}\n                    <label>\n                        <input\n                            type=\"radio\"\n                            bind:group={$timeRange}\n                            value={TimeRange.Year}\n                        />\n                        {tr.cardStatsFsrsForgettingCurveFirstYear()}\n                    </label>\n                {/if}\n                {#if maxDays > 365}\n                    <label>\n                        <input\n                            type=\"radio\"\n                            bind:group={$timeRange}\n                            value={TimeRange.AllTime}\n                        />\n                        {tr.cardStatsFsrsForgettingCurveAllTime()}\n                    </label>\n                {/if}\n            </div>\n        </InputBox>\n    {/if}\n    <Graph {title}>\n        <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n            <AxisTicks {bounds} />\n            <HoverColumns />\n            <NoDataOverlay {bounds} />\n        </svg>\n    </Graph>\n</div>\n\n<style>\n    .forgetting-curve {\n        width: 100%;\n        max-width: 50em;\n        margin-bottom: 10em;\n    }\n\n    .time-range-selector {\n        display: flex;\n        justify-content: space-around;\n        margin-bottom: 1em;\n        width: 100%;\n        max-width: 50em;\n    }\n\n    .time-range-selector label {\n        display: flex;\n        align-items: center;\n    }\n\n    .time-range-selector input {\n        margin-right: 0.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/card-info/Revlog.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { CardStatsResponse_StatsRevlogEntry as RevlogEntry } from \"@generated/anki/stats_pb\";\n    import { RevlogEntry_ReviewKind as ReviewKind } from \"@generated/anki/stats_pb\";\n    import * as tr2 from \"@generated/ftl\";\n    import { timeSpan, Timestamp } from \"@tslib/time\";\n    import { filterRevlogEntryByReviewKind } from \"./forgetting-curve\";\n\n    export let revlog: RevlogEntry[];\n    export const fsrsEnabled: boolean = false;\n\n    function reviewKindClass(entry: RevlogEntry): string {\n        switch (entry.reviewKind) {\n            case ReviewKind.LEARNING:\n                return \"revlog-learn\";\n            case ReviewKind.REVIEW:\n                return \"revlog-review\";\n            case ReviewKind.RELEARNING:\n                return \"revlog-relearn\";\n        }\n        return \"\";\n    }\n\n    function reviewKindLabel(entry: RevlogEntry): string {\n        switch (entry.reviewKind) {\n            case ReviewKind.LEARNING:\n                return tr2.cardStatsReviewLogTypeLearn();\n            case ReviewKind.REVIEW:\n                return tr2.cardStatsReviewLogTypeReview();\n            case ReviewKind.RELEARNING:\n                return tr2.cardStatsReviewLogTypeRelearn();\n            case ReviewKind.FILTERED:\n                return tr2.cardStatsReviewLogTypeFiltered();\n            case ReviewKind.MANUAL:\n                return tr2.cardStatsReviewLogTypeManual();\n            case ReviewKind.RESCHEDULED:\n                return tr2.cardStatsReviewLogTypeRescheduled();\n        }\n    }\n\n    function ratingClass(entry: RevlogEntry): string {\n        if (entry.buttonChosen === 1) {\n            return \"revlog-ease1\";\n        }\n        return \"\";\n    }\n\n    interface RevlogRow {\n        date: string;\n        time: string;\n        reviewKind: string;\n        reviewKindClass: string;\n        rating: number;\n        ratingClass: string;\n        interval: string;\n        ease: string;\n        takenSecs: string;\n        elapsedTime: string;\n        stability: string;\n    }\n\n    function revlogRowFromEntry(entry: RevlogEntry, elapsedTime: string): RevlogRow {\n        const timestamp = new Timestamp(Number(entry.time));\n\n        return {\n            date: timestamp.dateString(),\n            time: timestamp.timeString(),\n            reviewKind: reviewKindLabel(entry),\n            reviewKindClass: reviewKindClass(entry),\n            rating: entry.buttonChosen,\n            ratingClass: ratingClass(entry),\n            interval: timeSpan(entry.interval),\n            ease: formatEaseOrDifficulty(entry.ease),\n            takenSecs: timeSpan(entry.takenSecs, true),\n            elapsedTime,\n            stability: entry.memoryState?.stability\n                ? timeSpan(entry.memoryState.stability * 86400)\n                : \"\",\n        };\n    }\n\n    $: revlogRows = revlog.map((entry, index) => {\n        let prevValidEntry: RevlogEntry | undefined;\n        let i = index + 1;\n        while (i < revlog.length) {\n            if (filterRevlogEntryByReviewKind(revlog[i])) {\n                prevValidEntry = revlog[i];\n                break;\n            }\n            i++;\n        }\n\n        let elapsedTime = \"N/A\";\n        if (filterRevlogEntryByReviewKind(entry)) {\n            elapsedTime = prevValidEntry\n                ? timeSpan(Number(entry.time) - Number(prevValidEntry.time))\n                : \"0\";\n        }\n\n        return revlogRowFromEntry(entry, elapsedTime);\n    });\n\n    function formatEaseOrDifficulty(ease: number): string {\n        if (ease === 0) {\n            return \"\";\n        }\n        const asPct = ease / 10.0;\n        if (asPct <= 110) {\n            // FSRS\n            const unshifted = asPct - 10;\n            return `D:${unshifted.toFixed(0)}%`;\n        } else {\n            // SM-2\n            return `${asPct.toFixed(0)}%`;\n        }\n    }\n</script>\n\n{#if revlog.length > 0}\n    <div class=\"revlog-table\">\n        <div class=\"column\">\n            <div class=\"column-head\">{tr2.cardStatsReviewLogDate()}</div>\n            <div class=\"column-content\">\n                {#each revlogRows as row, _index}\n                    <div>\n                        <b>{row.date}</b>\n                        <span class=\"hidden-xs\">@ {row.time}</span>\n                    </div>\n                {/each}\n            </div>\n        </div>\n        <div class=\"column hidden-xs\">\n            <div class=\"column-head\">{tr2.cardStatsReviewLogType()}</div>\n            <div class=\"column-content\">\n                {#each revlogRows as row, _index}\n                    <div class={row.reviewKindClass}>\n                        {row.reviewKind}\n                    </div>\n                {/each}\n            </div>\n        </div>\n        <div class=\"column\">\n            <div class=\"column-head\">{tr2.cardStatsReviewLogRating()}</div>\n            <div class=\"column-content\">\n                {#each revlogRows as row, _index}\n                    <div class={row.ratingClass}>{row.rating}</div>\n                {/each}\n            </div>\n        </div>\n        <div class=\"column\">\n            <div class=\"column-head\">{tr2.cardStatsInterval()}</div>\n            <div class=\"column-content right\">\n                {#each revlogRows as row, _index}\n                    <div>{row.interval}</div>\n                {/each}\n            </div>\n        </div>\n        <div class=\"column hidden-xs\">\n            <div class=\"column-head\">{tr2.cardStatsEase()}</div>\n            <div class=\"column-content\">\n                {#each revlogRows as row, _index}\n                    <div>{row.ease}</div>\n                {/each}\n            </div>\n        </div>\n        <div class=\"column\">\n            <div class=\"column-head\">{tr2.cardStatsReviewLogTimeTaken()}</div>\n            <div class=\"column-content right\">\n                {#each revlogRows as row, _index}\n                    <div>{row.takenSecs}</div>\n                {/each}\n            </div>\n        </div>\n        <!-- {#if fsrsEnabled}\n            <div class=\"column\">\n                <div class=\"column-head\">{tr2.cardStatsFsrsStability()}</div>\n                <div class=\"column-content right\">\n                    {#each revlogRows as row, _index}\n                        <div>{row.stability}</div>\n                    {/each}\n                </div>\n            </div>\n        {/if} -->\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    .revlog-table {\n        width: 100%;\n        max-width: 50em;\n        display: flex;\n        flex-direction: row;\n        justify-content: space-between;\n        align-items: center;\n        white-space: nowrap;\n    }\n\n    .column {\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n\n        &:not(:last-child) {\n            margin-right: 0.5em;\n        }\n    }\n\n    .column-head {\n        font-weight: bold;\n    }\n\n    .column-content {\n        display: inline-flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n\n        &.right {\n            align-items: flex-end;\n        }\n\n        > div:empty::after {\n            /* prevent collapsing of empty rows */\n            content: \"\\00a0\";\n        }\n    }\n\n    .revlog-learn {\n        color: var(--state-new);\n    }\n\n    .revlog-review {\n        color: var(--state-review);\n    }\n\n    .revlog-relearn,\n    .revlog-ease1 {\n        color: var(--state-learn);\n    }\n\n    @media only screen and (max-device-width: 480px) and (orientation: portrait) {\n        .hidden-xs {\n            display: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/card-info/[cardId]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { page } from \"$app/state\";\n\n    import CardInfo from \"../CardInfo.svelte\";\n    import type { PageData } from \"./$types\";\n    import { goto } from \"$app/navigation\";\n\n    export let data: PageData;\n\n    const showRevlog = page.url.searchParams.get(\"revlog\") !== \"0\";\n\n    globalThis.anki ||= {};\n    globalThis.anki.updateCard = async (card_id: string): Promise<void> => {\n        const path = `/card-info/${card_id}`;\n        return goto(path).catch(() => {\n            window.location.href = path;\n        });\n    };\n</script>\n\n<CardInfo stats={data.info} {showRevlog} />\n"
  },
  {
    "path": "ts/routes/card-info/[cardId]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { cardStats } from \"@generated/backend\";\n\nimport type { PageLoad } from \"./$types\";\n\nfunction optionalBigInt(x: any): bigint | null {\n    try {\n        return BigInt(x);\n    } catch (e) {\n        return null;\n    }\n}\n\nexport const load = (async ({ params }) => {\n    const cid = optionalBigInt(params.cardId);\n    const info = cid !== null ? await cardStats({ cid }) : null;\n    return { info };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/card-info/[cardId]/[previousId]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { page } from \"$app/state\";\n\n    import CardInfo from \"../../CardInfo.svelte\";\n    import type { PageData } from \"./$types\";\n    import { goto } from \"$app/navigation\";\n\n    export let data: PageData;\n\n    const showRevlog = page.url.searchParams.get(\"revlog\") !== \"0\";\n    const showCurve = page.url.searchParams.get(\"curve\") !== \"0\";\n\n    globalThis.anki ||= {};\n    globalThis.anki.updateCardInfos = async (card_id: string): Promise<void> => {\n        const path = `/card-info/${card_id}`;\n        return goto(path).catch(() => {\n            window.location.href = path;\n        });\n    };\n</script>\n\n<center>\n    {#if data.currentInfo}\n        <h3>Current</h3>\n        <CardInfo stats={data.currentInfo} {showRevlog} {showCurve} />\n    {/if}\n    {#if data.previousInfo}\n        <h3>Previous</h3>\n        <CardInfo stats={data.previousInfo} {showRevlog} {showCurve} />\n    {/if}\n</center>\n\n<style>\n    :global(body) {\n        font-size: 80%;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/card-info/[cardId]/[previousId]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { cardStats } from \"@generated/backend\";\n\nimport type { PageLoad } from \"./$types\";\n\nfunction optionalBigInt(x: any): bigint | null {\n    try {\n        return BigInt(x);\n    } catch (e) {\n        return null;\n    }\n}\n\nexport const load = (async ({ params }) => {\n    const currentId = optionalBigInt(params.cardId);\n    const currentInfo = currentId !== null ? await cardStats({ cid: currentId }) : null;\n    const previousId = optionalBigInt(params.previousId);\n    const previousInfo = previousId !== null ? await cardStats({ cid: previousId }) : null;\n    return { currentInfo, previousInfo };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/card-info/forgetting-curve.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport {\n    type CardStatsResponse_StatsRevlogEntry as RevlogEntry,\n    RevlogEntry_ReviewKind,\n} from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { timeSpan } from \"@tslib/time\";\nimport { axisBottom, axisLeft, line, max, min, pointer, scaleLinear, scaleTime, select } from \"d3\";\nimport { type GraphBounds, setDataAvailable } from \"../graphs/graph-helpers\";\nimport { hideTooltip, showTooltip } from \"../graphs/tooltip-utils.svelte\";\n\nconst MIN_POINTS = 1000;\n\nfunction forgettingCurve(stability: number, daysElapsed: number, decay: number): number {\n    const factor = Math.pow(0.9, 1 / -decay) - 1;\n    return Math.pow((daysElapsed / stability) * factor + 1.0, -decay);\n}\n\ninterface DataPoint {\n    date: Date;\n    daysSinceFirstLearn: number;\n    elapsedDaysSinceLastReview: number;\n    retrievability: number;\n    stability: number;\n}\n\nexport enum TimeRange {\n    Week,\n    Month,\n    Year,\n    AllTime,\n}\n\nconst MAX_DAYS = {\n    [TimeRange.Week]: 7,\n    [TimeRange.Month]: 30,\n    [TimeRange.Year]: 365,\n    [TimeRange.AllTime]: Infinity,\n};\n\nfunction filterDataByTimeRange(data: DataPoint[], maxDays: number): DataPoint[] {\n    return data.filter((point) => point.daysSinceFirstLearn <= maxDays);\n}\n\nexport function filterRevlogEntryByReviewKind(entry: RevlogEntry): boolean {\n    return (\n        entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL\n        && entry.reviewKind !== RevlogEntry_ReviewKind.RESCHEDULED\n        && (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)\n    );\n}\n\nexport function filterRevlog(revlog: RevlogEntry[]): RevlogEntry[] {\n    const result: RevlogEntry[] = [];\n    for (const entry of revlog) {\n        if (\n            (entry.reviewKind === RevlogEntry_ReviewKind.MANUAL && entry.ease === 0)\n            || entry.memoryState === undefined\n        ) {\n            break;\n        }\n        result.push(entry);\n    }\n\n    return result.filter((entry) => filterRevlogEntryByReviewKind(entry));\n}\n\nexport function prepareData(revlog: RevlogEntry[], maxDays: number, decay: number) {\n    const data: DataPoint[] = [];\n    let lastReviewTime = 0;\n    let lastStability = 0;\n    const step = Math.min(maxDays / MIN_POINTS, 1);\n    let daysSinceFirstLearn = 0;\n\n    revlog\n        .slice()\n        .reverse()\n        .forEach((entry, index) => {\n            const reviewTime = Number(entry.time);\n            if (index === 0) {\n                lastReviewTime = reviewTime;\n                lastStability = entry.memoryState?.stability || 0;\n                data.push({\n                    date: new Date(reviewTime * 1000),\n                    daysSinceFirstLearn: 0,\n                    elapsedDaysSinceLastReview: 0,\n                    retrievability: 100,\n                    stability: lastStability,\n                });\n                return;\n            }\n\n            const totalDaysElapsed = (reviewTime - lastReviewTime) / 86400;\n            let elapsedDays = 0;\n            while (elapsedDays < totalDaysElapsed - step) {\n                elapsedDays += step;\n                const retrievability = forgettingCurve(lastStability, elapsedDays, decay);\n                data.push({\n                    date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),\n                    daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step,\n                    elapsedDaysSinceLastReview: elapsedDays,\n                    retrievability: retrievability * 100,\n                    stability: lastStability,\n                });\n            }\n            daysSinceFirstLearn += totalDaysElapsed;\n            data.push({\n                date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),\n                daysSinceFirstLearn: daysSinceFirstLearn,\n                retrievability: 100,\n                elapsedDaysSinceLastReview: 0,\n                stability: lastStability,\n            });\n\n            lastReviewTime = reviewTime;\n            lastStability = entry.memoryState?.stability || 0;\n        });\n\n    if (data.length === 0) {\n        return [];\n    }\n\n    const now = Date.now() / 1000;\n    const totalDaysSinceLastReview = (now - lastReviewTime) / 86400;\n    let elapsedDays = 0;\n    while (elapsedDays < totalDaysSinceLastReview - step) {\n        elapsedDays += step;\n        const retrievability = forgettingCurve(lastStability, elapsedDays, decay);\n        data.push({\n            date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),\n            daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step,\n            elapsedDaysSinceLastReview: elapsedDays,\n            retrievability: retrievability * 100,\n            stability: lastStability,\n        });\n    }\n    daysSinceFirstLearn += totalDaysSinceLastReview;\n    const retrievability = forgettingCurve(lastStability, totalDaysSinceLastReview, decay);\n    data.push({\n        date: new Date(now * 1000),\n        daysSinceFirstLearn: daysSinceFirstLearn,\n        elapsedDaysSinceLastReview: totalDaysSinceLastReview,\n        retrievability: retrievability * 100,\n        stability: lastStability,\n    });\n\n    const previewDays = maxDays - totalDaysSinceLastReview;\n    let previewDaysElapsed = 0;\n    while (previewDaysElapsed < previewDays) {\n        previewDaysElapsed += step;\n        const retrievability = forgettingCurve(lastStability, elapsedDays + previewDaysElapsed, decay);\n        data.push({\n            date: new Date((now + previewDaysElapsed * 86400) * 1000),\n            daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn + step,\n            elapsedDaysSinceLastReview: totalDaysSinceLastReview + previewDaysElapsed,\n            retrievability: retrievability * 100,\n            stability: lastStability,\n        });\n    }\n\n    const filteredData = filterDataByTimeRange(data, maxDays);\n    return filteredData;\n}\n\nexport function calculateMaxDays(filteredRevlog: RevlogEntry[], timeRange: TimeRange): number {\n    if (filteredRevlog.length === 0) {\n        return 0;\n    }\n    const today = new Date();\n    const daysSinceFirstLearn = (today.getTime() / 1000 - Number(filteredRevlog[filteredRevlog.length - 1].time))\n        / 86400;\n    const totalDaysSinceLastReview = (today.getTime() / 1000 - Number(filteredRevlog[0].time))\n        / 86400;\n    const lastScheduledDays = filteredRevlog[0].interval / 86400;\n    const previewDays = Math.max(lastScheduledDays * 1.5 - totalDaysSinceLastReview, lastScheduledDays * 0.5);\n    return Math.min(daysSinceFirstLearn + previewDays, MAX_DAYS[timeRange]);\n}\n\nexport function renderForgettingCurve(\n    filteredRevlog: RevlogEntry[],\n    timeRange: TimeRange,\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    desiredRetention: number,\n    decay: number,\n) {\n    const svg = select(svgElem);\n    const trans = svg.transition().duration(600) as any;\n    if (filteredRevlog.length === 0) {\n        setDataAvailable(svg, false);\n        return;\n    }\n    const maxDays = calculateMaxDays(filteredRevlog, timeRange);\n\n    const data = prepareData(filteredRevlog, maxDays, decay);\n\n    if (data.length === 0) {\n        setDataAvailable(svg, false);\n        return;\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    svg.selectAll(\".forgetting-curve-line\").remove();\n    svg.select(\".hover-columns\").remove();\n\n    const xMin = min(data, d => d.date);\n    const xMax = max(data, d => d.date);\n    const x = scaleTime()\n        .domain([xMin!, xMax!])\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n    const yMin = Math.max(\n        0,\n        100 - 1.2 * (100 - Math.min(...data.map((d) => d.retrievability))),\n    );\n    const y = scaleLinear()\n        .domain([yMin, 100])\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop]);\n\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(5).tickSizeOuter(0)))\n        .attr(\"direction\", \"ltr\");\n\n    svg.select<SVGGElement>(\".y-ticks\")\n        .attr(\"transform\", `translate(${bounds.marginLeft},0)`)\n        .call((selection) => selection.transition(trans).call(axisLeft(y).tickSizeOuter(0)))\n        .attr(\"direction\", \"ltr\");\n\n    svg.select(\".y-ticks .y-axis-title\").remove();\n    svg.select(\".y-ticks\")\n        .append(\"text\")\n        .attr(\"class\", \"y-axis-title\")\n        .attr(\"transform\", \"rotate(-90)\")\n        .attr(\"y\", 0 - bounds.marginLeft)\n        .attr(\"x\", 0 - (bounds.height / 2))\n        .attr(\"font-size\", \"1rem\")\n        .attr(\"dy\", \"1.1em\")\n        .attr(\"fill\", \"currentColor\");\n\n    const lineGenerator = line<DataPoint>()\n        .x((d) => x(d.date))\n        .y((d) => y(d.retrievability));\n\n    // gradient color\n    const desiredRetentionY = desiredRetention * 100;\n    svg.append(\"linearGradient\")\n        .attr(\"id\", \"line-gradient\")\n        .attr(\"gradientUnits\", \"userSpaceOnUse\")\n        .attr(\"x1\", 0)\n        .attr(\"y1\", y(0))\n        .attr(\"x2\", 0)\n        .attr(\"y2\", y(100))\n        .selectAll(\"stop\")\n        .data([\n            { offset: \"0%\", color: \"tomato\" },\n            { offset: `${desiredRetentionY}%`, color: \"steelblue\" },\n            { offset: \"100%\", color: \"green\" },\n        ])\n        .enter().append(\"stop\")\n        .attr(\"offset\", d => d.offset)\n        .attr(\"stop-color\", d => d.color);\n\n    // Split data into past and future\n    const today = new Date();\n    const pastData = data.filter(d => d.date <= today);\n    const futureData = data.filter(d => d.date >= today);\n\n    // Draw solid line for past data\n    svg.append(\"path\")\n        .datum(pastData)\n        .attr(\"class\", \"forgetting-curve-line\")\n        .attr(\"fill\", \"none\")\n        .attr(\"stroke\", \"url(#line-gradient)\")\n        .attr(\"stroke-width\", 1.5)\n        .attr(\"d\", lineGenerator);\n\n    // Draw dashed line for future data\n    svg.append(\"path\")\n        .datum(futureData)\n        .attr(\"class\", \"forgetting-curve-line\")\n        .attr(\"fill\", \"none\")\n        .attr(\"stroke\", \"url(#line-gradient)\")\n        .attr(\"stroke-width\", 1.5)\n        .attr(\"stroke-dasharray\", \"4 4\")\n        .attr(\"d\", lineGenerator);\n\n    svg.select(\".desired-retention-line\").remove();\n    if (desiredRetentionY > yMin) {\n        svg.append(\"line\")\n            .attr(\"class\", \"desired-retention-line\")\n            .attr(\"x1\", bounds.marginLeft)\n            .attr(\"x2\", bounds.width - bounds.marginRight)\n            .attr(\"y1\", y(desiredRetentionY))\n            .attr(\"y2\", y(desiredRetentionY))\n            .attr(\"stroke\", \"steelblue\")\n            .attr(\"stroke-dasharray\", \"4 4\")\n            .attr(\"stroke-width\", 1.2);\n    }\n\n    const focusLine = svg.append(\"line\")\n        .attr(\"class\", \"focus-line\")\n        .attr(\"y1\", bounds.marginTop)\n        .attr(\"y2\", bounds.height - bounds.marginBottom)\n        .attr(\"stroke\", \"black\")\n        .attr(\"stroke-width\", 1)\n        .style(\"opacity\", 0);\n\n    function tooltipText(d: DataPoint): string {\n        return `${maxDays >= 365 ? \"Date\" : \"Date Time\"}: ${\n            maxDays >= 365 ? d.date.toLocaleDateString() : d.date.toLocaleString()\n        }<br>\n        ${tr.cardStatsReviewLogElapsedTime()}: ${\n            timeSpan(d.elapsedDaysSinceLastReview * 86400)\n        }<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${tr.cardStatsFsrsStability()}: ${\n            timeSpan(d.stability * 86400)\n        }`;\n    }\n\n    // hover/tooltip\n    svg.append(\"g\")\n        .attr(\"class\", \"hover-columns\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\"rect\")\n        .attr(\"x\", d => x(d.date) - 1)\n        .attr(\"y\", bounds.marginTop)\n        .attr(\"width\", 2)\n        .attr(\"height\", bounds.height - bounds.marginTop - bounds.marginBottom)\n        .attr(\"fill\", \"transparent\")\n        .on(\"mousemove\", (event: MouseEvent, d: DataPoint) => {\n            const [x1, y1] = pointer(event, document.body);\n            const [_, y2] = pointer(event, svg.node());\n\n            const lineY = y(desiredRetentionY);\n            focusLine.attr(\"x1\", x(d.date) - 1).attr(\"x2\", x(d.date) + 1).style(\n                \"opacity\",\n                1,\n            );\n            let text = tooltipText(d);\n            const desiredRetentionPercent = desiredRetention * 100;\n            if (y2 >= lineY - 10 && y2 <= lineY + 10) {\n                text += `<br>${tr.cardStatsFsrsForgettingCurveDesiredRetention()}: ${\n                    desiredRetentionPercent.toFixed(0)\n                }%`;\n            }\n            showTooltip(text, x1, y1);\n        })\n        .on(\"mouseout\", () => {\n            focusLine.style(\"opacity\", 0);\n            hideTooltip();\n        });\n}\n"
  },
  {
    "path": "ts/routes/change-notetype/Alert.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { onEnterOrSpace } from \"@tslib/keys\";\n    import { slide } from \"svelte/transition\";\n\n    import Badge from \"$lib/components/Badge.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { minusIcon, plusIcon } from \"$lib/components/icons\";\n\n    import { MapContext } from \"./lib\";\n\n    export let unused: string[];\n    export let ctx: MapContext;\n\n    let unusedMsg: string;\n    $: unusedMsg =\n        ctx === MapContext.Field\n            ? tr.changeNotetypeWillDiscardContent()\n            : tr.changeNotetypeWillDiscardCards();\n\n    const maxItems: number = 3;\n    let collapsed: boolean = true;\n    $: collapseMsg = collapsed\n        ? tr.changeNotetypeExpand()\n        : tr.changeNotetypeCollapse();\n    $: icon = collapsed ? plusIcon : minusIcon;\n</script>\n\n<div class=\"alert alert-warning\" in:slide out:slide>\n    {#if unused.length > maxItems}\n        <div\n            class=\"clickable\"\n            on:click={() => (collapsed = !collapsed)}\n            on:keydown={onEnterOrSpace(() => (collapsed = !collapsed))}\n            role=\"button\"\n            tabindex=\"0\"\n            aria-expanded={!collapsed}\n        >\n            <Badge iconSize={80}>\n                <Icon {icon} />\n            </Badge>\n            {collapseMsg}\n        </div>\n    {/if}\n    {unusedMsg}\n    {#if collapsed}\n        <div>\n            {unused.slice(0, maxItems).join(\", \")}\n            {#if unused.length > maxItems}\n                ... (+{unused.length - maxItems})\n            {/if}\n        </div>\n    {:else}\n        <ul>\n            {#each unused as entry}\n                <li>{entry}</li>\n            {/each}\n        </ul>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .clickable {\n        cursor: pointer;\n        font-weight: bold;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/change-notetype/ChangeNotetypePage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { renderMarkdown } from \"@tslib/helpers\";\n\n    import Container from \"$lib/components/Container.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import StickyContainer from \"$lib/components/StickyContainer.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\n    import type { ChangeNotetypeState } from \"./lib\";\n    import { MapContext } from \"./lib\";\n    import Mapper from \"./Mapper.svelte\";\n    import NotetypeSelector from \"./NotetypeSelector.svelte\";\n    import StickyHeader from \"./StickyHeader.svelte\";\n\n    export let state: ChangeNotetypeState;\n    $: info = state.info;\n</script>\n\n<StickyContainer\n    --gutter-block=\"0.5rem\"\n    --gutter-inline=\"0.25rem\"\n    --sticky-borders=\"0 0 1px\"\n    breakpoint=\"sm\"\n>\n    <NotetypeSelector {state} />\n</StickyContainer>\n\n<Container breakpoint=\"sm\" --gutter-inline=\"0.25rem\" --gutter-block=\"0.75rem\">\n    <Row --cols={2}>\n        <TitledContainer title={tr.changeNotetypeFields()}>\n            <Row>\n                <StickyHeader {state} ctx={MapContext.Field} />\n                <Mapper {state} ctx={MapContext.Field} />\n            </Row>\n        </TitledContainer>\n    </Row>\n    <Row --cols={2}>\n        <TitledContainer title={tr.changeNotetypeTemplates()}>\n            <Row>\n                <StickyHeader {state} ctx={MapContext.Template} />\n                {#if $info.templates}\n                    <Mapper {state} ctx={MapContext.Template} />\n                {:else}\n                    <div>\n                        {@html renderMarkdown(tr.changeNotetypeToFromCloze())}\n                    </div>\n                {/if}\n            </Row>\n        </TitledContainer>\n    </Row>\n</Container>\n"
  },
  {
    "path": "ts/routes/change-notetype/Mapper.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Container from \"$lib/components/Container.svelte\";\n    import Spacer from \"$lib/components/Spacer.svelte\";\n\n    import type { ChangeNotetypeState, MapContext } from \"./lib\";\n    import MapperRow from \"./MapperRow.svelte\";\n\n    export let state: ChangeNotetypeState;\n    export let ctx: MapContext;\n\n    const info = state.info;\n</script>\n\n<Spacer --height=\"0.5rem\" />\n\n<Container --gutter-inline=\"0.5rem\" --gutter-block=\"0.15rem\">\n    {#each $info.mapForContext(ctx) as _, newIndex}\n        <MapperRow {state} {ctx} {newIndex} />\n    {/each}\n</Container>\n"
  },
  {
    "path": "ts/routes/change-notetype/MapperRow.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"$lib/components/Col.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import Select from \"$lib/components/Select.svelte\";\n\n    import type { ChangeNotetypeState, MapContext } from \"./lib\";\n\n    export let state: ChangeNotetypeState;\n    export let ctx: MapContext;\n    export let newIndex: number;\n\n    const info = state.info;\n    $: oldIndex = $info.getOldIndex(ctx, newIndex);\n\n    function onChange(evt: CustomEvent) {\n        oldIndex = evt.detail.value;\n        state.setOldIndex(ctx, newIndex, oldIndex);\n    }\n\n    $: label = $info.getOldNamesIncludingNothing(ctx)[oldIndex];\n</script>\n\n<Row>\n    <Col>\n        <Select\n            value={oldIndex}\n            {label}\n            list={$info.getOldNamesIncludingNothing(ctx)}\n            on:change={onChange}\n        />\n    </Col>\n    <Col>\n        {$info.getNewName(ctx, newIndex)}\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/routes/change-notetype/NotetypeSelector.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import { arrowLeftIcon, arrowRightIcon } from \"$lib/components/icons\";\n    import Select from \"$lib/components/Select.svelte\";\n\n    import type { ChangeNotetypeState } from \"./lib\";\n    import SaveButton from \"./SaveButton.svelte\";\n\n    export let state: ChangeNotetypeState;\n    const notetypes = state.notetypes;\n    const info = state.info;\n\n    let value = $notetypes.findIndex((e) => e.current);\n    $: options = Array.from($notetypes, (notetype) => notetype.name);\n    $: label = options[value];\n\n    $: state.setTargetNotetypeIndex(value);\n</script>\n\n<ButtonToolbar class=\"justify-content-between\" wrap={false}>\n    <div class=\"d-flex flex-row w-100\">\n        <Select label={$info.oldNotetypeName} value={1} list={[1]} disabled={true} />\n        <div class=\"arrow-container\">\n            {#if window.getComputedStyle(document.body).direction == \"rtl\"}\n                <Icon icon={arrowLeftIcon} />\n            {:else}\n                <Icon icon={arrowRightIcon} />\n            {/if}\n        </div>\n        <Select list={options} bind:value {label} />\n    </div>\n    <SaveButton {state} />\n</ButtonToolbar>\n\n<style lang=\"scss\">\n    .arrow-container {\n        margin: 0 0.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/change-notetype/SaveButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n\n    import type { ChangeNotetypeState } from \"./lib\";\n\n    export let state: ChangeNotetypeState;\n\n    function save(): void {\n        if (document.activeElement instanceof HTMLElement) {\n            document.activeElement.blur();\n        }\n        state.save();\n    }\n\n    const keyCombination = \"Control+Enter\";\n</script>\n\n<ButtonGroup>\n    <LabelButton\n        primary\n        tooltip={getPlatformString(keyCombination)}\n        on:click={save}\n        --border-left-radius=\"5px\"\n        --border-right-radius=\"5px\"\n    >\n        <div class=\"save\">{tr.actionsSave()}</div>\n    </LabelButton>\n    <Shortcut {keyCombination} on:action={save} />\n</ButtonGroup>\n\n<style lang=\"scss\">\n    .save {\n        margin: 0.15rem 0.75rem;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/change-notetype/StickyHeader.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Col from \"$lib/components/Col.svelte\";\n    import Container from \"$lib/components/Container.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n\n    import Alert from \"./Alert.svelte\";\n    import type { ChangeNotetypeState } from \"./lib\";\n    import { MapContext } from \"./lib\";\n\n    export let state: ChangeNotetypeState;\n    export let ctx: MapContext;\n\n    $: info = state.info;\n\n    $: unused =\n        $info.isCloze && ctx === MapContext.Template ? [] : $info.unusedItems(ctx);\n</script>\n\n{#if unused.length > 0}\n    <Alert {unused} {ctx} />\n{/if}\n\n{#if $info.templates || ctx === MapContext.Field}\n    <Container --gutter-inline=\"0.5rem\" --gutter-block=\"0.2rem\">\n        <Row --cols={2}>\n            <Col --col-size={1}><b>{tr.changeNotetypeCurrent()}</b></Col>\n            <Col --col-size={1}><b>{tr.changeNotetypeNew()}</b></Col>\n        </Row>\n    </Container>\n{/if}\n"
  },
  {
    "path": "ts/routes/change-notetype/[...notetypeIds]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ChangeNotetypePage from \"../ChangeNotetypePage.svelte\";\n    import type { PageData } from \"./$types\";\n\n    export let data: PageData;\n</script>\n\n<ChangeNotetypePage state={data.state} />\n"
  },
  {
    "path": "ts/routes/change-notetype/[...notetypeIds]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { getChangeNotetypeInfo, getNotetypeNames } from \"@generated/backend\";\n\nimport { ChangeNotetypeState } from \"../lib\";\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async ({ params }) => {\n    const [fromIdStr, toIdStr] = params.notetypeIds.split(\"/\");\n    const oldNotetypeId = BigInt(fromIdStr);\n    const newNotetypeId = toIdStr ? BigInt(toIdStr) : oldNotetypeId;\n\n    const notetypeNames = getNotetypeNames({});\n    const changeNotetypeInfo = getChangeNotetypeInfo({\n        oldNotetypeId,\n        newNotetypeId,\n    });\n    const [names, info] = await Promise.all([notetypeNames, changeNotetypeInfo]);\n    const state = new ChangeNotetypeState(names, info);\n\n    return { state };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/change-notetype/change-notetype-base.scss",
    "content": "@use \"../lib/sass/bootstrap-dark\";\n\n@import \"../lib/sass/base\";\n\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/grid\";\n@import \"../lib/sass/bootstrap-forms\";\n\n.night-mode {\n    @include bootstrap-dark.night-mode;\n}\n\nbody {\n    width: min(100vw, 70em);\n    margin: 0 auto;\n}\n\nhtml {\n    overflow-x: hidden;\n}\n\n#main {\n    padding: 0.5em 0.5em 1em 0.5em;\n    height: 100vh;\n}\n"
  },
  {
    "path": "ts/routes/change-notetype/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./change-notetype-base.scss\";\n\nimport { getChangeNotetypeInfo, getNotetypeNames } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport ChangeNotetypePage from \"./ChangeNotetypePage.svelte\";\nimport { ChangeNotetypeState } from \"./lib\";\n\nconst notetypeNames = getNotetypeNames({});\nconst i18n = setupI18n({\n    modules: [ModuleName.ACTIONS, ModuleName.CHANGE_NOTETYPE, ModuleName.KEYBOARD],\n});\n\nexport async function setupChangeNotetypePage(\n    oldNotetypeId: bigint,\n    newNotetypeId: bigint,\n): Promise<ChangeNotetypePage> {\n    const changeNotetypeInfo = getChangeNotetypeInfo({\n        oldNotetypeId,\n        newNotetypeId,\n    });\n    const [names, info] = await Promise.all([notetypeNames, changeNotetypeInfo, i18n]);\n\n    checkNightMode();\n\n    const state = new ChangeNotetypeState(names, info);\n    return new ChangeNotetypePage({\n        target: document.body,\n        props: { state },\n    });\n}\n\n// use #testXXXX where XXXX is notetype ID to test\nif (window.location.hash.startsWith(\"#test\")) {\n    const ntid = parseInt(window.location.hash.substring(\"#test\".length), 10);\n    setupChangeNotetypePage(BigInt(ntid), BigInt(ntid));\n}\n"
  },
  {
    "path": "ts/routes/change-notetype/lib.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport \"@tslib/i18n\";\n\nimport { ChangeNotetypeInfo, NotetypeNames } from \"@generated/anki/notetypes_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { get } from \"svelte/store\";\nimport { expect, test } from \"vitest\";\n\nimport { ChangeNotetypeState, MapContext, negativeOneToNull } from \"./lib\";\n\nconst exampleNames = {\n    entries: [\n        {\n            id: 1623289129847n,\n            name: \"Basic\",\n        },\n        {\n            id: 1623289129848n,\n            name: \"Basic (and reversed card)\",\n        },\n        {\n            id: 1623289129849n,\n            name: \"Basic (optional reversed card)\",\n        },\n        {\n            id: 1623289129850n,\n            name: \"Basic (type in the answer)\",\n        },\n        {\n            id: 1623289129851n,\n            name: \"Cloze\",\n        },\n    ],\n};\n\nconst exampleInfoDifferent = {\n    oldFieldNames: [\"Front\", \"Back\"],\n    oldTemplateNames: [\"Card 1\"],\n    newFieldNames: [\"Front\", \"Back\", \"Add Reverse\"],\n    newTemplateNames: [\"Card 1\", \"Card 2\"],\n    input: {\n        newFields: [0, 1, -1],\n        newTemplates: [0, -1],\n        oldNotetypeId: 1623289129847n,\n        newNotetypeId: 1623289129849n,\n        currentSchema: 1623302002316n,\n        oldNotetypeName: \"Basic\",\n    },\n};\n\nconst exampleInfoSame = {\n    oldFieldNames: [\"Front\", \"Back\"],\n    oldTemplateNames: [\"Card 1\"],\n    newFieldNames: [\"Front\", \"Back\"],\n    newTemplateNames: [\"Card 1\"],\n    input: {\n        newFields: [0, 1],\n        newTemplates: [0],\n        oldNotetypeId: 1623289129847n,\n        newNotetypeId: 1623289129847n,\n        currentSchema: 1623302002316n,\n        oldNotetypeName: \"Basic\",\n    },\n};\n\nfunction differentState(): ChangeNotetypeState {\n    return new ChangeNotetypeState(\n        new NotetypeNames(exampleNames),\n        new ChangeNotetypeInfo(exampleInfoDifferent),\n    );\n}\n\nfunction sameState(): ChangeNotetypeState {\n    return new ChangeNotetypeState(\n        new NotetypeNames(exampleNames),\n        new ChangeNotetypeInfo(exampleInfoSame),\n    );\n}\n\ntest(\"proto conversion\", () => {\n    const state = differentState();\n    expect(get(state.info).fields).toStrictEqual([0, 1, null]);\n    expect(negativeOneToNull(state.dataForSaving().newFields)).toStrictEqual([\n        0,\n        1,\n        null,\n    ]);\n});\n\ntest(\"mapping\", () => {\n    const state = differentState();\n    expect(get(state.info).getNewName(MapContext.Field, 0)).toBe(\"Front\");\n    expect(get(state.info).getNewName(MapContext.Field, 1)).toBe(\"Back\");\n    expect(get(state.info).getNewName(MapContext.Field, 2)).toBe(\"Add Reverse\");\n    expect(get(state.info).getOldNamesIncludingNothing(MapContext.Field)).toStrictEqual(\n        [\"Front\", \"Back\", tr.changeNotetypeNothing()],\n    );\n    expect(get(state.info).getOldIndex(MapContext.Field, 0)).toBe(0);\n    expect(get(state.info).getOldIndex(MapContext.Field, 1)).toBe(1);\n    expect(get(state.info).getOldIndex(MapContext.Field, 2)).toBe(2);\n    state.setOldIndex(MapContext.Field, 2, 0);\n    expect(get(state.info).getOldIndex(MapContext.Field, 2)).toBe(0);\n\n    // the same template shouldn't be mappable twice\n    expect(\n        get(state.info).getOldNamesIncludingNothing(MapContext.Template),\n    ).toStrictEqual([\"Card 1\", tr.changeNotetypeNothing()]);\n    expect(get(state.info).getOldIndex(MapContext.Template, 0)).toBe(0);\n    expect(get(state.info).getOldIndex(MapContext.Template, 1)).toBe(1);\n    state.setOldIndex(MapContext.Template, 1, 0);\n    expect(get(state.info).getOldIndex(MapContext.Template, 0)).toBe(1);\n    expect(get(state.info).getOldIndex(MapContext.Template, 1)).toBe(0);\n});\n\ntest(\"unused\", () => {\n    const state = differentState();\n    expect(get(state.info).unusedItems(MapContext.Field)).toStrictEqual([]);\n    state.setOldIndex(MapContext.Field, 0, 2);\n    expect(get(state.info).unusedItems(MapContext.Field)).toStrictEqual([\"Front\"]);\n});\n\ntest(\"unchanged\", () => {\n    let state = differentState();\n    expect(get(state.info).unchanged()).toBe(false);\n    state = sameState();\n    expect(get(state.info).unchanged()).toBe(true);\n});\n"
  },
  {
    "path": "ts/routes/change-notetype/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { ChangeNotetypeInfo, ChangeNotetypeRequest, NotetypeNames } from \"@generated/anki/notetypes_pb\";\nimport { changeNotetype, getChangeNotetypeInfo } from \"@generated/backend\";\nimport * as tr from \"@generated/ftl\";\nimport { isEqual } from \"lodash-es\";\nimport type { Readable } from \"svelte/store\";\nimport { readable } from \"svelte/store\";\n\nfunction nullToNegativeOne(list: (number | null)[]): number[] {\n    return list.map((val) => val ?? -1);\n}\n\n/** Public only for tests. */\nexport function negativeOneToNull(list: number[]): (number | null)[] {\n    return list.map((val) => (val === -1 ? null : val));\n}\n\n/** Wrapper for the protobuf message to make it more ergonomic. */\nexport class ChangeNotetypeInfoWrapper {\n    fields: (number | null)[];\n    templates?: (number | null)[];\n    oldNotetypeName: string;\n    isCloze: boolean;\n    readonly info: ChangeNotetypeInfo;\n\n    constructor(info: ChangeNotetypeInfo) {\n        this.info = info;\n        const templates = info.input?.newTemplates ?? [];\n        this.isCloze = info.input?.isCloze ?? false;\n        if (templates.length > 0) {\n            this.templates = negativeOneToNull(templates);\n        }\n        this.fields = negativeOneToNull(info.input?.newFields ?? []);\n        this.oldNotetypeName = info.oldNotetypeName;\n    }\n\n    /** A list with an entry for each field/template in the new notetype, with\n    the values pointing back to indexes in the original notetype. */\n    mapForContext(ctx: MapContext): (number | null)[] {\n        return ctx == MapContext.Template ? this.templates ?? [] : this.fields;\n    }\n\n    /** Return index of old fields/templates, with null values mapped to \"Nothing\"\n    at the end.*/\n    getOldIndex(ctx: MapContext, newIdx: number): number {\n        const map = this.mapForContext(ctx);\n        const val = map[newIdx];\n        return val ?? this.getOldNamesIncludingNothing(ctx).length - 1;\n    }\n\n    /** Return all the old names, with \"Nothing\" at the end. */\n    getOldNamesIncludingNothing(ctx: MapContext): string[] {\n        return [...this.getOldNames(ctx), tr.changeNotetypeNothing()];\n    }\n\n    /** Old names without \"Nothing\" at the end. */\n    getOldNames(ctx: MapContext): string[] {\n        return ctx == MapContext.Template\n            ? this.info.oldTemplateNames\n            : this.info.oldFieldNames;\n    }\n\n    getOldNotetypeName(): string {\n        return this.info.oldNotetypeName;\n    }\n\n    getNewName(ctx: MapContext, idx: number): string {\n        return (\n            ctx == MapContext.Template\n                ? this.info.newTemplateNames\n                : this.info.newFieldNames\n        )[idx];\n    }\n\n    unusedItems(ctx: MapContext): string[] {\n        const usedEntries = new Set(this.mapForContext(ctx).filter((v) => v !== null));\n        const oldNames = this.getOldNames(ctx);\n        const unusedIdxs = [...Array(oldNames.length).keys()].filter(\n            (idx) => !usedEntries.has(idx),\n        );\n        const unusedNames = unusedIdxs.map((idx) => oldNames[idx]);\n        return unusedNames;\n    }\n\n    unchanged(): boolean {\n        return (\n            this.input().newNotetypeId === this.input().oldNotetypeId\n            && isEqual(this.fields, [...Array(this.fields.length).keys()])\n            && isEqual(this.templates, [...Array(this.templates?.length ?? 0).keys()])\n        );\n    }\n\n    input(): ChangeNotetypeRequest {\n        return this.info.input!;\n    }\n\n    /** Pack changes back into input message for saving. */\n    intoInput(): ChangeNotetypeRequest {\n        const input = this.info.input!;\n        input.newFields = nullToNegativeOne(this.fields);\n        if (this.templates) {\n            input.newTemplates = nullToNegativeOne(this.templates);\n        }\n\n        return input;\n    }\n}\n\nexport interface NotetypeListEntry {\n    idx: number;\n    name: string;\n    current: boolean;\n}\n\nexport enum MapContext {\n    Field,\n    Template,\n}\n\nexport class ChangeNotetypeState {\n    readonly info: Readable<ChangeNotetypeInfoWrapper>;\n    readonly notetypes: Readable<NotetypeListEntry[]>;\n\n    private info_: ChangeNotetypeInfoWrapper;\n    private infoSetter!: (val: ChangeNotetypeInfoWrapper) => void;\n    private notetypeNames: NotetypeNames;\n    private notetypesSetter!: (val: NotetypeListEntry[]) => void;\n\n    constructor(\n        notetypes: NotetypeNames,\n        info: ChangeNotetypeInfo,\n    ) {\n        this.info_ = new ChangeNotetypeInfoWrapper(info);\n        this.info = readable(this.info_, (set) => {\n            this.infoSetter = set;\n        });\n        this.notetypeNames = notetypes;\n        this.notetypes = readable(this.buildNotetypeList(), (set) => {\n            this.notetypesSetter = set;\n            return;\n        });\n    }\n\n    async setTargetNotetypeIndex(idx: number): Promise<void> {\n        this.info_.input().newNotetypeId = this.notetypeNames.entries[idx].id!;\n        this.notetypesSetter(this.buildNotetypeList());\n        const { oldNotetypeId, newNotetypeId } = this.info_.input();\n        const newInfo = await getChangeNotetypeInfo({\n            oldNotetypeId,\n            newNotetypeId,\n        });\n        this.info_ = new ChangeNotetypeInfoWrapper(newInfo);\n        this.info_.unusedItems(MapContext.Field);\n        this.infoSetter(this.info_);\n    }\n\n    setOldIndex(ctx: MapContext, newIdx: number, oldIdx: number): void {\n        const list = this.info_.mapForContext(ctx);\n        const oldNames = this.info_.getOldNames(ctx);\n        const realOldIdx = oldIdx < oldNames.length ? oldIdx : null;\n        const allowDupes = ctx == MapContext.Field;\n\n        // remove any existing references?\n        if (!allowDupes && realOldIdx !== null) {\n            for (let i = 0; i < list.length; i++) {\n                if (list[i] === realOldIdx) {\n                    list[i] = null;\n                }\n            }\n        }\n\n        list[newIdx] = realOldIdx;\n        this.infoSetter(this.info_);\n    }\n\n    dataForSaving(): ChangeNotetypeRequest {\n        return this.info_.intoInput();\n    }\n\n    async save(): Promise<void> {\n        if (this.info_.unchanged()) {\n            alert(\"No changes to save\");\n            return;\n        }\n        await changeNotetype(this.dataForSaving());\n    }\n\n    private buildNotetypeList(): NotetypeListEntry[] {\n        const currentId = this.info_.input().newNotetypeId;\n        return this.notetypeNames.entries.map(\n            (entry, idx) => ({\n                idx,\n                name: entry.name,\n                current: entry.id === currentId,\n            } satisfies NotetypeListEntry),\n        );\n    }\n}\n"
  },
  {
    "path": "ts/routes/congrats/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { PageData } from \"./$types\";\n    import CongratsPage from \"./CongratsPage.svelte\";\n\n    export let data: PageData;\n</script>\n\n<CongratsPage info={data.info} />\n"
  },
  {
    "path": "ts/routes/congrats/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { congratsInfo } from \"@generated/backend\";\n\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async () => {\n    const info = await congratsInfo({});\n    return { info };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/congrats/CongratsPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { CongratsInfoResponse } from \"@generated/anki/scheduler_pb\";\n    import { congratsInfo } from \"@generated/backend\";\n    import * as tr from \"@generated/ftl\";\n    import { bridgeLink } from \"@tslib/bridgecommand\";\n\n    import Col from \"$lib/components/Col.svelte\";\n    import Container from \"$lib/components/Container.svelte\";\n\n    import { buildNextLearnMsg } from \"./lib\";\n    import { onMount } from \"svelte\";\n\n    export let info: CongratsInfoResponse;\n    export let refreshPeriodically = true;\n\n    const congrats = tr.schedulingCongratulationsFinished();\n    let nextLearnMsg: string;\n    $: nextLearnMsg = buildNextLearnMsg(info);\n    const today_reviews = tr.schedulingTodayReviewLimitReached();\n    const today_new = tr.schedulingTodayNewLimitReached();\n\n    const unburyThem = bridgeLink(\"unbury\", tr.schedulingUnburyThem());\n    const buriedMsg = tr.schedulingBuriedCardsFound({ unburyThem });\n    const customStudy = bridgeLink(\"customStudy\", tr.schedulingCustomStudy());\n    const customStudyMsg = tr.schedulingHowToCustomStudy({\n        customStudy,\n    });\n\n    onMount(() => {\n        if (refreshPeriodically) {\n            setInterval(async () => {\n                try {\n                    info = await congratsInfo({}, { alertOnError: false });\n                } catch {\n                    console.log(\"congrats fetch failed\");\n                }\n            }, 60000);\n        }\n    });\n</script>\n\n<Container --gutter-block=\"1rem\" --gutter-inline=\"2px\" breakpoint=\"sm\">\n    <Col --col-justify=\"center\">\n        <div class=\"congrats\">\n            <h1>{congrats}</h1>\n\n            <p>{nextLearnMsg}</p>\n\n            {#if info.reviewRemaining}\n                <p>{today_reviews}</p>\n            {/if}\n\n            {#if info.newRemaining}\n                <p>{today_new}</p>\n            {/if}\n\n            {#if info.bridgeCommandsSupported}\n                {#if info.haveSchedBuried || info.haveUserBuried}\n                    <p>\n                        {@html buriedMsg}\n                    </p>\n                {/if}\n\n                {#if !info.isFilteredDeck}\n                    <p>\n                        {@html customStudyMsg}\n                    </p>\n                {/if}\n            {/if}\n\n            {#if info.deckDescription}\n                <div class=\"description\">\n                    {@html info.deckDescription}\n                </div>\n            {/if}\n        </div>\n    </Col>\n</Container>\n\n<style lang=\"scss\">\n    .congrats {\n        margin-top: 2em;\n        max-width: 30em;\n        font-size: var(--font-size);\n\n        :global(a) {\n            color: var(--fg-link);\n            text-decoration: none;\n        }\n    }\n\n    .description {\n        border: 1px solid var(--border);\n        padding: 1em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/congrats/congrats-base.scss",
    "content": "@use \"../../lib/sass/root-vars\";\n@import \"../../lib/sass/base\";\n\n@import \"bootstrap/scss/containers\";\n\nbody {\n    margin-left: 0.5em;\n    margin-right: 0.5em;\n}\n"
  },
  {
    "path": "ts/routes/congrats/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n//\n// This old non-Sveltekit entrypoint has been preserved for AnkiWeb compatibility,\n// and can't yet be removed. AnkiWeb loads the generated .js file into an existing\n// page, and mounts into a div with 'id=congrats'. Unlike the desktop, it does not\n// auto-refresh (to reduce the load on AnkiWeb).\n\nimport \"./congrats-base.scss\";\n\nimport { congratsInfo } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\nimport { mount } from \"svelte\";\n\nimport CongratsPage from \"./CongratsPage.svelte\";\n\nconst i18n = setupI18n({ modules: [ModuleName.SCHEDULING] });\n\nexport async function setupCongrats(): Promise<void> {\n    checkNightMode();\n    await i18n;\n\n    const customMountPoint = document.getElementById(\"congrats\");\n    const info = await congratsInfo({});\n    const props = { info, refreshPeriodically: false };\n    mount(\n        CongratsPage, // use #congrats if it exists, otherwise entire body\n        { target: customMountPoint ?? document.body, props },\n    );\n    return;\n}\n\nsetupCongrats();\n"
  },
  {
    "path": "ts/routes/congrats/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { CongratsInfoResponse } from \"@generated/anki/scheduler_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { naturalUnit, unitAmount, unitName } from \"@tslib/time\";\n\nexport function buildNextLearnMsg(info: CongratsInfoResponse): string {\n    const secsUntil = info.secsUntilNextLearn;\n    // next learning card not due today?\n    if (secsUntil >= 86_400) {\n        return \"\";\n    }\n\n    const unit = naturalUnit(secsUntil);\n    const amount = Math.round(unitAmount(unit, secsUntil));\n    const unitStr = unitName(unit);\n    const nextLearnDue = tr.schedulingNextLearnDue({\n        amount,\n        unit: unitStr,\n    });\n    const remaining = tr.schedulingLearnRemaining({\n        remaining: info.learnRemaining,\n    });\n    return `${nextLearnDue} ${remaining}`;\n}\n"
  },
  {
    "path": "ts/routes/deck-options/Addons.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\n    import type { DeckOptionsState } from \"./lib\";\n\n    export let state: DeckOptionsState;\n\n    const components = state.addonComponents;\n    const auxData = state.currentAuxData;\n</script>\n\n{#if $components.length}\n    <TitledContainer title=\"Add-ons\">\n        {#each $components as addon}\n            <svelte:component this={addon.component} bind:data={$auxData} {...addon} />\n        {/each}\n    </TitledContainer>\n{/if}\n"
  },
  {
    "path": "ts/routes/deck-options/AdvancedOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import { type HelpItem, HelpItemScheduler } from \"$lib/components/types\";\n\n    import CardStateCustomizer from \"./CardStateCustomizer.svelte\";\n    import type { DeckOptionsState } from \"./lib\";\n    import SpinBoxFloatRow from \"./SpinBoxFloatRow.svelte\";\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import DateInput from \"./DateInput.svelte\";\n    import Warning from \"./Warning.svelte\";\n    import { getIgnoredBeforeCount } from \"@generated/backend\";\n    import type { GetIgnoredBeforeCountResponse } from \"@generated/anki/deck_config_pb\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const cardStateCustomizer = state.cardStateCustomizer;\n    const fsrs = state.fsrs;\n\n    const settings = {\n        maximumInterval: {\n            title: tr.schedulingMaximumInterval(),\n            help: tr.deckConfigMaximumIntervalTooltip(),\n            url: HelpPage.DeckOptions.maximumInterval,\n        },\n        historicalRetention: {\n            title: tr.deckConfigHistoricalRetention(),\n            help: tr.deckConfigHistoricalRetentionTooltip(),\n            sched: HelpItemScheduler.FSRS,\n        },\n        ignoreRevlogsBeforeMs: {\n            title: tr.deckConfigIgnoreBefore(),\n            help: tr.deckConfigIgnoreBeforeTooltip2(),\n            sched: HelpItemScheduler.FSRS,\n        },\n        startingEase: {\n            title: tr.schedulingStartingEase(),\n            help: tr.deckConfigStartingEaseTooltip(),\n            url: HelpPage.DeckOptions.startingEase,\n            sched: HelpItemScheduler.SM2,\n        },\n        easyBonus: {\n            title: tr.schedulingEasyBonus(),\n            help: tr.deckConfigEasyBonusTooltip(),\n            url: HelpPage.DeckOptions.easyBonus,\n            sched: HelpItemScheduler.SM2,\n        },\n        intervalModifier: {\n            title: tr.schedulingIntervalModifier(),\n            help: tr.deckConfigIntervalModifierTooltip(),\n            url: HelpPage.DeckOptions.intervalModifier,\n            sched: HelpItemScheduler.SM2,\n        },\n        hardInterval: {\n            title: tr.schedulingHardInterval(),\n            help: tr.deckConfigHardIntervalTooltip(),\n            url: HelpPage.DeckOptions.hardInterval,\n            sched: HelpItemScheduler.SM2,\n        },\n        newInterval: {\n            title: tr.schedulingNewInterval(),\n            help: tr.deckConfigNewIntervalTooltip(),\n            url: HelpPage.DeckOptions.newInterval,\n            sched: HelpItemScheduler.SM2,\n        },\n        customScheduling: {\n            title: tr.deckConfigCustomScheduling(),\n            help: tr.deckConfigCustomSchedulingTooltip(),\n            url: \"https://faqs.ankiweb.net/the-2021-scheduler.html#add-ons-and-custom-scheduling\",\n            global: true,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    $: maxIntervalWarningClass =\n        $config.maximumReviewInterval < 50 ? \"alert-danger\" : \"alert-warning\";\n    $: maxIntervalWarning =\n        $config.maximumReviewInterval < 180\n            ? tr.deckConfigTooShortMaximumInterval()\n            : \"\";\n\n    let ignoreRevlogsBeforeCount: GetIgnoredBeforeCountResponse | null = null;\n    let lastIgnoreRevlogsBeforeDate = \"\";\n    function updateIgnoreRevlogsBeforeCount(ignoreRevlogsBeforeDate: string) {\n        if (lastIgnoreRevlogsBeforeDate == ignoreRevlogsBeforeDate) {\n            return;\n        }\n        if (\n            cutoffUpdatedSinceLoad &&\n            ignoreRevlogsBeforeDate &&\n            ignoreRevlogsBeforeDate != \"1970-01-01\"\n        ) {\n            lastIgnoreRevlogsBeforeDate = ignoreRevlogsBeforeDate;\n            getIgnoredBeforeCount({\n                search:\n                    $config.paramSearch ||\n                    `preset:\"${state.getCurrentNameForSearch()}\" -is:suspended`,\n                ignoreRevlogsBeforeDate,\n            }).then((resp) => {\n                ignoreRevlogsBeforeCount = resp;\n            });\n        } else {\n            ignoreRevlogsBeforeCount = null;\n        }\n        cutoffUpdatedSinceLoad = true;\n    }\n\n    let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;\n    // Running the card count check on startup is inefficient. After users have had a few months\n    // to notice + update (e.g. from ~Oct 2025), we should change this to false.\n    let cutoffUpdatedSinceLoad = true;\n    const IGNORE_REVLOG_COUNT_DELAY_MS = 1000;\n\n    $: {\n        clearTimeout(timeoutId);\n        timeoutId = setTimeout(() => {\n            updateIgnoreRevlogsBeforeCount($config.ignoreRevlogsBeforeDate);\n        }, IGNORE_REVLOG_COUNT_DELAY_MS);\n    }\n    let ignoreRevlogsBeforeWarningClass = \"alert-warning\";\n    $: if (ignoreRevlogsBeforeCount) {\n        // If there is less than a tenth of reviews included\n        if (\n            Number(ignoreRevlogsBeforeCount.included) /\n                Number(ignoreRevlogsBeforeCount.total) <\n            0.1\n        ) {\n            ignoreRevlogsBeforeWarningClass = \"alert-danger\";\n        } else if (\n            ignoreRevlogsBeforeCount.included != ignoreRevlogsBeforeCount.total\n        ) {\n            ignoreRevlogsBeforeWarningClass = \"alert-warning\";\n        } else {\n            ignoreRevlogsBeforeWarningClass = \"alert-info\";\n        }\n    }\n    $: ignoreRevlogsBeforeWarning = ignoreRevlogsBeforeCount\n        ? tr.deckConfigIgnoreBeforeInfo({\n              included: ignoreRevlogsBeforeCount.included.toString(),\n              totalCards: ignoreRevlogsBeforeCount.total.toString(),\n          })\n        : \"\";\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigAdvancedTitle()}>\n    <HelpModal\n        title={tr.deckConfigAdvancedTitle()}\n        url={HelpPage.DeckOptions.advanced}\n        slot=\"tooltip\"\n        fsrs={$fsrs}\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SpinBoxRow\n                bind:value={$config.maximumReviewInterval}\n                defaultValue={defaults.maximumReviewInterval}\n                min={1}\n                max={365 * 100}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"maximumInterval\"))}\n                >\n                    {settings.maximumInterval.title}\n                </SettingTitle>\n            </SpinBoxRow>\n        </Item>\n\n        <Item>\n            <Warning warning={maxIntervalWarning} className={maxIntervalWarningClass}\n            ></Warning>\n        </Item>\n\n        {#if !$fsrs}\n            <Item>\n                <SpinBoxFloatRow\n                    bind:value={$config.initialEase}\n                    defaultValue={defaults.initialEase}\n                    min={1.31}\n                    max={5}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"startingEase\"),\n                            )}\n                    >\n                        {settings.startingEase.title}\n                    </SettingTitle>\n                </SpinBoxFloatRow>\n            </Item>\n\n            <Item>\n                <SpinBoxFloatRow\n                    bind:value={$config.easyMultiplier}\n                    defaultValue={defaults.easyMultiplier}\n                    min={1}\n                    max={5}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(Object.keys(settings).indexOf(\"easyBonus\"))}\n                    >\n                        {settings.easyBonus.title}\n                    </SettingTitle>\n                </SpinBoxFloatRow>\n            </Item>\n\n            <Item>\n                <SpinBoxFloatRow\n                    bind:value={$config.intervalMultiplier}\n                    defaultValue={defaults.intervalMultiplier}\n                    min={0.5}\n                    max={2}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"intervalModifier\"),\n                            )}\n                    >\n                        {settings.intervalModifier.title}\n                    </SettingTitle>\n                </SpinBoxFloatRow>\n            </Item>\n\n            <Item>\n                <SpinBoxFloatRow\n                    bind:value={$config.hardMultiplier}\n                    defaultValue={defaults.hardMultiplier}\n                    min={0.5}\n                    max={1.3}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"hardInterval\"),\n                            )}\n                    >\n                        {settings.hardInterval.title}\n                    </SettingTitle>\n                </SpinBoxFloatRow>\n            </Item>\n\n            <Item>\n                <SpinBoxFloatRow\n                    bind:value={$config.lapseMultiplier}\n                    defaultValue={defaults.lapseMultiplier}\n                    max={1}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(Object.keys(settings).indexOf(\"newInterval\"))}\n                    >\n                        {settings.newInterval.title}\n                    </SettingTitle>\n                </SpinBoxFloatRow>\n            </Item>\n        {:else}\n            <SpinBoxFloatRow\n                bind:value={$config.historicalRetention}\n                defaultValue={defaults.historicalRetention}\n                min={0.5}\n                max={1.0}\n                percentage={true}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"historicalRetention\"),\n                        )}\n                >\n                    {tr.deckConfigHistoricalRetention()}\n                </SettingTitle>\n            </SpinBoxFloatRow>\n\n            <Item>\n                <DateInput bind:date={$config.ignoreRevlogsBeforeDate}>\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"ignoreRevlogsBeforeMs\"),\n                            )}\n                    >\n                        {tr.deckConfigIgnoreBefore()}\n                    </SettingTitle>\n                </DateInput>\n            </Item>\n\n            <Item>\n                <Warning\n                    warning={ignoreRevlogsBeforeWarning}\n                    className={ignoreRevlogsBeforeWarningClass}\n                ></Warning>\n            </Item>\n        {/if}\n\n        <Item>\n            <CardStateCustomizer\n                title={settings.customScheduling.title}\n                on:click={() =>\n                    openHelpModal(Object.keys(settings).indexOf(\"customScheduling\"))}\n                bind:value={$cardStateCustomizer}\n            />\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/AudioOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import type { DeckOptionsState } from \"./lib\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n\n    const settings = {\n        disableAutoplay: {\n            title: tr.deckConfigDisableAutoplay(),\n            help: tr.deckConfigDisableAutoplayTooltip(),\n        },\n        skipQuestionWhenReplaying: {\n            title: tr.deckConfigSkipQuestionWhenReplaying(),\n            help: tr.deckConfigAlwaysIncludeQuestionAudioTooltip(),\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigAudioTitle()}>\n    <HelpModal\n        title={tr.deckConfigAudioTitle()}\n        url={HelpPage.DeckOptions.audio}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SwitchRow\n                bind:value={$config.disableAutoplay}\n                defaultValue={defaults.disableAutoplay}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"disableAutoplay\"))}\n                >\n                    {settings.disableAutoplay.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        <Item>\n            <SwitchRow\n                bind:value={$config.skipQuestionWhenReplayingAnswer}\n                defaultValue={defaults.skipQuestionWhenReplayingAnswer}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"skipQuestionWhenReplaying\"),\n                        )}\n                >\n                    {settings.skipQuestionWhenReplaying.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/AutoAdvance.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import { answerChoices, questionActionChoices } from \"./choices\";\n    import type { DeckOptionsState } from \"./lib\";\n    import SpinBoxFloatRow from \"./SpinBoxFloatRow.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n\n    const settings = {\n        secondsToShowQuestion: {\n            title: tr.deckConfigSecondsToShowQuestion(),\n            help: tr.deckConfigSecondsToShowQuestionTooltip3(),\n        },\n        secondsToShowAnswer: {\n            title: tr.deckConfigSecondsToShowAnswer(),\n            help: tr.deckConfigSecondsToShowAnswerTooltip2(),\n        },\n        waitForAudio: {\n            title: tr.deckConfigWaitForAudio(),\n            help: tr.deckConfigWaitForAudioTooltip2(),\n        },\n        questionAction: {\n            title: tr.deckConfigQuestionAction(),\n            help: tr.deckConfigQuestionActionToolTip(),\n        },\n        answerAction: {\n            title: tr.deckConfigAnswerAction(),\n            help: tr.deckConfigAnswerActionTooltip2(),\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.actionsAutoAdvance()}>\n    <HelpModal\n        title={tr.actionsAutoAdvance()}\n        url={HelpPage.DeckOptions.autoAdvance}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SpinBoxFloatRow\n                bind:value={$config.secondsToShowQuestion}\n                defaultValue={defaults.secondsToShowQuestion}\n                step={0.1}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"secondsToShowQuestion\"),\n                        )}\n                >\n                    {settings.secondsToShowQuestion.title}\n                </SettingTitle>\n            </SpinBoxFloatRow>\n        </Item>\n\n        <Item>\n            <SpinBoxFloatRow\n                bind:value={$config.secondsToShowAnswer}\n                defaultValue={defaults.secondsToShowAnswer}\n                step={0.1}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"secondsToShowAnswer\"),\n                        )}\n                >\n                    {settings.secondsToShowAnswer.title}\n                </SettingTitle>\n            </SpinBoxFloatRow>\n        </Item>\n\n        <Item>\n            <SwitchRow\n                bind:value={$config.waitForAudio}\n                defaultValue={defaults.waitForAudio}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"waitForAudio\"))}\n                >\n                    {settings.waitForAudio.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.questionAction}\n                defaultValue={defaults.questionAction}\n                choices={questionActionChoices()}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"questionAction\"))}\n                >\n                    {settings.questionAction.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.answerAction}\n                defaultValue={defaults.answerAction}\n                choices={answerChoices()}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"answerAction\"))}\n                >\n                    {settings.answerAction.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/BuryOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import type { DeckOptionsState } from \"./lib\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n\n    const priorityTooltip = \"\\n\\n\" + tr.deckConfigBuryPriorityTooltip();\n\n    const settings = {\n        buryNewSiblings: {\n            title: tr.deckConfigBuryNewSiblings(),\n            help: tr.deckConfigBuryNewTooltip() + priorityTooltip,\n        },\n        buryReviewSiblings: {\n            title: tr.deckConfigBuryReviewSiblings(),\n            help: tr.deckConfigBuryReviewTooltip() + priorityTooltip,\n        },\n        buryInterdayLearningSiblings: {\n            title: tr.deckConfigBuryInterdayLearningSiblings(),\n            help: tr.deckConfigBuryInterdayLearningTooltip() + priorityTooltip,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigBuryTitle()}>\n    <HelpModal\n        title={tr.deckConfigBuryTitle()}\n        url={HelpPage.Studying.siblingsAndBurying}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SwitchRow bind:value={$config.buryNew} defaultValue={defaults.buryNew}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"buryNewSiblings\"))}\n                >\n                    {settings.buryNewSiblings.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        <Item>\n            <SwitchRow\n                bind:value={$config.buryReviews}\n                defaultValue={defaults.buryReviews}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"buryReviewSiblings\"),\n                        )}\n                >\n                    {settings.buryReviewSiblings.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        <Item>\n            <SwitchRow\n                bind:value={$config.buryInterdayLearning}\n                defaultValue={defaults.buryInterdayLearning}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\n                                \"buryInterdayLearningSiblings\",\n                            ),\n                        )}\n                >\n                    {settings.buryInterdayLearningSiblings.title}\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/CardStateCustomizer.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n\n    import GlobalLabel from \"./GlobalLabel.svelte\";\n\n    export let value: string;\n    export let title: string;\n</script>\n\n<div class=\"m-2\">\n    <ConfigInput>\n        <RevertButton slot=\"revert\" bind:value defaultValue=\"\" />\n        <details>\n            <summary><GlobalLabel {title} /></summary>\n            <div class=\"text\">\n                <textarea\n                    class=\"card-state-customizer form-control\"\n                    bind:value\n                    spellcheck=\"false\"\n                    autocapitalize=\"none\"\n                ></textarea>\n            </div>\n        </details>\n    </ConfigInput>\n</div>\n\n<style lang=\"scss\">\n    .text {\n        width: 100%;\n        min-height: 2em;\n    }\n\n    .card-state-customizer {\n        background-color: var(--canvas-code);\n        border: 1px solid var(--border-subtle);\n        width: 100%;\n        height: 10em;\n        font-family: monospace;\n    }\n\n    @supports (-webkit-touch-callout: none) {\n        // mobile compat\n        .card-state-customizer {\n            font-size: 16px;\n            overflow-x: hidden;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/ConfigSelector.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { noop } from \"@tslib/functional\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n    import { createEventDispatcher, getContext } from \"svelte\";\n\n    import { modalsKey } from \"$lib/components/context-keys\";\n    import Select from \"$lib/components/Select.svelte\";\n    import StickyContainer from \"$lib/components/StickyContainer.svelte\";\n\n    import type { ConfigListEntry, DeckOptionsState } from \"./lib\";\n    import SaveButton from \"./SaveButton.svelte\";\n    import TextInputModal from \"./TextInputModal.svelte\";\n\n    export let state: DeckOptionsState;\n    const configList = state.configList;\n    const dispatch = createEventDispatcher();\n    const dispatchPresetChange = () => dispatch(\"presetchange\");\n\n    $: value = $configList.findIndex((entry) => entry.current);\n    $: label = configLabel($configList[value]);\n\n    function configLabel(entry: ConfigListEntry): string {\n        const count = tr.deckConfigUsedByDecks({ decks: entry.useCount });\n        return `${entry.name} (${count})`;\n    }\n\n    function blur(e: CustomEvent): void {\n        state.setCurrentIndex(e.detail.value);\n        dispatchPresetChange();\n    }\n\n    function onAddConfig(text: string): void {\n        const trimmed = text.trim();\n        if (trimmed.length) {\n            state.addConfig(trimmed);\n            dispatchPresetChange();\n        }\n    }\n\n    function onCloneConfig(text: string): void {\n        const trimmed = text.trim();\n        if (trimmed.length) {\n            state.cloneConfig(trimmed);\n            dispatchPresetChange();\n        }\n    }\n\n    function onRenameConfig(text: string): void {\n        state.setCurrentName(text);\n    }\n\n    const modals = getContext<Map<string, Modal>>(modalsKey);\n\n    let modalKey: string;\n    let modalStartingValue = \"\";\n    let modalTitle = \"\";\n    let modalSuccess: (text: string) => void = noop;\n\n    function promptToAdd() {\n        modalTitle = tr.deckConfigAddGroup();\n        modalSuccess = onAddConfig;\n        modalStartingValue = \"\";\n        modals.get(modalKey)!.show();\n    }\n\n    function promptToClone() {\n        modalTitle = tr.deckConfigCloneGroup();\n        modalSuccess = onCloneConfig;\n        modalStartingValue = state.getCurrentName();\n        modals.get(modalKey)!.show();\n    }\n\n    function promptToRename() {\n        modalTitle = tr.deckConfigRenameGroup();\n        modalSuccess = onRenameConfig;\n        modalStartingValue = state.getCurrentName();\n        modals.get(modalKey)!.show();\n    }\n</script>\n\n<TextInputModal\n    title={modalTitle}\n    prompt={tr.deckConfigNamePrompt()}\n    initialValue={modalStartingValue}\n    onOk={modalSuccess}\n    bind:modalKey\n/>\n\n<StickyContainer --gutter-block=\"0.5rem\" --sticky-borders=\"0 0 1px\" breakpoint=\"sm\">\n    <div class=\"button-bar\">\n        <Select\n            class=\"flex-grow-1 mr1\"\n            bind:value\n            {label}\n            list={$configList}\n            parser={(entry) => ({\n                content: configLabel(entry),\n                value: entry.idx,\n            })}\n            on:change={blur}\n        />\n\n        <SaveButton\n            {state}\n            on:add={promptToAdd}\n            on:clone={promptToClone}\n            on:rename={promptToRename}\n            on:remove={dispatchPresetChange}\n        />\n    </div>\n</StickyContainer>\n\n<style lang=\"scss\">\n    .button-bar {\n        display: flex;\n        flex-wrap: nowrap;\n        justify-content: space-between;\n\n        flex-grow: 1;\n    }\n\n    /* TODO replace with gap once available (blocked by Qt5 / Chromium 77) */\n    :global(.mr1) {\n        margin-right: 1rem;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/DailyLimits.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import GlobalLabel from \"./GlobalLabel.svelte\";\n    import type { DeckOptionsState } from \"./lib\";\n    import { ValueTab } from \"./lib\";\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import TabbedValue from \"./TabbedValue.svelte\";\n    import Warning from \"./Warning.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    export function onPresetChange() {\n        newTabs[0] = new ValueTab(\n            tr.deckConfigSharedPreset(),\n            $config.newPerDay,\n            (value) => ($config.newPerDay = value!),\n            $config.newPerDay,\n            null,\n        );\n        reviewTabs[0] = new ValueTab(\n            tr.deckConfigSharedPreset(),\n            $config.reviewsPerDay,\n            (value) => ($config.reviewsPerDay = value!),\n            $config.reviewsPerDay,\n            null,\n        );\n    }\n\n    const config = state.currentConfig;\n    const limits = state.deckLimits;\n    const defaults = state.defaults;\n    const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;\n    const applyAllParentLimits = state.applyAllParentLimits;\n\n    const v3Extra =\n        \"\\n\\n\" + tr.deckConfigLimitDeckV3() + \"\\n\\n\" + tr.deckConfigTabDescription();\n    const reviewV3Extra = \"\\n\\n\" + tr.deckConfigLimitInterdayBoundByReviews() + v3Extra;\n    const newCardsIgnoreReviewLimitHelp =\n        tr.deckConfigAffectsEntireCollection() +\n        \"\\n\\n\" +\n        tr.deckConfigNewCardsIgnoreReviewLimitTooltip();\n    const applyAllParentLimitsHelp =\n        tr.deckConfigAffectsEntireCollection() +\n        \"\\n\\n\" +\n        tr.deckConfigApplyAllParentLimitsTooltip();\n\n    $: reviewsTooLow =\n        Math.min(9999, newValue * 10) > reviewsValue\n            ? tr.deckConfigReviewsTooLow({\n                  cards: newValue,\n                  expected: Math.min(9999, newValue * 10),\n              })\n            : \"\";\n\n    const newTabs: ValueTab[] = [\n        new ValueTab(\n            tr.deckConfigSharedPreset(),\n            $config.newPerDay,\n            (value) => ($config.newPerDay = value!),\n            $config.newPerDay,\n            null,\n        ),\n        new ValueTab(\n            tr.deckConfigDeckOnly(),\n            $limits.new ?? null,\n            (value) => ($limits.new = value ?? undefined),\n            null,\n            null,\n        ),\n        new ValueTab(\n            tr.deckConfigTodayOnly(),\n            $limits.newTodayActive ? ($limits.newToday ?? null) : null,\n            (value) => ($limits.newToday = value ?? undefined),\n            null,\n            $limits.newToday ?? null,\n        ),\n    ];\n\n    const reviewTabs: ValueTab[] = [\n        new ValueTab(\n            tr.deckConfigSharedPreset(),\n            $config.reviewsPerDay,\n            (value) => ($config.reviewsPerDay = value!),\n            $config.reviewsPerDay,\n            null,\n        ),\n        new ValueTab(\n            tr.deckConfigDeckOnly(),\n            $limits.review ?? null,\n            (value) => ($limits.review = value ?? undefined),\n            null,\n            null,\n        ),\n        new ValueTab(\n            tr.deckConfigTodayOnly(),\n            $limits.reviewTodayActive ? ($limits.reviewToday ?? null) : null,\n            (value) => ($limits.reviewToday = value ?? undefined),\n            null,\n            $limits.reviewToday ?? null,\n        ),\n    ];\n\n    let newValue = 0;\n    let reviewsValue = 0;\n\n    const settings = {\n        newLimit: {\n            title: tr.schedulingNewCardsday(),\n            help: tr.deckConfigNewLimitTooltip() + v3Extra,\n            url: HelpPage.DeckOptions.newCardsday,\n        },\n        reviewLimit: {\n            title: tr.schedulingMaximumReviewsday(),\n            help: tr.deckConfigReviewLimitTooltip() + reviewV3Extra,\n            url: HelpPage.DeckOptions.maximumReviewsday,\n        },\n        newCardsIgnoreReviewLimit: {\n            title: tr.deckConfigNewCardsIgnoreReviewLimit(),\n            help: newCardsIgnoreReviewLimitHelp,\n            url: HelpPage.DeckOptions.newCardsday,\n            global: true,\n        },\n        applyAllParentLimits: {\n            title: tr.deckConfigApplyAllParentLimits(),\n            help: applyAllParentLimitsHelp,\n            url: HelpPage.DeckOptions.limitsFromTop,\n            global: true,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigDailyLimits()}>\n    <HelpModal\n        title={tr.deckConfigDailyLimits()}\n        url={HelpPage.DeckOptions.dailyLimits}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SpinBoxRow bind:value={newValue} defaultValue={defaults.newPerDay}>\n                <TabbedValue slot=\"tabs\" tabs={newTabs} bind:value={newValue} />\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"newLimit\"))}\n                >\n                    {settings.newLimit.title}\n                </SettingTitle>\n            </SpinBoxRow>\n        </Item>\n\n        <Item>\n            <SpinBoxRow bind:value={reviewsValue} defaultValue={defaults.reviewsPerDay}>\n                <TabbedValue slot=\"tabs\" tabs={reviewTabs} bind:value={reviewsValue} />\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"reviewLimit\"))}\n                >\n                    {settings.reviewLimit.title}\n                </SettingTitle>\n            </SpinBoxRow>\n        </Item>\n\n        <Item>\n            <Warning warning={reviewsTooLow} />\n        </Item>\n\n        <Item>\n            <SwitchRow bind:value={$newCardsIgnoreReviewLimit} defaultValue={false}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"newCardsIgnoreReviewLimit\"),\n                        )}\n                >\n                    <GlobalLabel title={settings.newCardsIgnoreReviewLimit.title} />\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        <Item>\n            <SwitchRow bind:value={$applyAllParentLimits} defaultValue={false}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"applyAllParentLimits\"),\n                        )}\n                >\n                    <GlobalLabel title={settings.applyAllParentLimits.title} />\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/DateInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script>\n    import Col from \"$lib/components/Col.svelte\";\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n\n    export let date;\n    $: date = date ? date : \"1970-01-01\";\n</script>\n\n<div>\n    <ConfigInput>\n        <Row --cols={13}>\n            <Col --col-size={7} breakpoint=\"xs\">\n                <slot />\n            </Col>\n            <Col --col-size={6} breakpoint=\"xs\">\n                <input bind:value={date} type=\"date\" />\n            </Col>\n        </Row>\n    </ConfigInput>\n</div>\n\n<style>\n    input {\n        width: 100%;\n        -webkit-appearance: none;\n        appearance: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/DeckOptionsPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { Writable } from \"svelte/store\";\n\n    import \"$lib/sveltelib/export-runtime\";\n\n    import Container from \"$lib/components/Container.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import type { DynamicSvelteComponent } from \"$lib/sveltelib/dynamicComponent\";\n\n    import Addons from \"./Addons.svelte\";\n    import AdvancedOptions from \"./AdvancedOptions.svelte\";\n    import AudioOptions from \"./AudioOptions.svelte\";\n    import AutoAdvance from \"./AutoAdvance.svelte\";\n    import BuryOptions from \"./BuryOptions.svelte\";\n    import ConfigSelector from \"./ConfigSelector.svelte\";\n    import DailyLimits from \"./DailyLimits.svelte\";\n    import DisplayOrder from \"./DisplayOrder.svelte\";\n    import FsrsOptionsOuter from \"./FsrsOptionsOuter.svelte\";\n    import HtmlAddon from \"./HtmlAddon.svelte\";\n    import LapseOptions from \"./LapseOptions.svelte\";\n    import type { DeckOptionsState } from \"./lib\";\n    import NewOptions from \"./NewOptions.svelte\";\n    import TimerOptions from \"./TimerOptions.svelte\";\n    import EasyDays from \"./EasyDays.svelte\";\n\n    export let state: DeckOptionsState;\n    const addons = state.addonComponents;\n\n    export function auxData(): Writable<Record<string, unknown>> {\n        return state.currentAuxData;\n    }\n\n    export function addSvelteAddon(component: DynamicSvelteComponent): void {\n        $addons = [...$addons, component];\n    }\n\n    export function addHtmlAddon(html: string, mounted: () => void): void {\n        $addons = [\n            ...$addons,\n            {\n                component: HtmlAddon,\n                html,\n                mounted,\n            },\n        ];\n    }\n\n    export const options = {};\n    export const dailyLimits = {};\n    export const newOptions = {};\n    export const lapseOptions = {};\n    export const buryOptions = {};\n    export const displayOrder = {};\n    export const timerOptions = {};\n    export const audioOptions = {};\n    export const advancedOptions = {};\n    export const easyDays = {};\n\n    let onPresetChange: () => void;\n</script>\n\n<ConfigSelector {state} on:presetchange={onPresetChange} />\n\n<div class=\"deck-options-page\">\n    <Container\n        breakpoint=\"sm\"\n        --gutter-inline=\"0.25rem\"\n        --gutter-block=\"0.75rem\"\n        class=\"container-columns\"\n    >\n        <div>\n            <Row class=\"row-columns\">\n                <DailyLimits {state} api={dailyLimits} bind:onPresetChange />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <NewOptions {state} api={newOptions} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <LapseOptions {state} api={lapseOptions} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <DisplayOrder {state} api={displayOrder} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <FsrsOptionsOuter {state} api={{}} bind:onPresetChange />\n            </Row>\n        </div>\n\n        <div>\n            <Row class=\"row-columns\">\n                <BuryOptions {state} api={buryOptions} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <AudioOptions {state} api={audioOptions} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <TimerOptions {state} api={timerOptions} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <AutoAdvance {state} api={timerOptions} />\n            </Row>\n\n            {#if $addons.length}\n                <Row class=\"row-columns\">\n                    <Addons {state} />\n                </Row>\n            {/if}\n\n            <Row class=\"row-columns\">\n                <EasyDays {state} api={easyDays} />\n            </Row>\n\n            <Row class=\"row-columns\">\n                <AdvancedOptions {state} api={advancedOptions} />\n            </Row>\n        </div>\n    </Container>\n</div>\n\n<style lang=\"scss\">\n    @use \"$lib/sass/breakpoints\" as bp;\n\n    .deck-options-page {\n        overflow-x: auto;\n\n        :global(.container-columns) {\n            display: grid;\n            gap: 0px;\n        }\n\n        @include bp.with-breakpoint(\"lg\") {\n            :global(.container-columns) {\n                grid-template-columns: repeat(2, 1fr);\n                gap: 20px;\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/DisplayOrder.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import {\n        DeckConfig_Config_NewCardGatherPriority as GatherOrder,\n        DeckConfig_Config_NewCardSortOrder as SortOrder,\n    } from \"@generated/anki/deck_config_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import {\n        newGatherPriorityChoices,\n        newSortOrderChoices,\n        reviewMixChoices,\n        reviewOrderChoices,\n    } from \"./choices\";\n    import type { DeckOptionsState } from \"./lib\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const fsrs = state.fsrs;\n\n    const currentDeck = \"\\n\\n\" + tr.deckConfigDisplayOrderWillUseCurrentDeck();\n\n    let disabledNewSortOrders: number[] = [];\n    $: {\n        switch ($config.newCardGatherPriority) {\n            case GatherOrder.RANDOM_NOTES:\n                disabledNewSortOrders = [\n                    // same as TEMPLATE\n                    SortOrder.TEMPLATE_THEN_RANDOM,\n                    // same as NO_SORT\n                    SortOrder.RANDOM_NOTE_THEN_TEMPLATE,\n                ];\n                break;\n            case GatherOrder.RANDOM_CARDS:\n                disabledNewSortOrders = [\n                    // same as TEMPLATE\n                    SortOrder.TEMPLATE_THEN_RANDOM,\n                    // not useful if siblings are not gathered together\n                    SortOrder.RANDOM_NOTE_THEN_TEMPLATE,\n                    // same as NO_SORT\n                    SortOrder.RANDOM_CARD,\n                ];\n                break;\n            default:\n                disabledNewSortOrders = [];\n                break;\n        }\n\n        // disabled options aren't deselected automatically\n        if (disabledNewSortOrders.includes($config.newCardSortOrder)) {\n            // default option should never be disabled\n            $config.newCardSortOrder = 0;\n        }\n\n        // check for invalid index from previous version\n        if (!($config.newCardSortOrder in SortOrder)) {\n            $config.newCardSortOrder = 0;\n        }\n    }\n\n    const settings = {\n        newGatherPriority: {\n            title: tr.deckConfigNewGatherPriority(),\n            help: tr.deckConfigNewGatherPriorityTooltip2() + currentDeck,\n        },\n        newCardSortOrder: {\n            title: tr.deckConfigNewCardSortOrder(),\n            help: tr.deckConfigNewCardSortOrderTooltip2() + currentDeck,\n        },\n        newReviewPriority: {\n            title: tr.deckConfigNewReviewPriority(),\n            help: tr.deckConfigNewReviewPriorityTooltip() + currentDeck,\n        },\n        interdayStepPriority: {\n            title: tr.deckConfigInterdayStepPriority(),\n            help: tr.deckConfigInterdayStepPriorityTooltip() + currentDeck,\n        },\n        reviewSortOrder: {\n            title: tr.deckConfigReviewSortOrder(),\n            help: tr.deckConfigReviewSortOrderTooltip() + currentDeck,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigOrderingTitle()}>\n    <HelpModal\n        title={tr.deckConfigOrderingTitle()}\n        url={HelpPage.DeckOptions.displayOrder}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.newCardGatherPriority}\n                defaultValue={defaults.newCardGatherPriority}\n                choices={newGatherPriorityChoices()}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"newGatherPriority\"),\n                        )}\n                >\n                    {settings.newGatherPriority.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.newCardSortOrder}\n                defaultValue={defaults.newCardSortOrder}\n                choices={newSortOrderChoices()}\n                disabledChoices={disabledNewSortOrders}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"newCardSortOrder\"),\n                        )}\n                >\n                    {settings.newCardSortOrder.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.newMix}\n                defaultValue={defaults.newMix}\n                choices={reviewMixChoices()}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"newReviewPriority\"),\n                        )}\n                >\n                    {settings.newReviewPriority.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.interdayLearningMix}\n                defaultValue={defaults.interdayLearningMix}\n                choices={reviewMixChoices()}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"interdayStepPriority\"),\n                        )}\n                >\n                    {settings.interdayStepPriority.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.reviewOrder}\n                defaultValue={defaults.reviewOrder}\n                choices={reviewOrderChoices($fsrs)}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"reviewSortOrder\"))}\n                >\n                    {settings.reviewSortOrder.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/EasyDays.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { DeckOptionsState } from \"./lib\";\n    import Warning from \"./Warning.svelte\";\n    import EasyDaysInput from \"./EasyDaysInput.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const fsrsEnabled = state.fsrs;\n    const reschedule = state.fsrsReschedule;\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const prevEasyDaysPercentages = $config.easyDaysPercentages.slice();\n\n    $: if ($config.easyDaysPercentages.length !== 7) {\n        $config.easyDaysPercentages = defaults.easyDaysPercentages.slice();\n    }\n\n    $: easyDaysChanged = $config.easyDaysPercentages.some(\n        (value, index) => value !== prevEasyDaysPercentages[index],\n    );\n\n    $: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0)\n        ? \"\"\n        : tr.deckConfigEasyDaysNoNormalDays();\n\n    $: rescheduleWarning =\n        easyDaysChanged && !($fsrsEnabled && $reschedule)\n            ? tr.deckConfigEasyDaysChange()\n            : \"\";\n</script>\n\n<datalist id=\"easy_day_steplist\">\n    <option>0.5</option>\n</datalist>\n\n<TitledContainer title={tr.deckConfigEasyDaysTitle()}>\n    <DynamicallySlottable slotHost={Item} {api}>\n        <EasyDaysInput bind:values={$config.easyDaysPercentages} />\n        <Item>\n            <Warning warning={noNormalDay} />\n        </Item>\n        <Item>\n            <Warning warning={rescheduleWarning} />\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/EasyDaysInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script>\n    import * as tr from \"@generated/ftl\";\n    import Item from \"$lib/components/Item.svelte\";\n\n    const easyDays = [\n        tr.deckConfigEasyDaysMonday(),\n        tr.deckConfigEasyDaysTuesday(),\n        tr.deckConfigEasyDaysWednesday(),\n        tr.deckConfigEasyDaysThursday(),\n        tr.deckConfigEasyDaysFriday(),\n        tr.deckConfigEasyDaysSaturday(),\n        tr.deckConfigEasyDaysSunday(),\n    ];\n\n    export let values = [0, 0, 0, 0, 0, 0, 0];\n</script>\n\n<Item>\n    <div class=\"container\">\n        <div class=\"easy-days-settings\">\n            <span></span>\n            <span class=\"header min-col\">{tr.deckConfigEasyDaysMinimum()}</span>\n            <span class=\"header\">{tr.deckConfigEasyDaysReduced()}</span>\n            <span class=\"header normal-col\">{tr.deckConfigEasyDaysNormal()}</span>\n\n            {#each easyDays as day, index}\n                <span class=\"day\">{day}</span>\n                <div class=\"input-container\">\n                    <input\n                        type=\"range\"\n                        bind:value={values[index]}\n                        step={0.5}\n                        max={1.0}\n                        min={0.0}\n                        list=\"easy_day_steplist\"\n                    />\n                </div>\n            {/each}\n        </div>\n    </div>\n</Item>\n\n<style lang=\"scss\">\n    .container {\n        display: flex;\n        justify-content: center;\n    }\n    .easy-days-settings {\n        width: 100%;\n        max-width: 1000px;\n        border-collapse: collapse;\n\n        display: grid;\n        grid-template-columns: auto 1fr 1fr 1fr;\n\n        border-collapse: collapse;\n        & > * {\n            padding: 8px 16px;\n            border-bottom: var(--border) solid 1px;\n        }\n    }\n    .input-container {\n        grid-column: 2 / span 3;\n    }\n    span {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        &.min-col {\n            justify-content: flex-start;\n        }\n\n        &.normal-col {\n            justify-content: flex-end;\n        }\n    }\n    .header {\n        word-wrap: break-word;\n        font-size: smaller;\n    }\n    .easy-days-settings input[type=\"range\"] {\n        width: 100%;\n        cursor: pointer;\n    }\n\n    .day {\n        word-wrap: break-word;\n        font-size: smaller;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/FsrsOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import {\n        ComputeRetentionProgress,\n        type ComputeParamsProgress,\n    } from \"@generated/anki/collection_pb\";\n    import { SimulateFsrsReviewRequest } from \"@generated/anki/scheduler_pb\";\n    import {\n        computeFsrsParams,\n        evaluateParamsLegacy,\n        getRetentionWorkload,\n        setWantsAbort,\n    } from \"@generated/backend\";\n    import * as tr from \"@generated/ftl\";\n    import { runWithBackendProgress } from \"@tslib/progress\";\n\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n\n    import GlobalLabel from \"./GlobalLabel.svelte\";\n    import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from \"./lib\";\n    import SpinBoxFloatRow from \"./SpinBoxFloatRow.svelte\";\n    import Warning from \"./Warning.svelte\";\n    import ParamsInputRow from \"./ParamsInputRow.svelte\";\n    import ParamsSearchRow from \"./ParamsSearchRow.svelte\";\n    import SimulatorModal from \"./SimulatorModal.svelte\";\n    import {\n        GetRetentionWorkloadRequest,\n        type GetRetentionWorkloadResponse,\n        UpdateDeckConfigsMode,\n    } from \"@generated/anki/deck_config_pb\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n    import TabbedValue from \"./TabbedValue.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n\n    export let state: DeckOptionsState;\n    export let openHelpModal: (String) => void;\n    export let onPresetChange: () => void;\n    export let newlyEnabled = false;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const fsrsReschedule = state.fsrsReschedule;\n    const daysSinceLastOptimization = state.daysSinceLastOptimization;\n    const limits = state.deckLimits;\n\n    $: lastOptimizationWarning =\n        $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : \"\";\n    let desiredRetentionFocused = false;\n    let desiredRetentionEverFocused = false;\n    let optimized = false;\n    const initialParams = [...fsrsParams($config)];\n    $: if (desiredRetentionFocused) {\n        desiredRetentionEverFocused = true;\n    }\n    $: showDesiredRetentionTooltip =\n        newlyEnabled || desiredRetentionEverFocused || optimized;\n\n    let computeParamsProgress: ComputeParamsProgress | undefined;\n    let computingParams = false;\n    let checkingParams = false;\n\n    const healthCheck = state.fsrsHealthCheck;\n\n    $: computing = computingParams || checkingParams;\n    $: defaultparamSearch = `preset:\"${state.getCurrentNameForSearch()}\" -is:suspended`;\n    $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2));\n    $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);\n\n    let desiredRetentionChangeInfo = \"\";\n    $: if (showDesiredRetentionTooltip) {\n        getRetentionChangeInfo(roundedRetention, fsrsParams($config));\n    }\n\n    $: retentionWarningClass = getRetentionWarningClass(roundedRetention);\n\n    $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;\n\n    // Create tabs for desired retention\n    const desiredRetentionTabs: ValueTab[] = [\n        new ValueTab(\n            tr.deckConfigSharedPreset(),\n            $config.desiredRetention,\n            (value) => ($config.desiredRetention = value!),\n            $config.desiredRetention,\n            null,\n        ),\n        new ValueTab(\n            tr.deckConfigDeckOnly(),\n            $limits.desiredRetention ?? null,\n            (value) => ($limits.desiredRetention = value ?? undefined),\n            null,\n            null,\n        ),\n    ];\n\n    // Get the effective desired retention value (deck-specific if set, otherwise config default)\n    let effectiveDesiredRetention =\n        $limits.desiredRetention ?? $config.desiredRetention;\n    const startingDesiredRetention = effectiveDesiredRetention.toFixed(2);\n\n    $: simulateFsrsRequest = new SimulateFsrsReviewRequest({\n        params: fsrsParams($config),\n        desiredRetention: $config.desiredRetention,\n        newLimit: $config.newPerDay,\n        reviewLimit: $config.reviewsPerDay,\n        maxInterval: $config.maximumReviewInterval,\n        search: `preset:\"${state.getCurrentNameForSearch()}\" -is:suspended`,\n        newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit,\n        easyDaysPercentages: $config.easyDaysPercentages,\n        reviewOrder: $config.reviewOrder,\n        historicalRetention: $config.historicalRetention,\n        learningStepCount: $config.learnSteps.length,\n        relearningStepCount: $config.relearnSteps.length,\n    });\n\n    const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;\n    const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;\n\n    function getRetentionLongShortWarning(retention: number) {\n        if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {\n            return tr.deckConfigDesiredRetentionTooLow();\n        } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {\n            return tr.deckConfigDesiredRetentionTooHigh();\n        } else {\n            return \"\";\n        }\n    }\n\n    let retentionWorkloadInfo: undefined | Promise<GetRetentionWorkloadResponse> =\n        undefined;\n    let lastParams = [...fsrsParams($config)];\n\n    async function getRetentionChangeInfo(retention: number, params: number[]) {\n        if (+startingDesiredRetention == roundedRetention) {\n            desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged();\n            return;\n        }\n        if (\n            // If the cache is empty and a request has not yet been made to fill it\n            !retentionWorkloadInfo ||\n            // If the parameters have been changed\n            lastParams.toString() !== params.toString()\n        ) {\n            const request = new GetRetentionWorkloadRequest({\n                w: params,\n                search: defaultparamSearch,\n            });\n            lastParams = [...params];\n            retentionWorkloadInfo = getRetentionWorkload(request);\n        }\n\n        const previous = +startingDesiredRetention * 100;\n        const after = retention * 100;\n        const resp = await retentionWorkloadInfo;\n        const factor = resp.costs[after] / resp.costs[previous];\n\n        desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({\n            factor: factor.toFixed(2),\n            previousDr: previous.toString(),\n        });\n    }\n\n    function getRetentionWarningClass(retention: number): string {\n        if (retention < 0.7 || retention > 0.97) {\n            return \"alert-danger\";\n        } else if (\n            retention < DESIRED_RETENTION_LOW_THRESHOLD ||\n            retention > DESIRED_RETENTION_HIGH_THRESHOLD\n        ) {\n            return \"alert-warning\";\n        } else {\n            return \"alert-info\";\n        }\n    }\n\n    function getIgnoreRevlogsBeforeMs() {\n        return BigInt(\n            $config.ignoreRevlogsBeforeDate\n                ? new Date($config.ignoreRevlogsBeforeDate).getTime()\n                : 0,\n        );\n    }\n\n    async function computeParams(): Promise<void> {\n        if (computingParams) {\n            await setWantsAbort({});\n            return;\n        }\n        if (state.presetAssignmentsChanged()) {\n            alert(tr.deckConfigPleaseSaveYourChangesFirst());\n            return;\n        }\n        computingParams = true;\n        computeParamsProgress = undefined;\n        try {\n            await runWithBackendProgress(\n                async () => {\n                    const params = fsrsParams($config);\n                    const RelearningSteps = $config.relearnSteps;\n                    let numOfRelearningStepsInDay = 0;\n                    let accumulatedTime = 0;\n                    for (let i = 0; i < RelearningSteps.length; i++) {\n                        accumulatedTime += RelearningSteps[i];\n                        if (accumulatedTime >= 1440) {\n                            break;\n                        }\n                        numOfRelearningStepsInDay++;\n                    }\n                    const resp = await computeFsrsParams({\n                        search: $config.paramSearch\n                            ? $config.paramSearch\n                            : defaultparamSearch,\n                        ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),\n                        currentParams: params,\n                        numOfRelearningSteps: numOfRelearningStepsInDay,\n                        healthCheck: $healthCheck,\n                    });\n\n                    const alreadyOptimal =\n                        (params.length &&\n                            params.every(\n                                (n, i) => n.toFixed(4) === resp.params[i].toFixed(4),\n                            )) ||\n                        resp.params.length === 0;\n\n                    let healthCheckMessage = \"\";\n                    if (resp.healthCheckPassed !== undefined) {\n                        healthCheckMessage = resp.healthCheckPassed\n                            ? tr.deckConfigFsrsGoodFit()\n                            : tr.deckConfigFsrsBadFitWarning();\n                    }\n                    let alreadyOptimalMessage = \"\";\n                    if (alreadyOptimal) {\n                        alreadyOptimalMessage = resp.fsrsItems\n                            ? tr.deckConfigFsrsParamsOptimal()\n                            : tr.deckConfigFsrsParamsNoReviews();\n                    }\n                    const message = [alreadyOptimalMessage, healthCheckMessage]\n                        .filter((a) => a)\n                        .join(\"\\n\\n\");\n\n                    if (message) {\n                        setTimeout(() => alert(message), 200);\n                    }\n\n                    if (!alreadyOptimal) {\n                        $config.fsrsParams6 = resp.params;\n                        setTimeout(() => {\n                            optimized = true;\n                        }, 201);\n                    }\n                    if (computeParamsProgress) {\n                        computeParamsProgress.current = computeParamsProgress.total;\n                    }\n                },\n                (progress) => {\n                    if (progress.value.case === \"computeParams\") {\n                        computeParamsProgress = progress.value.value;\n                    }\n                },\n            );\n        } finally {\n            computingParams = false;\n        }\n    }\n\n    async function checkParams(): Promise<void> {\n        if (checkingParams) {\n            await setWantsAbort({});\n            return;\n        }\n        if (state.presetAssignmentsChanged()) {\n            alert(tr.deckConfigPleaseSaveYourChangesFirst());\n            return;\n        }\n        checkingParams = true;\n        computeParamsProgress = undefined;\n        try {\n            await runWithBackendProgress(\n                async () => {\n                    const search = $config.paramSearch\n                        ? $config.paramSearch\n                        : defaultparamSearch;\n                    const resp = await evaluateParamsLegacy({\n                        search,\n                        ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),\n                        params: fsrsParams($config),\n                    });\n                    if (computeParamsProgress) {\n                        computeParamsProgress.current = computeParamsProgress.total;\n                    }\n                    setTimeout(\n                        () =>\n                            alert(\n                                `Log loss: ${resp.logLoss.toFixed(4)}, RMSE(bins): ${(\n                                    resp.rmseBins * 100\n                                ).toFixed(2)}%. ${tr.deckConfigSmallerIsBetter()}`,\n                            ),\n                        200,\n                    );\n                },\n                (progress) => {\n                    if (progress.value.case === \"computeParams\") {\n                        computeParamsProgress = progress.value.value;\n                    }\n                },\n            );\n        } finally {\n            checkingParams = false;\n        }\n    }\n\n    $: computeParamsProgressString = renderWeightProgress(computeParamsProgress);\n    $: totalReviews = computeParamsProgress?.reviews ?? undefined;\n\n    function renderWeightProgress(val: ComputeParamsProgress | undefined): String {\n        if (!val || !val.total) {\n            return \"\";\n        }\n        const pct = ((val.current / val.total) * 100).toFixed(1);\n        if (val instanceof ComputeRetentionProgress) {\n            return `${pct}%`;\n        } else {\n            if (val.current === val.total) {\n                return tr.deckConfigCheckingForImprovement();\n            } else {\n                return tr.deckConfigPercentOfReviews({ pct, reviews: val.reviews });\n            }\n        }\n    }\n\n    async function computeAllParams(): Promise<void> {\n        await commitEditing();\n        state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);\n    }\n\n    function showSimulatorModal(modal: Modal) {\n        if (fsrsParams($config).toString() === initialParams.toString()) {\n            modal?.show();\n        } else {\n            alert(tr.deckConfigFsrsSimulateSavePreset());\n        }\n    }\n\n    let simulatorModal: Modal;\n    let workloadModal: Modal;\n</script>\n\n<DynamicallySlottable slotHost={Item} api={{}}>\n    <Item>\n        <SpinBoxFloatRow\n            bind:value={effectiveDesiredRetention}\n            defaultValue={defaults.desiredRetention}\n            min={0.7}\n            max={0.99}\n            percentage={true}\n            bind:focused={desiredRetentionFocused}\n        >\n            <TabbedValue\n                slot=\"tabs\"\n                tabs={desiredRetentionTabs}\n                bind:value={effectiveDesiredRetention}\n            />\n            <SettingTitle on:click={() => openHelpModal(\"desiredRetention\")}>\n                {tr.deckConfigDesiredRetention()}\n            </SettingTitle>\n        </SpinBoxFloatRow>\n    </Item>\n</DynamicallySlottable>\n\n<button\n    class=\"btn btn-primary\"\n    on:click={() => {\n        simulateFsrsRequest.reviewLimit = 9999;\n        showSimulatorModal(workloadModal);\n    }}\n>\n    {tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}\n</button>\n\n<Warning warning={desiredRetentionChangeInfo} className={\"alert-info two-line\"} />\n<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />\n\n<div class=\"ms-1 me-1\">\n    <ParamsInputRow bind:value={$config.fsrsParams6} defaultValue={[]}>\n        <SettingTitle on:click={() => openHelpModal(\"modelParams\")}>\n            {tr.deckConfigWeights()}\n        </SettingTitle>\n    </ParamsInputRow>\n\n    <ParamsSearchRow\n        bind:value={$config.paramSearch}\n        placeholder={defaultparamSearch}\n    />\n\n    <SwitchRow bind:value={$fsrsReschedule} defaultValue={false}>\n        <SettingTitle on:click={() => openHelpModal(\"rescheduleCardsOnChange\")}>\n            <GlobalLabel title={tr.deckConfigRescheduleCardsOnChange()} />\n        </SettingTitle>\n    </SwitchRow>\n\n    {#if $fsrsReschedule}\n        <Warning warning={tr.deckConfigRescheduleCardsWarning()} />\n    {/if}\n\n    <SwitchRow bind:value={$healthCheck} defaultValue={false}>\n        <SettingTitle on:click={() => openHelpModal(\"healthCheck\")}>\n            <GlobalLabel\n                title={tr.deckConfigSlowSuffix({ text: tr.deckConfigHealthCheck() })}\n            />\n        </SettingTitle>\n    </SwitchRow>\n\n    <button\n        class=\"btn {computingParams ? 'btn-warning' : 'btn-primary'}\"\n        disabled={!computingParams && computing}\n        on:click={() => computeParams()}\n    >\n        {#if computingParams}\n            {tr.actionsCancel()}\n        {:else}\n            {tr.deckConfigOptimizeButton()}\n        {/if}\n    </button>\n    {#if state.legacyEvaluate}\n        <button\n            class=\"btn {checkingParams ? 'btn-warning' : 'btn-primary'}\"\n            disabled={!checkingParams && computing}\n            on:click={() => checkParams()}\n        >\n            {#if checkingParams}\n                {tr.actionsCancel()}\n            {:else}\n                {tr.deckConfigEvaluateButton()}\n            {/if}\n        </button>\n    {/if}\n    <div>\n        {#if computingParams || checkingParams}\n            {computeParamsProgressString}\n        {:else if totalReviews !== undefined}\n            {tr.statisticsReviews({ reviews: totalReviews })}\n        {/if}\n    </div>\n</div>\n\n<div class=\"m-1\">\n    <Warning warning={lastOptimizationWarning} className=\"alert-warning\" />\n\n    <button class=\"btn btn-primary\" on:click={() => computeAllParams()}>\n        {tr.deckConfigSaveAndOptimize()}\n    </button>\n</div>\n\n<hr />\n\n<div class=\"m-1\">\n    <button class=\"btn btn-primary\" on:click={() => showSimulatorModal(simulatorModal)}>\n        {tr.deckConfigFsrsSimulatorExperimental()}\n    </button>\n</div>\n\n<SimulatorModal\n    bind:modal={simulatorModal}\n    {state}\n    {simulateFsrsRequest}\n    {computing}\n    {openHelpModal}\n    {onPresetChange}\n/>\n\n<SimulatorModal\n    bind:modal={workloadModal}\n    workload\n    {state}\n    {simulateFsrsRequest}\n    {computing}\n    {openHelpModal}\n    {onPresetChange}\n/>\n\n<style>\n    .btn {\n        margin-bottom: 0.375rem;\n    }\n\n    :global(.two-line) {\n        white-space: pre-wrap;\n        min-height: calc(2ch + 30px);\n        box-sizing: content-box;\n        display: flex;\n        align-content: center;\n        flex-wrap: wrap;\n    }\n\n    hr {\n        border-top: 1px solid var(--border);\n        opacity: 1;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/FsrsOptionsOuter.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import { type HelpItem, HelpItemScheduler } from \"$lib/components/types\";\n\n    import FsrsOptions from \"./FsrsOptions.svelte\";\n    import GlobalLabel from \"./GlobalLabel.svelte\";\n    import type { DeckOptionsState } from \"./lib\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n    export let onPresetChange: () => void;\n\n    const fsrs = state.fsrs;\n    let newlyEnabled = false;\n    $: if (!$fsrs) {\n        newlyEnabled = true;\n    }\n\n    const settings = {\n        fsrs: {\n            title: \"FSRS\",\n            help: tr.deckConfigFsrsTooltip(),\n            url: HelpPage.DeckOptions.fsrs,\n            global: true,\n        },\n        desiredRetention: {\n            title: tr.deckConfigDesiredRetention(),\n            help:\n                tr.deckConfigDesiredRetentionTooltip() +\n                \"\\n\\n\" +\n                tr.deckConfigDesiredRetentionTooltip2(),\n            sched: HelpItemScheduler.FSRS,\n        },\n        modelParams: {\n            title: tr.deckConfigWeights(),\n            help:\n                tr.deckConfigWeightsTooltip2() +\n                \"\\n\\n\" +\n                tr.deckConfigComputeOptimalWeightsTooltip2(),\n            sched: HelpItemScheduler.FSRS,\n        },\n        rescheduleCardsOnChange: {\n            title: tr.deckConfigRescheduleCardsOnChange(),\n            help: tr.deckConfigRescheduleCardsOnChangeTooltip(),\n            sched: HelpItemScheduler.FSRS,\n            global: true,\n        },\n        healthCheck: {\n            title: tr.deckConfigHealthCheck(),\n            help:\n                tr.deckConfigAffectsEntireCollection() +\n                \"\\n\\n\" +\n                tr.deckConfigHealthCheckTooltip1() +\n                \"\\n\\n\" +\n                tr.deckConfigHealthCheckTooltip2(),\n            sched: HelpItemScheduler.FSRS,\n            global: true,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={\"FSRS\"}>\n    <HelpModal\n        title={\"FSRS\"}\n        url={HelpPage.DeckOptions.fsrs}\n        slot=\"tooltip\"\n        fsrs={$fsrs}\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SwitchRow bind:value={$fsrs} defaultValue={false}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"fsrs\"))}\n                >\n                    <GlobalLabel title={settings.fsrs.title} />\n                </SettingTitle>\n            </SwitchRow>\n        </Item>\n\n        {#if $fsrs}\n            <FsrsOptions\n                {state}\n                {newlyEnabled}\n                openHelpModal={(key) =>\n                    openHelpModal(Object.keys(settings).indexOf(key))}\n                {onPresetChange}\n            />\n        {/if}\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/GlobalLabel.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { mdiEarth } from \"$lib/components/icons\";\n    import Icon from \"$lib/components/Icon.svelte\";\n\n    export let title: string;\n</script>\n\n{title}\n<div class=\"inline-icon\" title={tr.deckConfigAffectsEntireCollection()}>\n    <Icon icon={mdiEarth} />\n</div>\n\n<style lang=\"scss\">\n    .inline-icon {\n        display: inline-block;\n        width: 1em;\n        fill: currentColor;\n        margin: 0.25em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/HtmlAddon.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { onMount } from \"svelte\";\n\n    export let html: string;\n    export let mounted: () => void;\n\n    onMount(mounted);\n</script>\n\n{@html html}\n"
  },
  {
    "path": "ts/routes/deck-options/LapseOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import { type HelpItem, HelpItemScheduler } from \"$lib/components/types\";\n\n    import { leechChoices } from \"./choices\";\n    import type { DeckOptionsState } from \"./lib\";\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import StepsInputRow from \"./StepsInputRow.svelte\";\n    import Warning from \"./Warning.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api = {};\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const fsrs = state.fsrs;\n\n    let stepsExceedMinimumInterval: string;\n    let stepsTooLargeForFsrs: string;\n    $: {\n        const lastRelearnStepInDays = $config.relearnSteps.length\n            ? $config.relearnSteps[$config.relearnSteps.length - 1] / 60 / 24\n            : 0;\n        stepsExceedMinimumInterval =\n            !$fsrs && lastRelearnStepInDays > $config.minimumLapseInterval\n                ? tr.deckConfigRelearningStepsAboveMinimumInterval()\n                : \"\";\n        stepsTooLargeForFsrs =\n            $fsrs && lastRelearnStepInDays >= 1\n                ? tr.deckConfigStepsTooLargeForFsrs()\n                : \"\";\n    }\n\n    const settings = {\n        relearningSteps: {\n            title: tr.deckConfigRelearningSteps(),\n            help: tr.deckConfigRelearningStepsTooltip(),\n            url: HelpPage.DeckOptions.relearningSteps,\n        },\n        minimumInterval: {\n            title: tr.schedulingMinimumInterval(),\n            help: tr.deckConfigMinimumIntervalTooltip(),\n            url: HelpPage.DeckOptions.minimumInterval,\n            sched: HelpItemScheduler.SM2,\n        },\n        leechThreshold: {\n            title: tr.schedulingLeechThreshold(),\n            help: tr.deckConfigLeechThresholdTooltip(),\n            url: HelpPage.Leeches.leeches,\n        },\n        leechAction: {\n            title: tr.schedulingLeechAction(),\n            help: tr.deckConfigLeechActionTooltip(),\n            url: HelpPage.Leeches.waiting,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.schedulingLapses()}>\n    <HelpModal\n        title={tr.schedulingLapses()}\n        url={HelpPage.DeckOptions.lapses}\n        slot=\"tooltip\"\n        fsrs={$fsrs}\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <StepsInputRow\n                bind:value={$config.relearnSteps}\n                defaultValue={defaults.relearnSteps}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"relearningSteps\"))}\n                >\n                    {settings.relearningSteps.title}\n                </SettingTitle>\n            </StepsInputRow>\n        </Item>\n\n        <Item>\n            <Warning warning={stepsTooLargeForFsrs} />\n        </Item>\n\n        {#if !$fsrs}\n            <Item>\n                <SpinBoxRow\n                    bind:value={$config.minimumLapseInterval}\n                    defaultValue={defaults.minimumLapseInterval}\n                    min={1}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"minimumInterval\"),\n                            )}\n                    >\n                        {settings.minimumInterval.title}\n                    </SettingTitle>\n                </SpinBoxRow>\n            </Item>\n        {/if}\n\n        <Item>\n            <Warning warning={stepsExceedMinimumInterval} />\n        </Item>\n\n        <Item>\n            <SpinBoxRow\n                bind:value={$config.leechThreshold}\n                defaultValue={defaults.leechThreshold}\n                min={1}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"leechThreshold\"))}\n                >\n                    {settings.leechThreshold.title}\n                </SettingTitle>\n            </SpinBoxRow>\n        </Item>\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.leechAction}\n                defaultValue={defaults.leechAction}\n                choices={leechChoices()}\n                breakpoint=\"md\"\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"leechAction\"))}\n                >\n                    {settings.leechAction.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/NewOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { DeckConfig_Config_NewCardInsertOrder } from \"@generated/anki/deck_config_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import { type HelpItem, HelpItemScheduler } from \"$lib/components/types\";\n\n    import { newInsertOrderChoices } from \"./choices\";\n    import type { DeckOptionsState } from \"./lib\";\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import StepsInputRow from \"./StepsInputRow.svelte\";\n    import Warning from \"./Warning.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api = {};\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n    const fsrs = state.fsrs;\n\n    let stepsExceedGraduatingInterval: string;\n    let stepsTooLargeForFsrs: string;\n    $: {\n        const lastLearnStepInDays = $config.learnSteps.length\n            ? $config.learnSteps[$config.learnSteps.length - 1] / 60 / 24\n            : 0;\n        stepsExceedGraduatingInterval =\n            lastLearnStepInDays > $config.graduatingIntervalGood\n                ? tr.deckConfigLearningStepAboveGraduatingInterval()\n                : \"\";\n        stepsTooLargeForFsrs =\n            $fsrs && lastLearnStepInDays >= 1\n                ? tr.deckConfigStepsTooLargeForFsrs()\n                : \"\";\n    }\n\n    $: goodExceedsEasy =\n        $config.graduatingIntervalGood > $config.graduatingIntervalEasy\n            ? tr.deckConfigGoodAboveEasy()\n            : \"\";\n\n    $: insertionOrderRandom =\n        $config.newCardInsertOrder == DeckConfig_Config_NewCardInsertOrder.RANDOM\n            ? tr.deckConfigNewInsertionOrderRandomWithV3()\n            : \"\";\n\n    const settings = {\n        learningSteps: {\n            title: tr.deckConfigLearningSteps(),\n            help: tr.deckConfigLearningStepsTooltip(),\n            url: HelpPage.DeckOptions.learningSteps,\n        },\n        graduatingInterval: {\n            title: tr.schedulingGraduatingInterval(),\n            help: tr.deckConfigGraduatingIntervalTooltip(),\n            url: HelpPage.DeckOptions.graduatingInterval,\n            sched: HelpItemScheduler.SM2,\n        },\n        easyInterval: {\n            title: tr.schedulingEasyInterval(),\n            help: tr.deckConfigEasyIntervalTooltip(),\n            url: HelpPage.DeckOptions.easyInterval,\n            sched: HelpItemScheduler.SM2,\n        },\n        insertionOrder: {\n            title: tr.deckConfigNewInsertionOrder(),\n            help: tr.deckConfigNewInsertionOrderTooltip(),\n            url: HelpPage.DeckOptions.insertionOrder,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.schedulingNewCards()}>\n    <HelpModal\n        title={tr.schedulingNewCards()}\n        url={HelpPage.DeckOptions.newCards}\n        slot=\"tooltip\"\n        {helpSections}\n        fsrs={$fsrs}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <StepsInputRow\n                bind:value={$config.learnSteps}\n                defaultValue={defaults.learnSteps}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"learningSteps\"))}\n                >\n                    {settings.learningSteps.title}\n                </SettingTitle>\n            </StepsInputRow>\n        </Item>\n\n        <Item>\n            <Warning warning={stepsTooLargeForFsrs} />\n        </Item>\n\n        {#if !$fsrs}\n            <Item>\n                <SpinBoxRow\n                    bind:value={$config.graduatingIntervalGood}\n                    defaultValue={defaults.graduatingIntervalGood}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"graduatingInterval\"),\n                            )}\n                    >\n                        {settings.graduatingInterval.title}\n                    </SettingTitle>\n                </SpinBoxRow>\n            </Item>\n\n            <Item>\n                <Warning warning={stepsExceedGraduatingInterval} />\n            </Item>\n\n            <Item>\n                <SpinBoxRow\n                    bind:value={$config.graduatingIntervalEasy}\n                    defaultValue={defaults.graduatingIntervalEasy}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"easyInterval\"),\n                            )}\n                    >\n                        {settings.easyInterval.title}\n                    </SettingTitle>\n                </SpinBoxRow>\n            </Item>\n\n            <Item>\n                <Warning warning={goodExceedsEasy} />\n            </Item>\n        {/if}\n\n        <Item>\n            <EnumSelectorRow\n                bind:value={$config.newCardInsertOrder}\n                defaultValue={defaults.newCardInsertOrder}\n                choices={newInsertOrderChoices()}\n                breakpoint={\"md\"}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"insertionOrder\"))}\n                >\n                    {settings.insertionOrder.title}\n                </SettingTitle>\n            </EnumSelectorRow>\n        </Item>\n\n        <Item>\n            <Warning warning={insertionOrderRandom} />\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/ParamsInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { tick } from \"svelte\";\n    import * as tr from \"@generated/ftl\";\n    import Warning from \"./Warning.svelte\";\n\n    export let value: number[];\n\n    let stringValue: string;\n    let taRef: HTMLTextAreaElement;\n\n    function updateHeight() {\n        if (taRef) {\n            taRef.style.height = \"auto\";\n            // +2 for \"overflow-y: auto\" in case js breaks\n            taRef.style.height = `${taRef.scrollHeight + 2}px`;\n        }\n    }\n\n    $: {\n        stringValue = render(value);\n        tick().then(updateHeight);\n    }\n\n    function render(params: number[]): string {\n        return params.map((v) => v.toFixed(4)).join(\", \");\n    }\n\n    const validParamCounts = [0, 17, 19, 21];\n\n    function update(e: Event): void {\n        const input = e.target as HTMLTextAreaElement;\n        const newValue = input.value\n            .replace(/ /g, \"\")\n            .split(\",\")\n            .filter((e) => e)\n            .map((v) => Number(v));\n\n        if (validParamCounts.includes(newValue.length)) {\n            value = newValue;\n        } else {\n            alert(tr.deckConfigInvalidParameters());\n            input.value = stringValue;\n        }\n    }\n\n    const UNLOCK_EDIT_COUNT = 3;\n    const UNLOCK_CLICK_TIMEOUT_MS = 500;\n    let clickCount = 0;\n\n    let clickTimeout: ReturnType<typeof setTimeout>;\n\n    function onClick() {\n        clickCount += 1;\n        clearTimeout(clickTimeout);\n        if (clickCount < UNLOCK_EDIT_COUNT) {\n            clickTimeout = setTimeout(() => {\n                clickCount = 0;\n            }, UNLOCK_CLICK_TIMEOUT_MS);\n        } else {\n            taRef.focus();\n        }\n    }\n\n    $: unlocked = clickCount >= UNLOCK_EDIT_COUNT;\n    $: unlockEditWarning = unlocked ? tr.deckConfigManualParameterEditWarning() : \"\";\n</script>\n\n<svelte:window onresize={updateHeight} />\n\n<div\n    on:click={onClick}\n    on:keypress={onClick}\n    role=\"button\"\n    aria-label={\"FSRS Parameters\"}\n    tabindex={unlocked ? -1 : 0}\n>\n    <textarea\n        bind:this={taRef}\n        value={stringValue}\n        on:blur={update}\n        class=\"w-100\"\n        placeholder={tr.deckConfigPlaceholderParameters()}\n        disabled={!unlocked}\n    ></textarea>\n</div>\n\n<Warning warning={unlockEditWarning} className=\"alert-danger\"></Warning>\n\n<style>\n    textarea {\n        resize: none;\n        overflow-y: auto;\n    }\n\n    textarea:disabled {\n        pointer-events: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/ParamsInputRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n\n    import ParamsInput from \"./ParamsInput.svelte\";\n\n    export let value: number[];\n    export let defaultValue: number[];\n</script>\n\n<slot />\n<ConfigInput>\n    <ParamsInput bind:value />\n    <RevertButton slot=\"revert\" bind:value {defaultValue} />\n</ConfigInput>\n"
  },
  {
    "path": "ts/routes/deck-options/ParamsSearchRow.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n\n    export let value: string;\n    export let placeholder: string;\n</script>\n\n<ConfigInput>\n    <input type=\"text\" bind:value {placeholder} class=\"w-100 mb-1\" />\n</ConfigInput>\n"
  },
  {
    "path": "ts/routes/deck-options/SaveButton.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { UpdateDeckConfigsMode } from \"@generated/anki/deck_config_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { withCollapsedWhitespace } from \"@tslib/i18n\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import { createEventDispatcher, tick } from \"svelte\";\n\n    import DropdownDivider from \"$lib/components/DropdownDivider.svelte\";\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { chevronDown } from \"$lib/components/icons\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    import type { DeckOptionsState } from \"./lib\";\n    import { commitEditing } from \"./lib\";\n\n    const rtl: boolean = window.getComputedStyle(document.body).direction == \"rtl\";\n\n    const dispatch = createEventDispatcher();\n\n    export let state: DeckOptionsState;\n\n    async function removeConfig(): Promise<void> {\n        // show pop-up after dropdown has gone away\n        await tick();\n\n        if (state.defaultConfigSelected()) {\n            alert(tr.schedulingTheDefaultConfigurationCantBeRemoved());\n            return;\n        }\n\n        const msg =\n            (state.removalWilLForceFullSync()\n                ? tr.deckConfigWillRequireFullSync() + \" \"\n                : \"\") +\n            tr.deckConfigConfirmRemoveName({ name: state.getCurrentName() });\n\n        if (confirm(withCollapsedWhitespace(msg))) {\n            try {\n                state.removeCurrentConfig();\n                dispatch(\"remove\");\n            } catch (err) {\n                alert(err);\n            }\n        }\n    }\n\n    async function save(mode: UpdateDeckConfigsMode): Promise<void> {\n        await commitEditing();\n        state.save(mode);\n    }\n\n    const saveKeyCombination = \"Control+Enter\";\n\n    let showFloating = false;\n</script>\n\n<div class=\"d-flex\">\n    <LabelButton\n        primary\n        on:click={() => save(UpdateDeckConfigsMode.NORMAL)}\n        tooltip={getPlatformString(saveKeyCombination)}\n        --border-left-radius={!rtl ? \"var(--border-radius)\" : \"0\"}\n        --border-right-radius={rtl ? \"var(--border-radius)\" : \"0\"}\n    >\n        <div class=\"save\">{tr.deckConfigSaveButton()}</div>\n    </LabelButton>\n    <Shortcut\n        keyCombination={saveKeyCombination}\n        on:action={() => save(UpdateDeckConfigsMode.NORMAL)}\n    />\n\n    <WithFloating\n        show={showFloating}\n        closeOnInsideClick\n        inline\n        on:close={() => (showFloating = false)}\n    >\n        <IconButton\n            class=\"chevron\"\n            slot=\"reference\"\n            on:click={() => (showFloating = !showFloating)}\n            --border-right-radius={!rtl ? \"var(--border-radius)\" : \"0\"}\n            --border-left-radius={rtl ? \"var(--border-radius)\" : \"0\"}\n            iconSize={80}\n        >\n            <Icon icon={chevronDown} />\n        </IconButton>\n        <Popover slot=\"floating\">\n            <DropdownItem on:click={() => dispatch(\"add\")}>\n                {tr.deckConfigAddGroup()}\n            </DropdownItem>\n            <DropdownItem on:click={() => dispatch(\"clone\")}>\n                {tr.deckConfigCloneGroup()}\n            </DropdownItem>\n            <DropdownItem on:click={() => dispatch(\"rename\")}>\n                {tr.deckConfigRenameGroup()}\n            </DropdownItem>\n            <DropdownItem on:click={removeConfig}>\n                {tr.deckConfigRemoveGroup()}\n            </DropdownItem>\n            <DropdownDivider />\n            <DropdownItem\n                on:click={() => save(UpdateDeckConfigsMode.APPLY_TO_CHILDREN)}\n            >\n                {tr.deckConfigSaveToAllSubdecks()}\n            </DropdownItem>\n        </Popover>\n    </WithFloating>\n</div>\n\n<style lang=\"scss\">\n    .save {\n        margin: 0 0.75rem;\n    }\n\n    /* Todo: find more elegant fix for misalignment */\n    :global(.chevron) {\n        height: 100% !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/SimulatorModal.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import Graph from \"../graphs/Graph.svelte\";\n    import HoverColumns from \"../graphs/HoverColumns.svelte\";\n    import CumulativeOverlay from \"../graphs/CumulativeOverlay.svelte\";\n    import AxisTicks from \"../graphs/AxisTicks.svelte\";\n    import NoDataOverlay from \"../graphs/NoDataOverlay.svelte\";\n    import TableData from \"../graphs/TableData.svelte\";\n    import InputBox from \"../graphs/InputBox.svelte\";\n    import { defaultGraphBounds, type TableDatum } from \"../graphs/graph-helpers\";\n    import {\n        SimulateSubgraph,\n        SimulateWorkloadSubgraph,\n        type Point,\n        type WorkloadPoint,\n    } from \"../graphs/simulator\";\n    import * as tr from \"@generated/ftl\";\n    import { renderSimulationChart, renderWorkloadChart } from \"../graphs/simulator\";\n    import {\n        computeOptimalRetention,\n        simulateFsrsReview,\n        simulateFsrsWorkload,\n    } from \"@generated/backend\";\n    import { runWithBackendProgress } from \"@tslib/progress\";\n    import type {\n        ComputeOptimalRetentionResponse,\n        SimulateFsrsReviewRequest,\n        SimulateFsrsReviewResponse,\n        SimulateFsrsWorkloadResponse,\n    } from \"@generated/anki/scheduler_pb\";\n    import type { DeckOptionsState } from \"./lib\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import GlobalLabel from \"./GlobalLabel.svelte\";\n    import SpinBoxFloatRow from \"./SpinBoxFloatRow.svelte\";\n    import { reviewOrderChoices } from \"./choices\";\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import { DeckConfig_Config_LeechAction } from \"@generated/anki/deck_config_pb\";\n    import EasyDaysInput from \"./EasyDaysInput.svelte\";\n    import Warning from \"./Warning.svelte\";\n    import type { ComputeRetentionProgress } from \"@generated/anki/collection_pb\";\n    import Modal from \"bootstrap/js/dist/modal\";\n\n    export let state: DeckOptionsState;\n    export let simulateFsrsRequest: SimulateFsrsReviewRequest;\n    export let computing: boolean;\n    export let openHelpModal: (key: string) => void;\n    export let onPresetChange: () => void;\n    /** Do not modify this once set */\n    export let workload: boolean = false;\n\n    const config = state.currentConfig;\n    let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;\n    let simulateWorkloadSubgraph: SimulateWorkloadSubgraph =\n        SimulateWorkloadSubgraph.ratio;\n    let tableData: TableDatum[] = [];\n    let simulating: boolean = false;\n    const fsrs = state.fsrs;\n    const bounds = defaultGraphBounds();\n\n    let svg: HTMLElement | SVGElement | null = null;\n    let simulationNumber = 0;\n    let points: (WorkloadPoint | Point)[] = [];\n    const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;\n    let smooth = true;\n    let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;\n    let leechThreshold = $config.leechThreshold;\n\n    let optimalRetention: null | number = null;\n    let computingRetention = false;\n    let computeRetentionProgress: ComputeRetentionProgress | undefined = undefined;\n\n    $: daysToSimulate = 365;\n    $: deckSize = 0;\n    $: windowSize = Math.ceil(daysToSimulate / 365);\n    $: processing = simulating || computingRetention;\n\n    function movingAverage(y: number[], windowSize: number): number[] {\n        const result: number[] = [];\n        for (let i = 0; i < y.length; i++) {\n            let sum = 0;\n            let count = 0;\n            for (let j = Math.max(0, i - windowSize + 1); j <= i; j++) {\n                sum += y[j];\n                count++;\n            }\n            result.push(sum / count);\n        }\n        return result;\n    }\n\n    function addArrays(arr1: number[], arr2: number[]): number[] {\n        return arr1.map((value, index) => value + arr2[index]);\n    }\n\n    function estimatedRetention(retention: number): String {\n        if (!retention) {\n            return \"\";\n        }\n        return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });\n    }\n\n    function updateRequest() {\n        simulateFsrsRequest.daysToSimulate = daysToSimulate;\n        simulateFsrsRequest.deckSize = deckSize;\n        simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches\n            ? leechThreshold\n            : undefined;\n        simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;\n    }\n\n    function renderRetentionProgress(\n        val: ComputeRetentionProgress | undefined,\n    ): String {\n        if (!val) {\n            return \"\";\n        }\n        return tr.deckConfigIterations({ count: val.current });\n    }\n\n    $: computeRetentionProgressString = renderRetentionProgress(\n        computeRetentionProgress,\n    );\n\n    async function computeRetention() {\n        let resp: ComputeOptimalRetentionResponse | undefined;\n        updateRequest();\n        try {\n            await runWithBackendProgress(\n                async () => {\n                    computingRetention = true;\n                    resp = await computeOptimalRetention(simulateFsrsRequest);\n                },\n                (progress) => {\n                    if (progress.value.case === \"computeRetention\") {\n                        computeRetentionProgress = progress.value.value;\n                    }\n                },\n            );\n        } finally {\n            computingRetention = false;\n            if (resp) {\n                optimalRetention = resp.optimalRetention;\n            }\n        }\n    }\n\n    async function simulateFsrs(): Promise<void> {\n        let resp: SimulateFsrsReviewResponse | undefined;\n        updateRequest();\n        try {\n            await runWithBackendProgress(\n                async () => {\n                    simulating = true;\n                    resp = await simulateFsrsReview(simulateFsrsRequest);\n                },\n                () => {},\n            );\n        } finally {\n            simulating = false;\n            if (resp) {\n                simulationNumber += 1;\n                const dailyTotalCount = addArrays(\n                    resp.dailyReviewCount,\n                    resp.dailyNewCount,\n                );\n\n                const dailyMemorizedCount = resp.accumulatedKnowledgeAcquisition;\n\n                points = points.concat(\n                    resp.dailyTimeCost.map((v, i) => ({\n                        x: i,\n                        timeCost: v,\n                        count: dailyTotalCount[i],\n                        memorized: dailyMemorizedCount[i],\n                        label: simulationNumber,\n                    })),\n                );\n\n                tableData = renderSimulationChart(\n                    svg as SVGElement,\n                    bounds,\n                    points,\n                    simulateSubgraph,\n                );\n            }\n        }\n    }\n\n    async function simulateWorkload(): Promise<void> {\n        let resp: SimulateFsrsWorkloadResponse | undefined;\n        updateRequest();\n        try {\n            await runWithBackendProgress(\n                async () => {\n                    simulating = true;\n                    resp = await simulateFsrsWorkload(simulateFsrsRequest);\n                },\n                () => {},\n            );\n        } finally {\n            simulating = false;\n            if (resp) {\n                simulationNumber += 1;\n\n                points = points.concat(\n                    Object.entries(resp.memorized).map(([dr, v]) => ({\n                        x: parseInt(dr),\n                        timeCost: resp!.cost[dr],\n                        memorized: v,\n                        count: resp!.reviewCount[dr],\n                        label: simulationNumber,\n                        learnSpan: simulateFsrsRequest.daysToSimulate,\n                    })),\n                );\n\n                tableData = renderWorkloadChart(\n                    svg as SVGElement,\n                    bounds,\n                    points as WorkloadPoint[],\n                    simulateWorkloadSubgraph,\n                );\n            }\n        }\n    }\n\n    function clearSimulation() {\n        points = points.filter((p) => p.label !== simulationNumber);\n        simulationNumber = Math.max(0, simulationNumber - 1);\n        tableData = renderSimulationChart(\n            svg as SVGElement,\n            bounds,\n            points,\n            simulateSubgraph,\n        );\n    }\n\n    function saveConfigToPreset() {\n        if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) {\n            $config.newPerDay = simulateFsrsRequest.newLimit;\n            $config.reviewsPerDay = simulateFsrsRequest.reviewLimit;\n            $config.maximumReviewInterval = simulateFsrsRequest.maxInterval;\n            if (!workload) {\n                $config.desiredRetention = simulateFsrsRequest.desiredRetention;\n            }\n            $newCardsIgnoreReviewLimit = simulateFsrsRequest.newCardsIgnoreReviewLimit;\n            $config.reviewOrder = simulateFsrsRequest.reviewOrder;\n            $config.leechAction = suspendLeeches\n                ? DeckConfig_Config_LeechAction.SUSPEND\n                : DeckConfig_Config_LeechAction.TAG_ONLY;\n            $config.leechThreshold = leechThreshold;\n            $config.easyDaysPercentages = [...easyDayPercentages];\n            onPresetChange();\n        }\n    }\n\n    $: if (svg) {\n        let pointsToRender = points;\n        if (smooth) {\n            // Group points by label (simulation number)\n            const groupedPoints = points.reduce(\n                (acc, point) => {\n                    acc[point.label] = acc[point.label] || [];\n                    acc[point.label].push(point);\n                    return acc;\n                },\n                {} as Record<number, Point[]>,\n            );\n\n            // Apply smoothing to each group separately\n            pointsToRender = Object.values(groupedPoints).flatMap((group) => {\n                const smoothedTimeCost = movingAverage(\n                    group.map((p) => p.timeCost),\n                    windowSize,\n                );\n                const smoothedCount = movingAverage(\n                    group.map((p) => p.count),\n                    windowSize,\n                );\n                const smoothedMemorized = movingAverage(\n                    group.map((p) => p.memorized),\n                    windowSize,\n                );\n\n                return group.map((p, i) => ({\n                    ...p,\n                    timeCost: smoothedTimeCost[i],\n                    count: smoothedCount[i],\n                    memorized: smoothedMemorized[i],\n                }));\n            });\n        }\n\n        const render_function = workload ? renderWorkloadChart : renderSimulationChart;\n\n        tableData = render_function(\n            svg as SVGElement,\n            bounds,\n            // This cast shouldn't matter because we aren't switching between modes in the same modal\n            pointsToRender as WorkloadPoint[],\n            (workload ? simulateWorkloadSubgraph : simulateSubgraph) as any as never,\n        );\n    }\n\n    $: easyDayPercentages = [...$config.easyDaysPercentages];\n\n    export let modal: Modal | null = null;\n\n    function setupModal(node: Element) {\n        modal = new Modal(node);\n        return {\n            destroy() {\n                modal?.dispose();\n                modal = null;\n            },\n        };\n    }\n</script>\n\n<div class=\"modal\" tabindex=\"-1\" use:setupModal>\n    <div class=\"modal-dialog modal-xl\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\">\n                    {#if workload}\n                        {tr.deckConfigFsrsSimulateDesiredRetentionExperimental()}\n                    {:else}\n                        {tr.deckConfigFsrsSimulatorExperimental()}\n                    {/if}\n                </h5>\n                <button\n                    type=\"button\"\n                    class=\"btn-close\"\n                    aria-label=\"Close\"\n                    on:click={() => modal?.hide()}\n                ></button>\n            </div>\n            <div class=\"modal-body\">\n                <SpinBoxRow\n                    bind:value={daysToSimulate}\n                    defaultValue={365}\n                    min={1}\n                    max={3650}\n                >\n                    <SettingTitle on:click={() => openHelpModal(\"simulateFsrsReview\")}>\n                        {tr.deckConfigDaysToSimulate()}\n                    </SettingTitle>\n                </SpinBoxRow>\n\n                <SpinBoxRow bind:value={deckSize} defaultValue={0} min={0} max={100000}>\n                    <SettingTitle on:click={() => openHelpModal(\"simulateFsrsReview\")}>\n                        {tr.deckConfigAdditionalNewCardsToSimulate()}\n                    </SettingTitle>\n                </SpinBoxRow>\n\n                {#if !workload}\n                    <SpinBoxFloatRow\n                        bind:value={simulateFsrsRequest.desiredRetention}\n                        defaultValue={$config.desiredRetention}\n                        min={0.7}\n                        max={0.99}\n                        percentage={true}\n                    >\n                        <SettingTitle\n                            on:click={() => openHelpModal(\"desiredRetention\")}\n                        >\n                            {tr.deckConfigDesiredRetention()}\n                        </SettingTitle>\n                    </SpinBoxFloatRow>\n                {/if}\n\n                <SpinBoxRow\n                    bind:value={simulateFsrsRequest.newLimit}\n                    defaultValue={$config.newPerDay}\n                    min={0}\n                    max={9999}\n                >\n                    <SettingTitle on:click={() => openHelpModal(\"newLimit\")}>\n                        {tr.schedulingNewCardsday()}\n                    </SettingTitle>\n                </SpinBoxRow>\n\n                <SpinBoxRow\n                    bind:value={simulateFsrsRequest.reviewLimit}\n                    defaultValue={$config.reviewsPerDay}\n                    min={0}\n                    max={9999}\n                >\n                    <SettingTitle on:click={() => openHelpModal(\"reviewLimit\")}>\n                        {tr.schedulingMaximumReviewsday()}\n                    </SettingTitle>\n                </SpinBoxRow>\n\n                <details>\n                    <summary>{tr.deckConfigEasyDaysTitle()}</summary>\n                    {#key easyDayPercentages}\n                        <EasyDaysInput bind:values={easyDayPercentages} />\n                    {/key}\n                </details>\n\n                <details>\n                    <summary>{tr.deckConfigAdvancedSettings()}</summary>\n                    <SpinBoxRow\n                        bind:value={simulateFsrsRequest.maxInterval}\n                        defaultValue={$config.maximumReviewInterval}\n                        min={1}\n                        max={36500}\n                    >\n                        <SettingTitle on:click={() => openHelpModal(\"maximumInterval\")}>\n                            {tr.schedulingMaximumInterval()}\n                        </SettingTitle>\n                    </SpinBoxRow>\n\n                    <EnumSelectorRow\n                        bind:value={simulateFsrsRequest.reviewOrder}\n                        defaultValue={$config.reviewOrder}\n                        choices={reviewOrderChoices($fsrs)}\n                    >\n                        <SettingTitle on:click={() => openHelpModal(\"reviewSortOrder\")}>\n                            {tr.deckConfigReviewSortOrder()}\n                        </SettingTitle>\n                    </EnumSelectorRow>\n\n                    <SwitchRow\n                        bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit}\n                        defaultValue={$newCardsIgnoreReviewLimit}\n                    >\n                        <SettingTitle\n                            on:click={() => openHelpModal(\"newCardsIgnoreReviewLimit\")}\n                        >\n                            <GlobalLabel\n                                title={tr.deckConfigNewCardsIgnoreReviewLimit()}\n                            />\n                        </SettingTitle>\n                    </SwitchRow>\n\n                    <SwitchRow bind:value={smooth} defaultValue={true}>\n                        <SettingTitle\n                            on:click={() => openHelpModal(\"simulateFsrsReview\")}\n                        >\n                            {tr.deckConfigSmoothGraph()}\n                        </SettingTitle>\n                    </SwitchRow>\n\n                    <SwitchRow\n                        bind:value={suspendLeeches}\n                        defaultValue={$config.leechAction ==\n                            DeckConfig_Config_LeechAction.SUSPEND}\n                    >\n                        <SettingTitle on:click={() => openHelpModal(\"leechAction\")}>\n                            {tr.deckConfigSuspendLeeches()}\n                        </SettingTitle>\n                    </SwitchRow>\n\n                    {#if suspendLeeches}\n                        <SpinBoxRow\n                            bind:value={leechThreshold}\n                            defaultValue={$config.leechThreshold}\n                            min={1}\n                            max={9999}\n                        >\n                            <SettingTitle\n                                on:click={() => openHelpModal(\"leechThreshold\")}\n                            >\n                                {tr.schedulingLeechThreshold()}\n                            </SettingTitle>\n                        </SpinBoxRow>\n                    {/if}\n                </details>\n\n                <div style=\"display:none;\">\n                    <details>\n                        <summary>{tr.deckConfigComputeOptimalRetention()}</summary>\n                        <button\n                            class=\"btn {computingRetention\n                                ? 'btn-warning'\n                                : 'btn-primary'}\"\n                            disabled={!computingRetention && computing}\n                            on:click={() => computeRetention()}\n                        >\n                            {#if computingRetention}\n                                {tr.actionsCancel()}\n                            {:else}\n                                {tr.deckConfigComputeButton()}\n                            {/if}\n                        </button>\n\n                        {#if optimalRetention}\n                            {estimatedRetention(optimalRetention)}\n                            {#if optimalRetention - $config.desiredRetention >= 0.01}\n                                <Warning\n                                    warning={tr.deckConfigDesiredRetentionBelowOptimal()}\n                                    className=\"alert-warning\"\n                                />\n                            {/if}\n                        {/if}\n\n                        {#if computingRetention}\n                            <div>{computeRetentionProgressString}</div>\n                        {/if}\n                    </details>\n                </div>\n\n                <div>\n                    <button\n                        class=\"btn {computing ? 'btn-warning' : 'btn-primary'}\"\n                        disabled={computing}\n                        on:click={workload ? simulateWorkload : simulateFsrs}\n                    >\n                        {tr.deckConfigSimulate()}\n                    </button>\n\n                    <button\n                        class=\"btn {computing ? 'btn-warning' : 'btn-primary'}\"\n                        disabled={computing}\n                        on:click={clearSimulation}\n                    >\n                        {tr.deckConfigClearLastSimulate()}\n                    </button>\n\n                    <button\n                        class=\"btn {computing ? 'btn-warning' : 'btn-primary'}\"\n                        disabled={computing}\n                        on:click={saveConfigToPreset}\n                    >\n                        {tr.deckConfigSaveOptionsToPreset()}\n                    </button>\n\n                    {#if processing}\n                        {tr.actionsProcessing()}\n                    {/if}\n                </div>\n\n                <Graph>\n                    <div class=\"radio-group\">\n                        <InputBox>\n                            {#if !workload}\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateSubgraph.count}\n                                        bind:group={simulateSubgraph}\n                                    />\n                                    {tr.deckConfigFsrsSimulatorRadioCount()}\n                                </label>\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateSubgraph.time}\n                                        bind:group={simulateSubgraph}\n                                    />\n                                    {tr.statisticsReviewsTimeCheckbox()}\n                                </label>\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateSubgraph.memorized}\n                                        bind:group={simulateSubgraph}\n                                    />\n                                    {tr.deckConfigFsrsSimulatorRadioMemorized()}\n                                </label>\n                            {:else}\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateWorkloadSubgraph.ratio}\n                                        bind:group={simulateWorkloadSubgraph}\n                                    />\n                                    {tr.deckConfigFsrsSimulatorRadioRatio()}\n                                </label>\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateWorkloadSubgraph.count}\n                                        bind:group={simulateWorkloadSubgraph}\n                                    />\n                                    {tr.deckConfigFsrsSimulatorRadioCount()}\n                                </label>\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateWorkloadSubgraph.time}\n                                        bind:group={simulateWorkloadSubgraph}\n                                    />\n                                    {tr.statisticsReviewsTimeCheckbox()}\n                                </label>\n                                <label>\n                                    <input\n                                        type=\"radio\"\n                                        value={SimulateWorkloadSubgraph.memorized}\n                                        bind:group={simulateWorkloadSubgraph}\n                                    />\n                                    {tr.deckConfigFsrsSimulatorRadioMemorized()}\n                                </label>\n                            {/if}\n                        </InputBox>\n                    </div>\n\n                    <div class=\"svg-container\">\n                        <svg\n                            bind:this={svg}\n                            viewBox={`0 0 ${bounds.width} ${bounds.height}`}\n                        >\n                            <CumulativeOverlay />\n                            <HoverColumns />\n                            <AxisTicks {bounds} />\n                            <NoDataOverlay {bounds} />\n                        </svg>\n                    </div>\n\n                    <TableData {tableData} />\n                </Graph>\n            </div>\n        </div>\n    </div>\n</div>\n\n<style>\n    .modal {\n        background-color: rgba(0, 0, 0, 0.5);\n        --bs-modal-margin: 0;\n        --bs-modal-border-radius: 0;\n    }\n\n    .svg-container {\n        width: 100%;\n        /* Account for modal header, controls, etc */\n        max-height: max(calc(100vh - 400px), 200px);\n        aspect-ratio: 600 / 250;\n        display: flex;\n        align-items: center;\n    }\n\n    svg {\n        width: 100%;\n        height: 100%;\n    }\n\n    .modal-header {\n        position: sticky;\n        top: 0;\n        background-color: var(--bs-body-bg);\n        z-index: 100;\n    }\n\n    :global(.modal-xl) {\n        max-width: 100vw;\n    }\n\n    div.radio-group {\n        margin: 0.5em;\n    }\n\n    .btn {\n        margin-bottom: 0.375rem;\n    }\n\n    summary {\n        margin-bottom: 0.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/SpinBoxFloatRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"$lib/components/Col.svelte\";\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import SpinBox from \"$lib/components/SpinBox.svelte\";\n\n    export let value: number;\n    export let defaultValue: number;\n    export let min = 0;\n    export let max = 9999;\n    export let step = 0.01;\n    export let percentage = false;\n    export let focused = false;\n</script>\n\n<Row --cols={13}>\n    <Col --col-size={7} breakpoint=\"xs\">\n        <slot />\n    </Col>\n    <Col --col-size={6} breakpoint=\"xs\">\n        <Row class=\"flex-grow-1\">\n            <slot name=\"tabs\" />\n            <ConfigInput>\n                <SpinBox bind:value {min} {max} {step} {percentage} bind:focused />\n                <RevertButton slot=\"revert\" bind:value {defaultValue} />\n            </ConfigInput>\n        </Row>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/routes/deck-options/SpinBoxRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"$lib/components/Col.svelte\";\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import SpinBox from \"$lib/components/SpinBox.svelte\";\n\n    export let value: number;\n    export let defaultValue: number;\n    export let min = 0;\n    export let max = 9999;\n</script>\n\n<Row --cols={13}>\n    <Col --col-size={7} breakpoint=\"xs\">\n        <slot />\n    </Col>\n    <Col --col-size={6} breakpoint=\"xs\">\n        <Row class=\"flex-grow-1\">\n            <slot name=\"tabs\" />\n            <ConfigInput>\n                <SpinBox bind:value {min} {max} />\n                <RevertButton slot=\"revert\" bind:value {defaultValue} />\n            </ConfigInput>\n        </Row>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/routes/deck-options/StepsInput.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { stepsToString, stringToSteps } from \"./steps\";\n\n    export let value: number[];\n\n    let stringValue: string;\n    $: stringValue = stepsToString(value);\n\n    function update(this: HTMLInputElement): void {\n        value = stringToSteps(this.value);\n    }\n</script>\n\n<input type=\"text\" value={stringValue} on:blur={update} />\n\n<style lang=\"scss\">\n    input {\n        width: 100%;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/StepsInputRow.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"$lib/components/Col.svelte\";\n    import ConfigInput from \"$lib/components/ConfigInput.svelte\";\n    import RevertButton from \"$lib/components/RevertButton.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n\n    import StepsInput from \"./StepsInput.svelte\";\n\n    export let value: any;\n    export let defaultValue: any;\n</script>\n\n<Row --cols={13}>\n    <Col --col-size={7} breakpoint=\"xs\">\n        <slot />\n    </Col>\n    <Col --col-size={6} breakpoint=\"xs\">\n        <ConfigInput>\n            <StepsInput bind:value />\n            <RevertButton slot=\"revert\" bind:value {defaultValue} />\n        </ConfigInput>\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/routes/deck-options/TabbedValue.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    /* This component accepts an array of tabs and a value. Whenever a tab is\n    activated, its last used value is applied to its provided setter and the\n    component's value. Whenever it's deactivated, its setter is called with its\n    disabledValue. */\n    import type { ValueTab } from \"./lib\";\n\n    export let tabs: ValueTab[];\n    export let value: number;\n\n    let activeTab = lastSetTab();\n    $: onTabChanged(activeTab);\n    $: value = tabs[activeTab].value ?? 0;\n    $: tabs[activeTab].setValue(value);\n\n    function lastSetTab(): number {\n        const revIdx = tabs\n            .slice()\n            .reverse()\n            .findIndex((tab) => tab.value !== null);\n        return revIdx === -1 ? 0 : tabs.length - revIdx - 1;\n    }\n\n    function onTabChanged(newTab: number) {\n        for (const [idx, tab] of tabs.entries()) {\n            if (newTab === idx) {\n                tab.enable(value);\n            } else if (newTab > idx) {\n                /* antecedent tabs are obscured, so we can preserve their original values */\n                tab.reset();\n            } else {\n                /* but subsequent tabs would obscure, so they must be nulled */\n                tab.disable();\n            }\n        }\n    }\n\n    const handleClick = (tabValue: number) => () => (activeTab = tabValue);\n</script>\n\n<ul>\n    {#each tabs as tab, idx}\n        <li class:active={activeTab === idx}>\n            <button on:click={handleClick(idx)}>{tab.title}</button>\n        </li>\n    {/each}\n</ul>\n\n<style lang=\"scss\">\n    ul {\n        width: 100%;\n        display: flex;\n        flex-wrap: nowrap;\n        &:has(li:nth-child(3)) {\n            justify-content: space-between;\n        }\n        justify-content: space-around;\n        padding-inline: 0;\n        margin-bottom: 0.5rem;\n        list-style: none;\n    }\n\n    button {\n        display: block;\n        white-space: nowrap;\n        cursor: pointer;\n        color: var(--fg-subtle);\n        border: 1px solid transparent;\n        background-color: transparent;\n        /* remove default macOS styling */\n        box-shadow: none;\n        font-size: smaller;\n    }\n\n    li.active > button {\n        color: var(--fg);\n        border-bottom: 4px solid var(--border-focus);\n        border-radius: 0;\n    }\n    button:hover {\n        color: var(--fg);\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/TextInputModal.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Modal from \"bootstrap/js/dist/modal\";\n    import { getContext, onDestroy, onMount } from \"svelte\";\n\n    import { modalsKey } from \"$lib/components/context-keys\";\n    import { registerModalClosingHandler } from \"$lib/sveltelib/modal-closing\";\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    export let title: string;\n    export let prompt: string;\n    export let initialValue = \"\";\n    export let onOk: (text: string) => void;\n    $: value = initialValue;\n\n    export const modalKey: string = Math.random().toString(36).substring(2);\n\n    const modals = getContext<Map<string, Modal>>(modalsKey);\n\n    let modalRef: HTMLDivElement;\n    let modal: Modal;\n\n    let inputRef: HTMLInputElement;\n\n    function onOkClicked(): void {\n        onOk(inputRef.value);\n        modal.hide();\n        value = initialValue;\n    }\n\n    function onCancelClicked(): void {\n        modal.hide();\n        value = initialValue;\n    }\n\n    function onShown(): void {\n        inputRef.focus();\n        setModalOpen(true);\n    }\n\n    function onHidden() {\n        setModalOpen(false);\n    }\n\n    const { set: setModalOpen, remove: removeModalClosingHandler } =\n        registerModalClosingHandler(onCancelClicked);\n\n    onMount(() => {\n        modalRef.addEventListener(\"shown.bs.modal\", onShown);\n        modalRef.addEventListener(\"hidden.bs.modal\", onHidden);\n        modal = new Modal(modalRef, { keyboard: false });\n        modals.set(modalKey, modal);\n    });\n\n    onDestroy(() => {\n        removeModalClosingHandler();\n        modalRef.removeEventListener(\"shown.bs.modal\", onShown);\n        modalRef.removeEventListener(\"hidden.bs.modal\", onHidden);\n    });\n</script>\n\n<div\n    bind:this={modalRef}\n    class=\"modal fade\"\n    tabindex=\"-1\"\n    aria-labelledby=\"modalLabel\"\n    aria-hidden=\"true\"\n>\n    <div class=\"modal-dialog\">\n        <div class=\"modal-content\" class:default-colors={$pageTheme.isDark}>\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\" id=\"modalLabel\">{title}</h5>\n                <button\n                    type=\"button\"\n                    class=\"btn-close\"\n                    class:invert={$pageTheme.isDark}\n                    data-bs-dismiss=\"modal\"\n                    aria-label=\"Close\"\n                ></button>\n            </div>\n            <div class=\"modal-body\">\n                <form on:submit|preventDefault={onOkClicked}>\n                    <div class=\"mb-3\">\n                        <label for=\"prompt-input\" class=\"col-form-label\">\n                            {prompt}:\n                        </label>\n                        <input\n                            id=\"prompt-input\"\n                            bind:this={inputRef}\n                            type=\"text\"\n                            class:nightMode={$pageTheme.isDark}\n                            class=\"form-control\"\n                            bind:value\n                        />\n                    </div>\n                </form>\n            </div>\n            <div class=\"modal-footer\">\n                <button\n                    type=\"button\"\n                    class=\"btn btn-secondary\"\n                    on:click={onCancelClicked}\n                >\n                    Cancel\n                </button>\n                <button type=\"button\" class=\"btn btn-primary\" on:click={onOkClicked}>\n                    OK\n                </button>\n            </div>\n        </div>\n    </div>\n</div>\n\n<style lang=\"scss\">\n    @use \"$lib/sass/night-mode\" as nightmode;\n\n    .nightMode {\n        @include nightmode.input;\n    }\n\n    .default-colors {\n        background-color: var(--canvas);\n        color: var(--fg);\n    }\n\n    .invert {\n        filter: invert(1) grayscale(100%) brightness(200%);\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/deck-options/TimerOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import DynamicallySlottable from \"$lib/components/DynamicallySlottable.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Item from \"$lib/components/Item.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import type { DeckOptionsState } from \"./lib\";\n    import SpinBoxRow from \"./SpinBoxRow.svelte\";\n    import Warning from \"./Warning.svelte\";\n\n    export let state: DeckOptionsState;\n    export let api: Record<string, never>;\n\n    const config = state.currentConfig;\n    const defaults = state.defaults;\n\n    $: maximumAnswerSecondsAboveRecommended =\n        $config.capAnswerTimeToSecs > 600\n            ? tr.deckConfigMaximumAnswerSecsAboveRecommended()\n            : \"\";\n\n    const settings = {\n        maximumAnswerSecs: {\n            title: tr.deckConfigMaximumAnswerSecs(),\n            help: tr.deckConfigMaximumAnswerSecsTooltip(),\n        },\n        showAnswerTimer: {\n            title: tr.schedulingShowAnswerTimer(),\n            help: tr.deckConfigShowAnswerTimerTooltip(),\n        },\n        stopTimerOnAnswer: {\n            title: tr.deckConfigStopTimerOnAnswer(),\n            help: tr.deckConfigStopTimerOnAnswerTooltip(),\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.deckConfigTimerTitle()}>\n    <HelpModal\n        title={tr.deckConfigTimerTitle()}\n        url={HelpPage.DeckOptions.timer}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <DynamicallySlottable slotHost={Item} {api}>\n        <Item>\n            <SpinBoxRow\n                bind:value={$config.capAnswerTimeToSecs}\n                defaultValue={defaults.capAnswerTimeToSecs}\n                min={1}\n                max={7200}\n            >\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(\n                            Object.keys(settings).indexOf(\"maximumAnswerSecs\"),\n                        )}\n                >\n                    {settings.maximumAnswerSecs.title}\n                </SettingTitle>\n            </SpinBoxRow>\n        </Item>\n\n        <Item>\n            <Warning warning={maximumAnswerSecondsAboveRecommended} />\n        </Item>\n\n        <Item>\n            <!-- AnkiMobile hides this -->\n            <div class=\"show-timer-switch\" style=\"display: contents;\">\n                <SwitchRow\n                    bind:value={$config.showTimer}\n                    defaultValue={defaults.showTimer}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"showAnswerTimer\"),\n                            )}\n                    >\n                        {settings.showAnswerTimer.title}\n                    </SettingTitle>\n                </SwitchRow>\n            </div>\n        </Item>\n\n        <Item>\n            <div class=\"show-timer-switch\" style=\"display: contents;\">\n                <SwitchRow\n                    bind:value={$config.stopTimerOnAnswer}\n                    defaultValue={defaults.stopTimerOnAnswer}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"stopTimerOnAnswer\"),\n                            )}\n                    >\n                        {settings.stopTimerOnAnswer.title}\n                    </SettingTitle>\n                </SwitchRow>\n            </div>\n        </Item>\n    </DynamicallySlottable>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/deck-options/Warning.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { withoutUnicodeIsolation } from \"@tslib/i18n\";\n    import { slide } from \"svelte/transition\";\n\n    import Row from \"$lib/components/Row.svelte\";\n\n    export let warning: string;\n    export let className = \"alert-warning\";\n</script>\n\n{#if warning}\n    <Row>\n        <div class=\"col-12 alert {className} mb-0\" in:slide out:slide>\n            {withoutUnicodeIsolation(warning)}\n        </div>\n    </Row>\n{/if}\n"
  },
  {
    "path": "ts/routes/deck-options/[deckId]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import { onMount } from \"svelte\";\n    import DeckOptionsPage from \"../DeckOptionsPage.svelte\";\n    import { commitEditing } from \"../lib\";\n    import type { PageData } from \"./$types\";\n    import { deckOptionsRequireClose, deckOptionsReady } from \"@generated/backend\";\n\n    export let data: PageData;\n    let page: DeckOptionsPage;\n\n    globalThis.anki ||= {};\n    globalThis.anki.deckOptionsPendingChanges = async (): Promise<void> => {\n        await commitEditing();\n        if (\n            !(await data.state.isModified()) ||\n            confirm(tr.cardTemplatesDiscardChanges())\n        ) {\n            // Either there was no change, or the user accepted to discard the changes.\n            deckOptionsRequireClose({});\n        }\n    };\n\n    onMount(() => {\n        globalThis.$deckOptions = new Promise((resolve, _reject) => {\n            resolve(page);\n        });\n        data.state.resolveOriginalConfigs();\n        deckOptionsReady({});\n    });\n</script>\n\n<DeckOptionsPage state={data.state} bind:this={page} />\n"
  },
  {
    "path": "ts/routes/deck-options/[deckId]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { getDeckConfigsForUpdate } from \"@generated/backend\";\n\nimport { DeckOptionsState } from \"../lib\";\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async ({ params }) => {\n    const deckId = Number(params.deckId);\n\n    const did = BigInt(deckId);\n    const info = await getDeckConfigsForUpdate({ did });\n    const state = new DeckOptionsState(BigInt(did), info);\n\n    return { state };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/deck-options/choices.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport {\n    DeckConfig_Config_AnswerAction,\n    DeckConfig_Config_LeechAction,\n    DeckConfig_Config_NewCardGatherPriority,\n    DeckConfig_Config_NewCardInsertOrder,\n    DeckConfig_Config_NewCardSortOrder,\n    DeckConfig_Config_QuestionAction,\n    DeckConfig_Config_ReviewCardOrder,\n    DeckConfig_Config_ReviewMix,\n} from \"@generated/anki/deck_config_pb\";\nimport * as tr from \"@generated/ftl\";\n\nimport type { Choice } from \"$lib/components/EnumSelector.svelte\";\n\nexport function newGatherPriorityChoices(): Choice<DeckConfig_Config_NewCardGatherPriority>[] {\n    return [\n        {\n            label: tr.deckConfigNewGatherPriorityDeck(),\n            value: DeckConfig_Config_NewCardGatherPriority.DECK,\n        },\n        {\n            label: tr.deckConfigNewGatherPriorityDeckThenRandomNotes(),\n            value: DeckConfig_Config_NewCardGatherPriority.DECK_THEN_RANDOM_NOTES,\n        },\n        {\n            label: tr.deckConfigNewGatherPriorityPositionLowestFirst(),\n            value: DeckConfig_Config_NewCardGatherPriority.LOWEST_POSITION,\n        },\n        {\n            label: tr.deckConfigNewGatherPriorityPositionHighestFirst(),\n            value: DeckConfig_Config_NewCardGatherPriority.HIGHEST_POSITION,\n        },\n        {\n            label: tr.deckConfigNewGatherPriorityRandomNotes(),\n            value: DeckConfig_Config_NewCardGatherPriority.RANDOM_NOTES,\n        },\n        {\n            label: tr.deckConfigNewGatherPriorityRandomCards(),\n            value: DeckConfig_Config_NewCardGatherPriority.RANDOM_CARDS,\n        },\n    ];\n}\n\nexport function newSortOrderChoices(): Choice<DeckConfig_Config_NewCardSortOrder>[] {\n    return [\n        {\n            label: tr.deckConfigSortOrderTemplateThenGather(),\n            value: DeckConfig_Config_NewCardSortOrder.TEMPLATE,\n        },\n        {\n            label: tr.deckConfigSortOrderGather(),\n            value: DeckConfig_Config_NewCardSortOrder.NO_SORT,\n        },\n        {\n            label: tr.deckConfigSortOrderCardTemplateThenRandom(),\n            value: DeckConfig_Config_NewCardSortOrder.TEMPLATE_THEN_RANDOM,\n        },\n        {\n            label: tr.deckConfigSortOrderRandomNoteThenTemplate(),\n            value: DeckConfig_Config_NewCardSortOrder.RANDOM_NOTE_THEN_TEMPLATE,\n        },\n        {\n            label: tr.deckConfigSortOrderRandom(),\n            value: DeckConfig_Config_NewCardSortOrder.RANDOM_CARD,\n        },\n    ];\n}\n\nexport function reviewOrderChoices(\n    fsrs: boolean,\n): Choice<DeckConfig_Config_ReviewCardOrder>[] {\n    return [\n        ...[\n            {\n                label: tr.deckConfigSortOrderDueDateThenRandom(),\n                value: DeckConfig_Config_ReviewCardOrder.DAY,\n            },\n            {\n                label: tr.deckConfigSortOrderDueDateThenDeck(),\n                value: DeckConfig_Config_ReviewCardOrder.DAY_THEN_DECK,\n            },\n            {\n                label: tr.deckConfigSortOrderDeckThenDueDate(),\n                value: DeckConfig_Config_ReviewCardOrder.DECK_THEN_DAY,\n            },\n            {\n                label: tr.deckConfigSortOrderAscendingIntervals(),\n                value: DeckConfig_Config_ReviewCardOrder.INTERVALS_ASCENDING,\n            },\n            {\n                label: tr.deckConfigSortOrderDescendingIntervals(),\n                value: DeckConfig_Config_ReviewCardOrder.INTERVALS_DESCENDING,\n            },\n        ],\n        ...difficultyOrders(fsrs),\n        ...retrievabilityOrders(fsrs),\n        ...[\n            {\n                label: tr.decksRelativeOverdueness(),\n                value: DeckConfig_Config_ReviewCardOrder.RELATIVE_OVERDUENESS,\n            },\n            {\n                label: tr.deckConfigSortOrderRandom(),\n                value: DeckConfig_Config_ReviewCardOrder.RANDOM,\n            },\n            {\n                label: tr.decksOrderAdded(),\n                value: DeckConfig_Config_ReviewCardOrder.ADDED,\n            },\n            {\n                label: tr.decksLatestAddedFirst(),\n                value: DeckConfig_Config_ReviewCardOrder.REVERSE_ADDED,\n            },\n        ],\n    ];\n}\n\nexport function reviewMixChoices(): Choice<DeckConfig_Config_ReviewMix>[] {\n    return [\n        {\n            label: tr.deckConfigReviewMixMixWithReviews(),\n            value: DeckConfig_Config_ReviewMix.MIX_WITH_REVIEWS,\n        },\n        {\n            label: tr.deckConfigReviewMixShowAfterReviews(),\n            value: DeckConfig_Config_ReviewMix.AFTER_REVIEWS,\n        },\n        {\n            label: tr.deckConfigReviewMixShowBeforeReviews(),\n            value: DeckConfig_Config_ReviewMix.BEFORE_REVIEWS,\n        },\n    ];\n}\n\nexport function leechChoices(): Choice<DeckConfig_Config_LeechAction>[] {\n    return [\n        {\n            label: tr.actionsSuspendCard(),\n            value: DeckConfig_Config_LeechAction.SUSPEND,\n        },\n        {\n            label: tr.schedulingTagOnly(),\n            value: DeckConfig_Config_LeechAction.TAG_ONLY,\n        },\n    ];\n}\n\nexport function newInsertOrderChoices(): Choice<DeckConfig_Config_NewCardInsertOrder>[] {\n    return [\n        {\n            label: tr.deckConfigNewInsertionOrderSequential(),\n            value: DeckConfig_Config_NewCardInsertOrder.DUE,\n        },\n        {\n            label: tr.deckConfigNewInsertionOrderRandom(),\n            value: DeckConfig_Config_NewCardInsertOrder.RANDOM,\n        },\n    ];\n}\n\nexport function answerChoices(): Choice<DeckConfig_Config_AnswerAction>[] {\n    return [\n        {\n            label: tr.studyingBuryCard(),\n            value: DeckConfig_Config_AnswerAction.BURY_CARD,\n        },\n        {\n            label: tr.deckConfigAnswerAgain(),\n            value: DeckConfig_Config_AnswerAction.ANSWER_AGAIN,\n        },\n        {\n            label: tr.deckConfigAnswerGood(),\n            value: DeckConfig_Config_AnswerAction.ANSWER_GOOD,\n        },\n        {\n            label: tr.deckConfigAnswerHard(),\n            value: DeckConfig_Config_AnswerAction.ANSWER_HARD,\n        },\n        {\n            label: tr.deckConfigShowReminder(),\n            value: DeckConfig_Config_AnswerAction.SHOW_REMINDER,\n        },\n    ];\n}\nexport function questionActionChoices(): Choice<DeckConfig_Config_QuestionAction>[] {\n    return [\n        {\n            label: tr.deckConfigQuestionActionShowAnswer(),\n            value: DeckConfig_Config_QuestionAction.SHOW_ANSWER,\n        },\n        {\n            label: tr.deckConfigQuestionActionShowReminder(),\n            value: DeckConfig_Config_QuestionAction.SHOW_REMINDER,\n        },\n    ];\n}\n\nfunction difficultyOrders(fsrs: boolean): Choice<DeckConfig_Config_ReviewCardOrder>[] {\n    const order = [\n        {\n            label: fsrs\n                ? tr.deckConfigSortOrderDescendingDifficulty()\n                : tr.deckConfigSortOrderAscendingEase(),\n            value: DeckConfig_Config_ReviewCardOrder.EASE_ASCENDING,\n        },\n        {\n            label: fsrs\n                ? tr.deckConfigSortOrderAscendingDifficulty()\n                : tr.deckConfigSortOrderDescendingEase(),\n            value: DeckConfig_Config_ReviewCardOrder.EASE_DESCENDING,\n        },\n    ];\n    if (fsrs) {\n        order.reverse();\n    }\n    return order;\n}\n\nfunction retrievabilityOrders(\n    fsrs: boolean,\n): Choice<DeckConfig_Config_ReviewCardOrder>[] {\n    if (!fsrs) {\n        return [];\n    }\n    return [\n        {\n            label: tr.deckConfigSortOrderRetrievabilityAscending(),\n            value: DeckConfig_Config_ReviewCardOrder.RETRIEVABILITY_ASCENDING,\n        },\n        {\n            label: tr.deckConfigSortOrderRetrievabilityDescending(),\n            value: DeckConfig_Config_ReviewCardOrder.RETRIEVABILITY_DESCENDING,\n        },\n    ];\n}\n"
  },
  {
    "path": "ts/routes/deck-options/deck-options-base.scss",
    "content": "@import \"$lib/sass/base\";\n\n// override Bootstrap transition duration\n$carousel-transition: var(--transition);\n\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/carousel\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/badge\";\n@import \"$lib/sass/bootstrap-forms\";\n@import \"$lib/sass/bootstrap-tooltip\";\n\ninput[type=\"text\"],\ninput[type=\"date\"],\ntextarea {\n    padding-inline: 0.5rem;\n    background: var(--canvas-inset);\n}\n\ninput {\n    color: var(--fg);\n}\n\n// Setting 100% height causes the sticky element to hide as you scroll down on Safari.\nhtml {\n    height: initial;\n}\n\n[dir=\"rtl\"] .modal-header .btn-close {\n    padding: 1rem 1rem !important;\n    margin: -1rem auto -1rem -1rem !important;\n}\n"
  },
  {
    "path": "ts/routes/deck-options/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport \"$lib/sveltelib/export-runtime\";\nimport \"./deck-options-base.scss\";\n\nimport { getDeckConfigsForUpdate } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport { modalsKey, touchDeviceKey } from \"$lib/components/context-keys\";\nimport EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\nimport SwitchRow from \"$lib/components/SwitchRow.svelte\";\nimport TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\nimport DeckOptionsPage from \"./DeckOptionsPage.svelte\";\nimport { DeckOptionsState } from \"./lib\";\nimport SpinBoxFloatRow from \"./SpinBoxFloatRow.svelte\";\nimport SpinBoxRow from \"./SpinBoxRow.svelte\";\n\nconst i18n = setupI18n({\n    modules: [\n        ModuleName.HELP,\n        ModuleName.SCHEDULING,\n        ModuleName.ACTIONS,\n        ModuleName.DECK_CONFIG,\n        ModuleName.KEYBOARD,\n        ModuleName.STUDYING,\n        ModuleName.DECKS,\n    ],\n});\n\nexport async function setupDeckOptions(did_: number): Promise<DeckOptionsPage> {\n    const did = BigInt(did_);\n    const [info] = await Promise.all([getDeckConfigsForUpdate({ did }), i18n]);\n\n    checkNightMode();\n\n    const context = new Map();\n    context.set(modalsKey, new Map());\n    context.set(touchDeviceKey, \"ontouchstart\" in document.documentElement);\n\n    const state = new DeckOptionsState(BigInt(did), info);\n    return new DeckOptionsPage({\n        target: document.body,\n        props: { state },\n        context,\n    });\n}\n\nexport const components = {\n    TitledContainer,\n    SpinBoxRow,\n    SpinBoxFloatRow,\n    EnumSelectorRow,\n    SwitchRow,\n};\n\n// if (window.location.hash.startsWith(\"#test\")) {\n//     setupDeckOptions(1);\n// }\n"
  },
  {
    "path": "ts/routes/deck-options/lib.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport { protoBase64 } from \"@bufbuild/protobuf\";\nimport {\n    DeckConfig_Config_LeechAction,\n    DeckConfigsForUpdate,\n    UpdateDeckConfigsMode,\n} from \"@generated/anki/deck_config_pb\";\nimport { get } from \"svelte/store\";\nimport { expect, test } from \"vitest\";\n\nimport { DeckOptionsState } from \"./lib\";\n\nconst exampleData = {\n    allConfig: [\n        {\n            config: {\n                id: 1n,\n                name: \"Default\",\n                mtimeSecs: 1618570764n,\n                usn: -1,\n                config: {\n                    learnSteps: [1, 10],\n                    relearnSteps: [10],\n                    newPerDay: 10,\n                    reviewsPerDay: 200,\n                    initialEase: 2.5,\n                    easyMultiplier: 1.2999999523162842,\n                    hardMultiplier: 1.2000000476837158,\n                    intervalMultiplier: 1,\n                    maximumReviewInterval: 36500,\n                    minimumLapseInterval: 1,\n                    graduatingIntervalGood: 1,\n                    graduatingIntervalEasy: 4,\n                    leechAction: DeckConfig_Config_LeechAction.TAG_ONLY,\n                    leechThreshold: 8,\n                    capAnswerTimeToSecs: 60,\n                    other: protoBase64.dec(\n                        \"eyJuZXciOnsic2VwYXJhdGUiOnRydWV9LCJyZXYiOnsiZnV6eiI6MC4wNSwibWluU3BhY2UiOjF9fQ==\",\n                    ),\n                },\n            },\n            useCount: 1,\n        },\n        {\n            config: {\n                id: 1618570764780n,\n                name: \"another one\",\n                mtimeSecs: 1618570781n,\n                usn: -1,\n                config: {\n                    learnSteps: [1, 10, 20, 30],\n                    relearnSteps: [10],\n                    newPerDay: 40,\n                    reviewsPerDay: 200,\n                    initialEase: 2.5,\n                    easyMultiplier: 1.2999999523162842,\n                    hardMultiplier: 1.2000000476837158,\n                    intervalMultiplier: 1,\n                    maximumReviewInterval: 36500,\n                    minimumLapseInterval: 1,\n                    graduatingIntervalGood: 1,\n                    graduatingIntervalEasy: 4,\n                    leechAction: DeckConfig_Config_LeechAction.TAG_ONLY,\n                    leechThreshold: 8,\n                    capAnswerTimeToSecs: 60,\n                },\n            },\n            useCount: 1,\n        },\n    ],\n    currentDeck: {\n        name: \"Default::child\",\n        configId: 1618570764780n,\n    },\n    defaults: {\n        config: {\n            learnSteps: [1, 10],\n            relearnSteps: [10],\n            newPerDay: 20,\n            reviewsPerDay: 200,\n            initialEase: 2.5,\n            easyMultiplier: 1.2999999523162842,\n            hardMultiplier: 1.2000000476837158,\n            intervalMultiplier: 1,\n            maximumReviewInterval: 36500,\n            minimumLapseInterval: 1,\n            graduatingIntervalGood: 1,\n            graduatingIntervalEasy: 4,\n            leechAction: DeckConfig_Config_LeechAction.TAG_ONLY,\n            leechThreshold: 8,\n            capAnswerTimeToSecs: 60,\n        },\n    },\n};\n\nfunction startingState(): DeckOptionsState {\n    return new DeckOptionsState(\n        123n,\n        new DeckConfigsForUpdate(exampleData),\n    );\n}\n\ntest(\"start\", () => {\n    const state = startingState();\n    expect(state.currentDeck.name).toBe(\"Default::child\");\n});\n\ntest(\"deck list\", () => {\n    const state = startingState();\n    expect(get(state.configList)).toStrictEqual([\n        {\n            current: true,\n            idx: 0,\n            name: \"another one\",\n            useCount: 1,\n        },\n        {\n            current: false,\n            idx: 1,\n            name: \"Default\",\n            useCount: 1,\n        },\n    ]);\n    expect(get(state.currentConfig).newPerDay).toBe(40);\n\n    // rename\n    state.setCurrentName(\"zzz\");\n    expect(get(state.configList)).toStrictEqual([\n        {\n            current: false,\n            idx: 0,\n            name: \"Default\",\n            useCount: 1,\n        },\n        {\n            current: true,\n            idx: 1,\n            name: \"zzz\",\n            useCount: 1,\n        },\n    ]);\n\n    // add\n    state.addConfig(\"hello\");\n    expect(get(state.configList)).toStrictEqual([\n        {\n            current: false,\n            idx: 0,\n            name: \"Default\",\n            useCount: 1,\n        },\n        {\n            current: true,\n            idx: 1,\n            name: \"hello\",\n            useCount: 1,\n        },\n        {\n            current: false,\n            idx: 2,\n            name: \"zzz\",\n            useCount: 0,\n        },\n    ]);\n    expect(get(state.currentConfig).newPerDay).toBe(20);\n\n    // change current\n    state.setCurrentIndex(0);\n    expect(get(state.configList)).toStrictEqual([\n        {\n            current: true,\n            idx: 0,\n            name: \"Default\",\n            useCount: 2,\n        },\n        {\n            current: false,\n            idx: 1,\n            name: \"hello\",\n            useCount: 0,\n        },\n        {\n            current: false,\n            idx: 2,\n            name: \"zzz\",\n            useCount: 0,\n        },\n    ]);\n    expect(get(state.currentConfig).newPerDay).toBe(10);\n\n    // can't delete default\n    expect(() => state.removeCurrentConfig()).toThrow();\n\n    // deleting old deck should work\n    state.setCurrentIndex(1);\n    state.removeCurrentConfig();\n    expect(get(state.currentConfig).newPerDay).toBe(10);\n\n    // as should newly added one\n    state.setCurrentIndex(1);\n    state.removeCurrentConfig();\n    expect(get(state.currentConfig).newPerDay).toBe(10);\n\n    // only the pre-existing deck should be listed for removal\n    const out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL);\n    expect(out.removedConfigIds).toStrictEqual([1618570764780n]);\n});\n\ntest(\"duplicate name\", () => {\n    const state = startingState();\n\n    // duplicate will get renamed\n    state.addConfig(\"another one\");\n    expect(get(state.configList).find((e) => e.current)?.name).toMatch(/another.*\\d+$/);\n\n    // should handle renames too\n    state.setCurrentName(\"Default\");\n    expect(get(state.configList).find((e) => e.current)?.name).toMatch(/Default\\d+$/);\n});\n\ntest(\"saving\", () => {\n    let state = startingState();\n    let out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL);\n    expect(out.removedConfigIds).toStrictEqual([]);\n    expect(out.targetDeckId).toBe(123n);\n    // in no-changes case, currently selected config should\n    // be returned\n    expect(out.configs!.length).toBe(1);\n    expect(out.configs![0].name).toBe(\"another one\");\n    expect(out.mode).toBe(UpdateDeckConfigsMode.NORMAL);\n\n    // rename, then change current deck\n    state.setCurrentName(\"zzz\");\n    out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n    state.setCurrentIndex(0);\n\n    // renamed deck should be in changes, with current deck as last element\n    out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n    expect(out.configs!.map((c) => c.name)).toStrictEqual([\"zzz\", \"Default\"]);\n    expect(out.mode).toBe(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n\n    // start again, adding new deck\n    state = startingState();\n    state.addConfig(\"hello\");\n\n    // deleting it should not change removedConfigs\n    state.removeCurrentConfig();\n    out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n    expect(out.removedConfigIds).toStrictEqual([]);\n\n    // select the other non-default deck & remove\n    state.setCurrentIndex(0);\n    state.removeCurrentConfig();\n\n    // should be listed in removedConfigs, and modified should\n    // only contain Default, which is the new current deck\n    out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n    expect(out.removedConfigIds).toStrictEqual([1618570764780n]);\n    expect(out.configs!.map((c) => c.name)).toStrictEqual([\"Default\"]);\n});\n\ntest(\"aux data\", () => {\n    const state = startingState();\n    expect(get(state.currentAuxData)).toStrictEqual({});\n    state.currentAuxData.update((val) => {\n        return { ...val, hello: \"world\" };\n    });\n\n    // check default\n    state.setCurrentIndex(1);\n    expect(get(state.currentAuxData)).toStrictEqual({\n        new: {\n            separate: true,\n        },\n        rev: {\n            fuzz: 0.05,\n            minSpace: 1,\n        },\n    });\n    state.currentAuxData.update((val) => {\n        return { ...val, defaultAddition: true };\n    });\n\n    // ensure changes serialize\n    const out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);\n    expect(out.configs!.length).toBe(2);\n    const json = out.configs!.map((c) => JSON.parse(new TextDecoder().decode(c.config!.other)));\n    expect(json).toStrictEqual([\n        // other deck comes first\n        {\n            hello: \"world\",\n        },\n        // default is selected, so comes last\n        {\n            defaultAddition: true,\n            new: {\n                separate: true,\n            },\n            rev: {\n                fuzz: 0.05,\n                minSpace: 1,\n            },\n        },\n    ]);\n});\n"
  },
  {
    "path": "ts/routes/deck-options/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { PlainMessage } from \"@bufbuild/protobuf\";\nimport type {\n    DeckConfigsForUpdate,\n    DeckConfigsForUpdate_CurrentDeck,\n    UpdateDeckConfigsMode,\n    UpdateDeckConfigsRequest,\n} from \"@generated/anki/deck_config_pb\";\nimport { DeckConfig, DeckConfig_Config, DeckConfigsForUpdate_CurrentDeck_Limits } from \"@generated/anki/deck_config_pb\";\nimport { updateDeckConfigs } from \"@generated/backend\";\nimport { localeCompare } from \"@tslib/i18n\";\nimport { promiseWithResolver } from \"@tslib/promise\";\nimport { cloneDeep, isEqual, isEqualWith } from \"lodash-es\";\nimport { tick } from \"svelte\";\nimport type { Readable, Writable } from \"svelte/store\";\nimport { get, readable, writable } from \"svelte/store\";\n\nimport type { DynamicSvelteComponent } from \"$lib/sveltelib/dynamicComponent\";\n\nexport type DeckOptionsId = bigint;\n\nexport interface ConfigWithCount {\n    config: DeckConfig;\n    useCount: number;\n}\n\n/** Info for showing the top selector */\nexport interface ConfigListEntry {\n    idx: number;\n    name: string;\n    useCount: number;\n    current: boolean;\n}\n\ntype AllConfigs =\n    & Required<\n        Pick<\n            PlainMessage<UpdateDeckConfigsRequest>,\n            | \"configs\"\n            | \"cardStateCustomizer\"\n            | \"limits\"\n            | \"newCardsIgnoreReviewLimit\"\n            | \"applyAllParentLimits\"\n            | \"fsrs\"\n            | \"fsrsReschedule\"\n        >\n    >\n    & { currentConfig: DeckConfig_Config };\n\nexport class DeckOptionsState {\n    readonly currentConfig: Writable<DeckConfig_Config>;\n    readonly currentAuxData: Writable<Record<string, unknown>>;\n    readonly configList: Readable<ConfigListEntry[]>;\n    readonly cardStateCustomizer: Writable<string>;\n    readonly currentDeck: DeckConfigsForUpdate_CurrentDeck;\n    readonly deckLimits: Writable<DeckConfigsForUpdate_CurrentDeck_Limits>;\n    readonly defaults: DeckConfig_Config;\n    readonly addonComponents: Writable<DynamicSvelteComponent[]>;\n    readonly newCardsIgnoreReviewLimit: Writable<boolean>;\n    readonly applyAllParentLimits: Writable<boolean>;\n    readonly fsrs: Writable<boolean>;\n    readonly fsrsReschedule: Writable<boolean> = writable(false);\n    readonly fsrsHealthCheck: Writable<boolean>;\n    readonly legacyEvaluate: boolean;\n    readonly daysSinceLastOptimization: Writable<number>;\n    readonly currentPresetName: Writable<string>;\n    /** Used to detect if there are any pending changes */\n    readonly originalConfigsPromise: Promise<AllConfigs>;\n    readonly originalConfigsResolve: (value: AllConfigs) => void;\n\n    private targetDeckId: DeckOptionsId;\n    private configs: ConfigWithCount[];\n    private selectedIdx: number;\n    private configListSetter!: (val: ConfigListEntry[]) => void;\n    private modifiedConfigs: Set<DeckOptionsId> = new Set();\n    private removedConfigs: DeckOptionsId[] = [];\n    private schemaModified: boolean;\n    private _presetAssignmentsChanged = false;\n    // tracks presets that have already been\n    // selected/loaded once, via their ids.\n    // needed for proper change detection\n    private loadedPresets: Set<DeckConfig[\"id\"]> = new Set();\n\n    constructor(targetDeckId: DeckOptionsId, data: DeckConfigsForUpdate) {\n        this.targetDeckId = targetDeckId;\n        this.currentDeck = data.currentDeck!;\n        this.defaults = data.defaults!.config!;\n        this.configs = data.allConfig.map((config) => {\n            const configInner = config.config!;\n\n            return {\n                config: configInner,\n                useCount: config.useCount!,\n            };\n        });\n        this.selectedIdx = Math.max(\n            0,\n            this.configs.findIndex((c) => c.config.id === this.currentDeck.configId),\n        );\n        this.sortConfigs();\n        this.cardStateCustomizer = writable(data.cardStateCustomizer);\n        this.deckLimits = writable(data.currentDeck?.limits ?? createLimits());\n        this.newCardsIgnoreReviewLimit = writable(data.newCardsIgnoreReviewLimit);\n        this.applyAllParentLimits = writable(data.applyAllParentLimits);\n        this.fsrs = writable(data.fsrs);\n        this.fsrsHealthCheck = writable(data.fsrsHealthCheck);\n        this.legacyEvaluate = data.fsrsLegacyEvaluate;\n        this.daysSinceLastOptimization = writable(data.daysSinceLastFsrsOptimize);\n\n        // decrement the use count of the starting item, as we'll apply +1 to currently\n        // selected one at display time\n        this.configs[this.selectedIdx].useCount -= 1;\n        this.currentConfig = writable(this.getCurrentConfig());\n        this.currentAuxData = writable(this.getCurrentAuxData());\n        this.currentPresetName = writable(this.configs[this.selectedIdx].config.name);\n        this.configList = readable(this.getConfigList(), (set) => {\n            this.configListSetter = set;\n            return;\n        });\n        this.schemaModified = data.schemaModified;\n        this.addonComponents = writable([]);\n\n        // create a temporary subscription to force our setters to be set immediately,\n        // so unit tests don't get stale results\n        get(this.configList);\n\n        // update our state when the current config is changed\n        this.currentConfig.subscribe((val) => this.onCurrentConfigChanged(val));\n        this.currentAuxData.subscribe((val) => this.onCurrentAuxDataChanged(val));\n\n        // no need to call markCurrentPresetAsLoaded here\n        // since any changes components make will be before\n        // originalConfigsResolve is called on mount\n        this.loadedPresets.add(this.configs[this.selectedIdx].config.id);\n\n        // Must be resolved after all components are mounted, as some components\n        // may modify the config during their initialization.\n        [this.originalConfigsPromise, this.originalConfigsResolve] = promiseWithResolver<AllConfigs>();\n    }\n\n    /**\n     * Patch the original config if components change it after preset select\n     * `EasyDays` and `DateInput` both do this when their settings are blank\n     * We only need to patch when the preset is first selected.\n     */\n    async markCurrentPresetAsLoaded(): Promise<void> {\n        const id = this.configs[this.selectedIdx].config.id;\n        const loaded = this.loadedPresets;\n        // ignore new presets with an id of 0\n        if (id && !loaded.has(id)) {\n            // preset was loaded for the first time, patch original config\n            loaded.add(id);\n            const original = await this.originalConfigsPromise;\n            // can't index into `original` with `this.selectedIdx` due to `sortConfigs`\n            const idx = original.configs.findIndex((conf) => conf.id === id);\n            // this should never be -1, since new presets are excluded, and removed presets aren't considered\n            if (idx !== -1) {\n                original.configs[idx] = cloneDeep(this.configs[this.selectedIdx].config);\n            }\n        }\n    }\n\n    setCurrentIndex(index: number): void {\n        this.selectedIdx = index;\n        this._presetAssignmentsChanged = true;\n        this.updateCurrentConfig();\n        // use counts have changed\n        this.updateConfigList();\n        // wait for components that modify config on preset select\n        tick().then(() => this.markCurrentPresetAsLoaded());\n    }\n\n    getCurrentName(): string {\n        return this.configs[this.selectedIdx].config.name;\n    }\n\n    getCurrentNameForSearch(): string {\n        return this.getCurrentName().replace(/([\\\\\"])/g, \"\\\\$1\");\n    }\n\n    setCurrentName(name: string): void {\n        if (this.configs[this.selectedIdx].config.name === name) {\n            return;\n        }\n        const uniqueName = this.ensureNewNameUnique(name);\n        const config = this.configs[this.selectedIdx].config;\n        config.name = uniqueName;\n        if (config.id) {\n            this.modifiedConfigs.add(config.id);\n        }\n        this.sortConfigs();\n        this.updateConfigList();\n    }\n\n    /** Adds a new config, making it current. */\n    addConfig(name: string): void {\n        this.addConfigFrom(name, this.defaults);\n    }\n\n    /** Clone the current config, making it current. */\n    cloneConfig(name: string): void {\n        this.addConfigFrom(name, this.configs[this.selectedIdx].config.config!);\n    }\n\n    /** Clone the current config, making it current. */\n    private addConfigFrom(name: string, source: DeckConfig_Config): void {\n        const uniqueName = this.ensureNewNameUnique(name);\n        const config = new DeckConfig({\n            id: 0n,\n            name: uniqueName,\n            config: new DeckConfig_Config(cloneDeep(source)),\n        });\n        const configWithCount = { config, useCount: 0 };\n        this.configs.push(configWithCount);\n        this.selectedIdx = this.configs.length - 1;\n        this._presetAssignmentsChanged = true;\n        this.sortConfigs();\n        this.updateCurrentConfig();\n        this.updateConfigList();\n    }\n\n    removalWilLForceFullSync(): boolean {\n        return !this.schemaModified && this.configs[this.selectedIdx].config.id !== 0n;\n    }\n\n    defaultConfigSelected(): boolean {\n        return this.configs[this.selectedIdx].config.id === 1n;\n    }\n\n    /** Will throw if the default deck is selected. */\n    removeCurrentConfig(): void {\n        const currentId = this.configs[this.selectedIdx].config.id;\n        if (currentId === 1n) {\n            throw Error(\"can't remove default config\");\n        }\n        if (currentId !== 0n) {\n            this.removedConfigs.push(currentId);\n            this.schemaModified = true;\n        }\n        this.configs.splice(this.selectedIdx, 1);\n        const newIdx = Math.max(0, this.selectedIdx - 1);\n        this.setCurrentIndex(newIdx);\n    }\n\n    dataForSaving(\n        mode: UpdateDeckConfigsMode,\n    ): PlainMessage<UpdateDeckConfigsRequest> {\n        const modifiedConfigsExcludingCurrent = this.configs\n            .map((c) => c.config)\n            .filter((c, idx) => {\n                return (\n                    idx !== this.selectedIdx\n                    && (c.id === 0n || this.modifiedConfigs.has(c.id))\n                );\n            });\n        const configs = [\n            ...modifiedConfigsExcludingCurrent,\n            // current must come last, even if unmodified\n            this.configs[this.selectedIdx].config,\n        ];\n        return {\n            targetDeckId: this.targetDeckId,\n            removedConfigIds: this.removedConfigs,\n            configs,\n            mode,\n            cardStateCustomizer: get(this.cardStateCustomizer),\n            limits: get(this.deckLimits),\n            newCardsIgnoreReviewLimit: get(this.newCardsIgnoreReviewLimit),\n            applyAllParentLimits: get(this.applyAllParentLimits),\n            fsrs: get(this.fsrs),\n            fsrsReschedule: get(this.fsrsReschedule),\n            fsrsHealthCheck: get(this.fsrsHealthCheck),\n        };\n    }\n\n    presetAssignmentsChanged(): boolean {\n        return this._presetAssignmentsChanged;\n    }\n\n    async save(mode: UpdateDeckConfigsMode): Promise<void> {\n        await updateDeckConfigs(\n            this.dataForSaving(mode),\n        );\n    }\n\n    private onCurrentConfigChanged(config: DeckConfig_Config): void {\n        const configOuter = this.configs[this.selectedIdx].config;\n        if (!isEqual(config, configOuter.config)) {\n            configOuter.config = config;\n            if (configOuter.id) {\n                this.modifiedConfigs.add(configOuter.id);\n            }\n        }\n    }\n\n    private onCurrentAuxDataChanged(data: Record<string, unknown>): void {\n        const current = this.getCurrentAuxData();\n        if (!isEqual(current, data)) {\n            this.currentConfig.update((config) => {\n                const asBytes = new TextEncoder().encode(JSON.stringify(data));\n                config.other = asBytes;\n                return config;\n            });\n        }\n    }\n\n    private ensureNewNameUnique(name: string): string {\n        const idx = this.configs.findIndex((e) => e.config.name === name);\n        if (idx !== -1) {\n            return name + (new Date().getTime() / 1000).toFixed(0);\n        } else {\n            return name;\n        }\n    }\n\n    private updateCurrentConfig(): void {\n        this.currentConfig.set(this.getCurrentConfig());\n        this.currentAuxData.set(this.getCurrentAuxData());\n    }\n\n    private updateConfigList(): void {\n        this.configListSetter?.(this.getConfigList());\n        this.currentPresetName.set(this.configs[this.selectedIdx].config.name);\n    }\n\n    /** Returns a copy of the currently selected config. */\n    private getCurrentConfig(): DeckConfig_Config {\n        return cloneDeep(this.configs[this.selectedIdx].config.config!);\n    }\n\n    /** Extra data associated with current config (for add-ons) */\n    private getCurrentAuxData(): Record<string, unknown> {\n        const conf = this.configs[this.selectedIdx].config.config!;\n        return bytesToObject(conf.other);\n    }\n\n    private sortConfigs() {\n        const currentConfigName = this.configs[this.selectedIdx].config.name;\n        this.configs.sort((a, b) => localeCompare(a.config.name, b.config.name, { sensitivity: \"base\" }));\n        this.selectedIdx = this.configs.findIndex(\n            (c) => c.config.name == currentConfigName,\n        );\n    }\n\n    private getConfigList(): ConfigListEntry[] {\n        const list: ConfigListEntry[] = this.configs.map((c, idx) => {\n            const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0);\n            return {\n                name: c.config.name,\n                current: idx === this.selectedIdx,\n                idx,\n                useCount,\n            };\n        });\n        return list;\n    }\n\n    private getAllConfigs(): AllConfigs {\n        return cloneDeep({\n            configs: this.configs.map(c => c.config),\n            cardStateCustomizer: get(this.cardStateCustomizer),\n            limits: get(this.deckLimits),\n            newCardsIgnoreReviewLimit: get(this.newCardsIgnoreReviewLimit),\n            applyAllParentLimits: get(this.applyAllParentLimits),\n            fsrs: get(this.fsrs),\n            fsrsReschedule: get(this.fsrsReschedule),\n            currentConfig: get(this.currentConfig),\n        });\n    }\n\n    async isModified(): Promise<boolean> {\n        const original = await this.originalConfigsPromise;\n        const current = this.getAllConfigs();\n        return !isEqualWith(original, current, (lhs, rhs) => {\n            if (typeof lhs === \"number\" && typeof rhs === \"number\") {\n                // rslib hands us 32-bit floats (f32), while ts uses 64-bit floats\n                // SpinBox and ParamsInput both round their values as f64 on blur\n                // while the original config's corresponding value remains an f32\n                // so we convert both to f32 before checking for equality\n                return Math.fround(lhs) === Math.fround(rhs);\n            }\n            // undefined means fallback to isEqual\n        });\n    }\n\n    resolveOriginalConfigs(): void {\n        this.originalConfigsResolve(this.getAllConfigs());\n    }\n}\n\nfunction bytesToObject(bytes: Uint8Array): Record<string, unknown> {\n    if (!bytes.length) {\n        return {};\n    }\n\n    let obj: Record<string, unknown>;\n\n    try {\n        obj = JSON.parse(new TextDecoder().decode(bytes));\n    } catch (err) {\n        console.log(`invalid json in deck config`);\n        return {};\n    }\n\n    if (obj.constructor !== Object) {\n        console.log(`invalid object in deck config`);\n        return {};\n    }\n\n    return obj;\n}\n\nexport function createLimits(): DeckConfigsForUpdate_CurrentDeck_Limits {\n    return new DeckConfigsForUpdate_CurrentDeck_Limits({});\n}\n\nexport class ValueTab {\n    readonly title: string;\n    value: number | null;\n    private setter: (value: number | null) => void;\n    private disabledValue: number | null;\n    private startValue: number | null;\n    private initialValue: number | null;\n\n    constructor(\n        title: string,\n        value: number | null,\n        setter: (value: number | null) => void,\n        disabledValue: number | null,\n        startValue: number | null,\n    ) {\n        this.title = title;\n        this.value = this.initialValue = value;\n        this.setter = setter;\n        this.disabledValue = disabledValue;\n        this.startValue = startValue;\n    }\n\n    reset(): void {\n        this.setter(this.initialValue);\n    }\n\n    disable(): void {\n        this.setter(this.disabledValue);\n    }\n\n    enable(fallbackValue: number): void {\n        this.value = this.value ?? this.startValue ?? fallbackValue;\n        this.setter(this.value);\n    }\n\n    setValue(value: number): void {\n        this.value = value;\n        this.setter(value);\n    }\n}\n\n/** Ensure blur handler has fired so changes get committed. */\nexport async function commitEditing(): Promise<void> {\n    if (document.activeElement instanceof HTMLElement) {\n        document.activeElement.blur();\n    }\n    await tick();\n}\n\nexport function fsrsParams(config: DeckConfig_Config): number[] {\n    if (config.fsrsParams6) {\n        return config.fsrsParams6;\n    } else if (config.fsrsParams5) {\n        return config.fsrsParams5;\n    } else {\n        return config.fsrsParams4;\n    }\n}\n"
  },
  {
    "path": "ts/routes/deck-options/steps.test.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { expect, test } from \"vitest\";\n\nimport { stepsToString, stringToSteps } from \"./steps\";\n\ntest(\"whole steps\", () => {\n    const steps = [1, 10, 60, 120, 1440];\n    const string = \"1m 10m 1h 2h 1d\";\n    expect(stepsToString(steps)).toBe(string);\n    expect(stringToSteps(string)).toStrictEqual(steps);\n});\n\ntest(\"fractional steps\", () => {\n    const steps = [1 / 60, 5 / 60, 1.5, 400];\n    const string = \"1s 5s 90s 400m\";\n    expect(stepsToString(steps)).toBe(string);\n    expect(stringToSteps(string)).toStrictEqual(steps);\n});\n\ntest(\"rounding\", () => {\n    const steps = [0.1666666716337204];\n    expect(stepsToString(steps)).toBe(\"10s\");\n});\n\ntest(\"parsing\", () => {\n    expect(stringToSteps(\"\")).toStrictEqual([]);\n    expect(stringToSteps(\"    \")).toStrictEqual([]);\n    expect(stringToSteps(\"1 hello 2\")).toStrictEqual([1, 2]);\n});\n"
  },
  {
    "path": "ts/routes/deck-options/steps.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { naturalWholeUnit, TimespanUnit, unitAmount, unitSeconds } from \"@tslib/time\";\n\nfunction unitSuffix(unit: TimespanUnit): string {\n    switch (unit) {\n        case TimespanUnit.Seconds:\n            return \"s\";\n        case TimespanUnit.Minutes:\n            return \"m\";\n        case TimespanUnit.Hours:\n            return \"h\";\n        case TimespanUnit.Days:\n            return \"d\";\n        default:\n            // should not happen\n            return \"\";\n    }\n}\n\nfunction suffixToUnit(suffix: string): TimespanUnit {\n    switch (suffix) {\n        case \"s\":\n            return TimespanUnit.Seconds;\n        case \"h\":\n            return TimespanUnit.Hours;\n        case \"d\":\n            return TimespanUnit.Days;\n        default:\n            return TimespanUnit.Minutes;\n    }\n}\n\nfunction minutesToString(step: number): string {\n    const secs = step * 60;\n    let unit = naturalWholeUnit(secs);\n    if ([TimespanUnit.Months, TimespanUnit.Years].includes(unit)) {\n        unit = TimespanUnit.Days;\n    }\n    const amount = Math.round(unitAmount(unit, secs));\n\n    return `${amount}${unitSuffix(unit)}`;\n}\n\nfunction stringToMinutes(text: string): number {\n    const match = text.match(/(\\d+)(.*)/);\n    if (match) {\n        const [_, num, suffix] = match;\n        const unit = suffixToUnit(suffix);\n        const seconds = unitSeconds(unit) * parseInt(num, 10);\n        // should be representable as negative i32 seconds in a revlog\n        const capped_seconds = Math.min(seconds, 2 ** 31);\n        return capped_seconds / 60;\n    } else {\n        return 0;\n    }\n}\n\nexport function stepsToString(steps: number[]): string {\n    return steps.map(minutesToString).join(\" \");\n}\n\nexport function stringToSteps(text: string): number[] {\n    return (\n        text\n            .split(\" \")\n            .map(stringToMinutes)\n            // remove zeros\n            .filter((e) => e)\n    );\n}\n"
  },
  {
    "path": "ts/routes/graphs/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import AddedGraph from \"./AddedGraph.svelte\";\n    import ButtonsGraph from \"./ButtonsGraph.svelte\";\n    import CalendarGraph from \"./CalendarGraph.svelte\";\n    import CardCounts from \"./CardCounts.svelte\";\n    import DifficultyGraph from \"./DifficultyGraph.svelte\";\n    import EaseGraph from \"./EaseGraph.svelte\";\n    import FutureDue from \"./FutureDue.svelte\";\n    import GraphsPage from \"./GraphsPage.svelte\";\n    import HourGraph from \"./HourGraph.svelte\";\n    import IntervalsGraph from \"./IntervalsGraph.svelte\";\n    import RangeBox from \"./RangeBox.svelte\";\n    import RetrievabilityGraph from \"./RetrievabilityGraph.svelte\";\n    import ReviewsGraph from \"./ReviewsGraph.svelte\";\n    import StabilityGraph from \"./StabilityGraph.svelte\";\n    import TodayStats from \"./TodayStats.svelte\";\n    import TrueRetention from \"./TrueRetention.svelte\";\n\n    const graphs = [\n        TodayStats,\n        FutureDue,\n        CalendarGraph,\n        ReviewsGraph,\n        CardCounts,\n        IntervalsGraph,\n        StabilityGraph,\n        EaseGraph,\n        DifficultyGraph,\n        RetrievabilityGraph,\n        TrueRetention,\n        HourGraph,\n        ButtonsGraph,\n        AddedGraph,\n    ];\n</script>\n\n<GraphsPage\n    {graphs}\n    initialSearch=\"deck:current\"\n    initialDays={365}\n    controller={RangeBox}\n/>\n"
  },
  {
    "path": "ts/routes/graphs/AddedGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import type { GraphData } from \"./added\";\n    import { buildHistogram, gatherData } from \"./added\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import { GraphRange, RevlogRange } from \"./graph-helpers\";\n    import GraphRangeRadios from \"./GraphRangeRadios.svelte\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let graphRange: GraphRange = GraphRange.Month;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let addedData: GraphData | null = null;\n    $: if (sourceData) {\n        addedData = gatherData(sourceData);\n    }\n\n    $: if (addedData) {\n        [histogramData, tableData] = buildHistogram(\n            addedData,\n            graphRange,\n            dispatch,\n            $prefs.browserLinksSupported,\n        );\n    }\n\n    const title = tr.statisticsAddedTitle();\n    const subtitle = tr.statisticsAddedSubtitle();\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        <GraphRangeRadios bind:graphRange revlogRange={RevlogRange.All} />\n    </InputBox>\n\n    <HistogramGraph data={histogramData} />\n\n    <TableData {tableData} />\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/AxisTicks.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphBounds } from \"./graph-helpers\";\n\n    export let bounds: GraphBounds;\n</script>\n\n<g class=\"x-ticks\" transform={`translate(0, ${bounds.height - bounds.marginBottom})`} />\n<g class=\"y-ticks\" transform={`translate(${bounds.marginLeft}, 0)`} />\n<g class=\"y2-ticks\" transform={`translate(${bounds.width - bounds.marginRight}, 0)`} />\n\n<style lang=\"scss\">\n    g :global(.domain) {\n        opacity: 0.05;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/ButtonsGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n\n    import AxisTicks from \"./AxisTicks.svelte\";\n    import { renderButtons } from \"./buttons\";\n    import Graph from \"./Graph.svelte\";\n    import type { RevlogRange } from \"./graph-helpers\";\n    import { defaultGraphBounds, GraphRange } from \"./graph-helpers\";\n    import GraphRangeRadios from \"./GraphRangeRadios.svelte\";\n    import HoverColumns from \"./HoverColumns.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import NoDataOverlay from \"./NoDataOverlay.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let revlogRange: RevlogRange;\n\n    let graphRange: GraphRange = GraphRange.Year;\n\n    const bounds = defaultGraphBounds();\n\n    let svg: HTMLElement | SVGElement | null = null;\n\n    $: if (sourceData) {\n        renderButtons(svg as SVGElement, bounds, sourceData, graphRange);\n    }\n\n    const title = tr.statisticsAnswerButtonsTitle();\n    const subtitle = tr.statisticsAnswerButtonsSubtitle();\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        <GraphRangeRadios bind:graphRange {revlogRange} followRevlog={true} />\n    </InputBox>\n\n    <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n        <g class=\"bars\" />\n        <HoverColumns />\n        <AxisTicks {bounds} />\n        <NoDataOverlay {bounds} />\n    </svg>\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/CalendarGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import AxisTicks from \"./AxisTicks.svelte\";\n    import type { GraphData } from \"./calendar\";\n    import { gatherData, renderCalendar } from \"./calendar\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs, SearchEventMap } from \"./graph-helpers\";\n    import { defaultGraphBounds, RevlogRange } from \"./graph-helpers\";\n    import InputBox from \"./InputBox.svelte\";\n    import NoDataOverlay from \"./NoDataOverlay.svelte\";\n\n    export let sourceData: GraphsResponse;\n    export let prefs: GraphPrefs;\n    export let revlogRange: RevlogRange;\n    export let nightMode: boolean;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let graphData: GraphData | null = null;\n\n    const bounds = defaultGraphBounds();\n    bounds.height = 120;\n    bounds.marginLeft = 20;\n    bounds.marginRight = 20;\n\n    let svg: HTMLElement | SVGElement | null = null;\n    const maxYear = new Date().getFullYear();\n    let minYear = 0;\n    let targetYear = maxYear;\n\n    $: if (sourceData) {\n        graphData = gatherData(sourceData, $prefs.calendarFirstDayOfWeek);\n        renderCalendar(\n            svg as SVGElement,\n            bounds,\n            graphData,\n            dispatch,\n            targetYear,\n            nightMode,\n            revlogRange,\n            (day) => ($prefs.calendarFirstDayOfWeek = day),\n        );\n    }\n\n    $: {\n        if (revlogRange < RevlogRange.Year) {\n            minYear = maxYear;\n        } else if (revlogRange === RevlogRange.Year) {\n            minYear = maxYear - 1;\n        } else {\n            minYear = 2000;\n        }\n        if (targetYear < minYear) {\n            targetYear = minYear;\n        }\n    }\n\n    const title = tr.statisticsCalendarTitle();\n</script>\n\n<Graph {title}>\n    <InputBox>\n        <span>\n            <button on:click={() => targetYear--} disabled={minYear >= targetYear}>\n                ◄\n            </button>\n        </span>\n        <span>{targetYear}</span>\n        <span>\n            <button on:click={() => targetYear++} disabled={targetYear >= maxYear}>\n                ►\n            </button>\n        </span>\n    </InputBox>\n\n    <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n        <g class=\"weekdays\" />\n        <g class=\"days\" />\n        <AxisTicks {bounds} />\n        <NoDataOverlay {bounds} />\n    </svg>\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/CardCounts.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr2 from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import type { GraphData, TableDatum } from \"./card-counts\";\n    import { gatherData, renderCards } from \"./card-counts\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap } from \"./graph-helpers\";\n    import { defaultGraphBounds } from \"./graph-helpers\";\n    import InputBox from \"./InputBox.svelte\";\n\n    export let sourceData: GraphsResponse;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let svg: HTMLElement | SVGElement | null = null;\n\n    const bounds = defaultGraphBounds();\n    bounds.width = 225;\n    bounds.marginBottom = 0;\n\n    let graphData: GraphData = null!;\n    let tableData: TableDatum[] = null!;\n\n    $: {\n        graphData = gatherData(sourceData, $prefs.cardCountsSeparateInactive);\n        tableData = renderCards(svg as any, bounds, graphData);\n    }\n\n    const label = tr2.statisticsCountsSeparateSuspendedBuriedCards();\n    const total = tr2.statisticsCountsTotalCards();\n</script>\n\n<Graph title={graphData.title}>\n    <InputBox>\n        <label>\n            <input type=\"checkbox\" bind:checked={$prefs.cardCountsSeparateInactive} />\n            {label}\n        </label>\n    </InputBox>\n\n    <div class=\"counts-outer\">\n        <div class=\"svg-container\">\n            <svg\n                bind:this={svg}\n                viewBox={`0 0 ${bounds.width} ${bounds.height}`}\n                style=\"opacity: {graphData.totalCards ? 1 : 0}\"\n            >\n                <g class=\"counts\" />\n            </svg>\n        </div>\n        <div class=\"counts-table\">\n            <table>\n                <tbody>\n                    {#each tableData as d, _idx}\n                        <tr>\n                            <!-- prettier-ignore -->\n                            <td>\n                            <span style=\"color: {d.colour};\">■&nbsp;</span>\n                            {#if $prefs.browserLinksSupported}\n                                <button class=\"search-link\" on:click={() => dispatch('search', { query: d.query })}>{d.label}</button>\n                            {:else}\n                                <span>{d.label}</span>\n                            {/if}\n                        </td>\n                            <td class=\"right\">{d.count}</td>\n                            <td class=\"right\">{d.percent}</td>\n                        </tr>\n                    {/each}\n\n                    <tr>\n                        <td>\n                            <span style=\"visibility: hidden;\">■</span>\n                            {total}\n                        </td>\n                        <td class=\"right\">{graphData.totalCards}</td>\n                        <td></td>\n                    </tr>\n                </tbody>\n            </table>\n        </div>\n    </div>\n</Graph>\n\n<style lang=\"scss\">\n    svg {\n        transition: opacity var(--transition-slow);\n    }\n\n    .counts-outer {\n        display: flex;\n        justify-content: center;\n        margin: 0 4vw;\n        flex-wrap: wrap;\n        flex: 1;\n\n        .svg-container {\n            width: 225px;\n        }\n\n        .counts-table {\n            display: flex;\n            flex-direction: column;\n            justify-content: center;\n\n            table {\n                border-spacing: 1em 0;\n                padding-left: 4vw;\n\n                td {\n                    white-space: nowrap;\n                    padding: 0 min(4vw, 40px);\n\n                    &.right {\n                        text-align: right;\n                    }\n                }\n            }\n        }\n    }\n\n    .search-link {\n        border: 1px transparent solid;\n        background: transparent;\n        cursor: pointer;\n        box-shadow: none;\n        padding: 0 2px;\n        margin-bottom: 0px;\n    }\n\n    .search-link:hover {\n        color: var(--fg-link);\n        text-decoration: underline;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/CumulativeOverlay.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\"></script>\n\n<path class=\"cumulative-overlay\" />\n\n<style lang=\"scss\">\n    :global(:root) {\n        --area-fill: #000000;\n        --area-fill-opacity: 0.03;\n        --area-stroke: #000000;\n        --area-stroke-opacity: 0.08;\n    }\n\n    :global(:root[class*=\"night-mode\"]) {\n        --area-fill: #ffffff;\n        --area-fill-opacity: 0.08;\n        --area-stroke: #000000;\n        --area-stroke-opacity: 0.18;\n    }\n\n    .cumulative-overlay {\n        pointer-events: none;\n        fill: var(--area-fill);\n        fill-opacity: var(--area-fill-opacity);\n        stroke: var(--area-stroke);\n        stroke-opacity: var(--area-stroke-opacity);\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/DifficultyGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import { gatherData, prepareData } from \"./difficulty\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import TableData from \"./TableData.svelte\";\n    import PercentageRange from \"./PercentageRange.svelte\";\n    import { PercentageRangeEnum, PercentageRangeToQuantile } from \"./percentageRange\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let range = PercentageRangeEnum.All;\n\n    $: if (sourceData) {\n        [histogramData, tableData] = prepareData(\n            gatherData(sourceData),\n            dispatch,\n            $prefs.browserLinksSupported,\n            PercentageRangeToQuantile(range),\n        );\n    }\n\n    const title = tr.statisticsCardDifficultyTitle();\n    const subtitle = tr.statisticsCardDifficultySubtitle2();\n</script>\n\n{#if sourceData?.fsrs}\n    <Graph {title} {subtitle}>\n        <PercentageRange bind:range />\n\n        <HistogramGraph data={histogramData} />\n\n        <TableData {tableData} />\n    </Graph>\n{/if}\n"
  },
  {
    "path": "ts/routes/graphs/EaseGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import { gatherData, prepareData } from \"./ease\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n\n    $: if (sourceData) {\n        [histogramData, tableData] = prepareData(\n            gatherData(sourceData),\n            dispatch,\n            $prefs.browserLinksSupported,\n        );\n    }\n\n    const title = tr.statisticsCardEaseTitle();\n    const subtitle = tr.statisticsCardEaseSubtitle();\n</script>\n\n{#if !(sourceData?.fsrs ?? false)}\n    <Graph {title} {subtitle}>\n        <HistogramGraph data={histogramData} />\n\n        <TableData {tableData} />\n    </Graph>\n{/if}\n"
  },
  {
    "path": "ts/routes/graphs/FutureDue.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import type { GraphData } from \"./future-due\";\n    import { buildHistogram, gatherData } from \"./future-due\";\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import { GraphRange, RevlogRange } from \"./graph-helpers\";\n    import GraphRangeRadios from \"./GraphRangeRadios.svelte\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let graphData: GraphData | null = null;\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let graphRange: GraphRange = GraphRange.Month;\n\n    $: if (sourceData) {\n        graphData = gatherData(sourceData);\n    }\n\n    $: if (graphData) {\n        ({ histogramData, tableData } = buildHistogram(\n            graphData,\n            graphRange,\n            $prefs.futureDueShowBacklog,\n            dispatch,\n            $prefs.browserLinksSupported,\n        ));\n    }\n\n    const title = tr.statisticsFutureDueTitle();\n    const subtitle = tr.statisticsFutureDueSubtitle();\n    const backlogLabel = tr.statisticsBacklogCheckbox();\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        {#if graphData && graphData.haveBacklog}\n            <label>\n                <input type=\"checkbox\" bind:checked={$prefs.futureDueShowBacklog} />\n                {backlogLabel}\n            </label>\n        {/if}\n\n        <GraphRangeRadios bind:graphRange revlogRange={RevlogRange.All} />\n    </InputBox>\n\n    <HistogramGraph data={histogramData} />\n\n    <TableData {tableData} />\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/Graph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\n    // When title is null (default), the graph is inlined, not having TitledContainer wrapper.\n    export let title: string | null = null;\n    export let subtitle: string | null = null;\n</script>\n\n{#if title == null}\n    <div class=\"graph d-flex flex-grow-1 flex-column justify-content-center\">\n        {#if subtitle}\n            <div class=\"subtitle\">{subtitle}</div>\n        {/if}\n        <slot />\n    </div>\n{:else}\n    <TitledContainer class=\"d-flex flex-column\" {title}>\n        <slot name=\"tooltip\" slot=\"tooltip\"></slot>\n        <div class=\"graph d-flex flex-grow-1 flex-column justify-content-center\">\n            {#if subtitle}\n                <div class=\"subtitle\">{subtitle}</div>\n            {/if}\n            <slot />\n        </div>\n    </TitledContainer>\n{/if}\n\n<style lang=\"scss\">\n    @use \"$lib/sass/elevation\" as *;\n    .graph {\n        /* See graph-styles.ts for constants referencing global styles */\n        :global(.graph-element-clickable) {\n            cursor: pointer;\n        }\n\n        /* Customizing the standard x and y tick markers and text on the graphs.\n         * The `tick` class is automatically added by d3. */\n        :global(.tick) {\n            :global(line) {\n                opacity: 0.1;\n            }\n\n            :global(text) {\n                opacity: 0.5;\n                font-size: 10px;\n\n                @media only screen and (max-width: 800px) {\n                    font-size: 13px;\n                }\n\n                @media only screen and (max-width: 600px) {\n                    font-size: 16px;\n                }\n            }\n        }\n\n        :global(.tick-odd) {\n            @media only screen and (max-width: 600px) {\n                /* on small screens, hide every second row on graphs that have\n                 * marked the ticks as odd */\n                display: none;\n            }\n        }\n\n        &:focus {\n            outline: 0;\n        }\n    }\n\n    .subtitle {\n        text-align: center;\n        margin-bottom: 1em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/GraphRangeRadios.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { MONTH, timeSpan, YEAR } from \"@tslib/time\";\n\n    import { GraphRange, RevlogRange } from \"./graph-helpers\";\n\n    export let revlogRange: RevlogRange;\n    export let graphRange: GraphRange;\n    export let followRevlog: boolean = false;\n\n    function onFollowRevlog(revlogRange: RevlogRange) {\n        if (revlogRange === RevlogRange.All) {\n            graphRange = GraphRange.AllTime;\n        } else if (graphRange === GraphRange.AllTime) {\n            graphRange = GraphRange.Year;\n        }\n    }\n\n    $: if (followRevlog) {\n        // split into separate function so svelte does not\n        // run this when graphRange changes\n        onFollowRevlog(revlogRange);\n    }\n\n    const month = timeSpan(1 * MONTH);\n    const month3 = timeSpan(3 * MONTH);\n    const year = timeSpan(1 * YEAR);\n    const all = tr.statisticsRangeAllTime();\n</script>\n\n<label>\n    <input type=\"radio\" bind:group={graphRange} value={GraphRange.Month} />\n    {month}\n</label>\n<label>\n    <input type=\"radio\" bind:group={graphRange} value={GraphRange.ThreeMonths} />\n    {month3}\n</label>\n<label>\n    <input type=\"radio\" bind:group={graphRange} value={GraphRange.Year} />\n    {year}\n</label>\n{#if revlogRange === RevlogRange.All}\n    <label>\n        <input type=\"radio\" bind:group={graphRange} value={GraphRange.AllTime} />\n        {all}\n    </label>\n{/if}\n"
  },
  {
    "path": "ts/routes/graphs/GraphsPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { bridgeCommand } from \"@tslib/bridgecommand\";\n    import type { Component } from \"svelte\";\n    import { writable } from \"svelte/store\";\n\n    import { pageTheme } from \"$lib/sveltelib/theme\";\n\n    import RangeBox from \"./RangeBox.svelte\";\n    import WithGraphData from \"./WithGraphData.svelte\";\n\n    export let initialSearch: string;\n    export let initialDays: number;\n\n    const search = writable(initialSearch);\n    const days = writable(initialDays);\n\n    export let graphs: Component<any>[];\n    /** See RangeBox */\n    export let controller: Component<any> | null = RangeBox;\n\n    function browserSearch(event: CustomEvent) {\n        bridgeCommand(`browserSearch: ${$search} ${event.detail.query}`);\n    }\n</script>\n\n<WithGraphData {search} {days} let:sourceData let:loading let:prefs let:revlogRange>\n    {#if controller}\n        <svelte:component this={controller} {search} {days} {loading} />\n    {/if}\n\n    <div class=\"graphs-container\">\n        {#if sourceData && revlogRange}\n            {#each graphs as graph}\n                <svelte:component\n                    this={graph}\n                    {sourceData}\n                    {prefs}\n                    {revlogRange}\n                    nightMode={$pageTheme.isDark}\n                    on:search={browserSearch}\n                />\n            {/each}\n        {/if}\n    </div>\n    <div class=\"spacer\"></div>\n</WithGraphData>\n\n<style lang=\"scss\">\n    .graphs-container {\n        display: grid;\n        gap: 1em;\n        grid-template-columns: repeat(3, minmax(0, 1fr));\n        // required on Safari to stretch whole width\n        width: calc(100vw - 3em);\n        margin-left: 1em;\n        margin-right: 1em;\n\n        @media only screen and (max-width: 600px) {\n            width: calc(100vw - 1rem);\n            margin-left: 0.5rem;\n            margin-right: 0.5rem;\n        }\n\n        @media only screen and (max-width: 1400px) {\n            grid-template-columns: 1fr 1fr;\n        }\n        @media only screen and (max-width: 1200px) {\n            grid-template-columns: 1fr;\n        }\n        @media only screen and (max-width: 600px) {\n            font-size: 12px;\n        }\n\n        @media only print {\n            // grid layout does not honor page-break-inside\n            display: block;\n            margin-top: 3em;\n        }\n    }\n\n    .spacer {\n        height: 1.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/HistogramGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import AxisTicks from \"./AxisTicks.svelte\";\n    import CumulativeOverlay from \"./CumulativeOverlay.svelte\";\n    import { defaultGraphBounds } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import { histogramGraph } from \"./histogram-graph\";\n    import HoverColumns from \"./HoverColumns.svelte\";\n    import NoDataOverlay from \"./NoDataOverlay.svelte\";\n\n    export let data: HistogramData | null = null;\n\n    const bounds = defaultGraphBounds();\n    let svg: HTMLElement | SVGElement | null = null;\n\n    $: histogramGraph(svg as SVGElement, bounds, data);\n</script>\n\n<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n    <g class=\"bars\" />\n    <HoverColumns />\n    <CumulativeOverlay />\n    <AxisTicks {bounds} />\n    <NoDataOverlay {bounds} />\n</svg>\n"
  },
  {
    "path": "ts/routes/graphs/HourGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n\n    import AxisTicks from \"./AxisTicks.svelte\";\n    import CumulativeOverlay from \"./CumulativeOverlay.svelte\";\n    import Graph from \"./Graph.svelte\";\n    import type { RevlogRange } from \"./graph-helpers\";\n    import { defaultGraphBounds, GraphRange } from \"./graph-helpers\";\n    import GraphRangeRadios from \"./GraphRangeRadios.svelte\";\n    import { renderHours } from \"./hours\";\n    import HoverColumns from \"./HoverColumns.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import NoDataOverlay from \"./NoDataOverlay.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let revlogRange: RevlogRange;\n    let graphRange: GraphRange = GraphRange.Year;\n\n    const bounds = defaultGraphBounds();\n\n    let svg: HTMLElement | SVGElement | null = null;\n\n    $: if (sourceData) {\n        renderHours(svg as SVGElement, bounds, sourceData, graphRange);\n    }\n\n    const title = tr.statisticsHoursTitle();\n    const subtitle = tr.statisticsHoursSubtitle();\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        <GraphRangeRadios bind:graphRange {revlogRange} followRevlog={true} />\n    </InputBox>\n\n    <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n        <g class=\"bars\" />\n        <CumulativeOverlay />\n        <HoverColumns />\n        <AxisTicks {bounds} />\n        <NoDataOverlay {bounds} />\n    </svg>\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/HoverColumns.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\"></script>\n\n<g class=\"hover-columns\" />\n\n<style lang=\"scss\">\n    .hover-columns {\n        :global(rect) {\n            fill: none;\n            pointer-events: all;\n        }\n\n        :global(rect:hover) {\n            fill: grey;\n            opacity: 0.2;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/InputBox.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n</script>\n\n<div>\n    <slot />\n</div>\n\n<style lang=\"scss\">\n    div {\n        display: flex;\n        justify-content: center;\n\n        @media only screen and (max-device-width: 480px) and (orientation: portrait) {\n            font-size: smaller;\n        }\n\n        & > :global(*) {\n            padding-left: 0.5em;\n            padding-right: 0.5em;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/IntervalsGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { MONTH, timeSpan } from \"@tslib/time\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import type { IntervalGraphData } from \"./intervals\";\n    import {\n        gatherIntervalData,\n        IntervalRange,\n        prepareIntervalData,\n    } from \"./intervals\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let intervalData: IntervalGraphData | null = null;\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let range = IntervalRange.Percentile95;\n\n    $: if (sourceData) {\n        intervalData = gatherIntervalData(sourceData.intervals!);\n    }\n\n    $: if (intervalData) {\n        [histogramData, tableData] = prepareIntervalData(\n            intervalData,\n            range,\n            dispatch,\n            $prefs.browserLinksSupported,\n            false,\n        );\n    }\n\n    const title = tr.statisticsIntervalsTitle();\n    const subtitle = tr.statisticsIntervalsSubtitle();\n    const month = timeSpan(1 * MONTH);\n    const all = tr.statisticsRangeAllTime();\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        <label>\n            <input type=\"radio\" bind:group={range} value={IntervalRange.Month} />\n            {month}\n        </label>\n        <label>\n            <input type=\"radio\" bind:group={range} value={IntervalRange.Percentile50} />\n            50%\n        </label>\n        <label>\n            <input type=\"radio\" bind:group={range} value={IntervalRange.Percentile95} />\n            95%\n        </label>\n        <label>\n            <input type=\"radio\" bind:group={range} value={IntervalRange.All} />\n            {all}\n        </label>\n    </InputBox>\n\n    <HistogramGraph data={histogramData} />\n\n    <TableData {tableData} />\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/NoDataOverlay.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import type { GraphBounds } from \"./graph-helpers\";\n\n    export let bounds: GraphBounds;\n\n    const noData = tr.statisticsNoData();\n</script>\n\n<g class=\"no-data\">\n    <rect x=\"0\" y=\"0\" width={bounds.width} height={bounds.height} />\n    <text x=\"{bounds.width / 2},\" y={bounds.height / 2}>{noData}</text>\n</g>\n\n<style lang=\"scss\">\n    .no-data {\n        rect {\n            fill: var(--canvas-elevated);\n        }\n\n        text {\n            text-anchor: middle;\n            fill: grey;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/PercentageRange.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import InputBox from \"./InputBox.svelte\";\n    import { PercentageRangeEnum } from \"./percentageRange\";\n    import * as tr from \"@generated/ftl\";\n\n    export let range: PercentageRangeEnum;\n</script>\n\n<InputBox>\n    <label>\n        <input\n            type=\"radio\"\n            bind:group={range}\n            value={PercentageRangeEnum.Percentile50}\n        />\n        50%\n    </label>\n    <label>\n        <input\n            type=\"radio\"\n            bind:group={range}\n            value={PercentageRangeEnum.Percentile95}\n        />\n        95%\n    </label>\n    <label>\n        <input\n            type=\"radio\"\n            bind:group={range}\n            value={PercentageRangeEnum.Percentile100}\n        />\n        100%\n    </label>\n    <label>\n        <input type=\"radio\" bind:group={range} value={PercentageRangeEnum.All} />\n        {tr.statisticsRangeAllTime()}\n    </label>\n</InputBox>\n"
  },
  {
    "path": "ts/routes/graphs/RangeBox.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import type { Writable } from \"svelte/store\";\n\n    import { daysToRevlogRange, RevlogRange } from \"./graph-helpers\";\n    import InputBox from \"./InputBox.svelte\";\n\n    enum SearchRange {\n        Deck = 1,\n        Collection = 2,\n        Custom = 3,\n    }\n\n    export let loading: boolean;\n\n    export let days: Writable<number>;\n    export let search: Writable<string>;\n\n    let revlogRange = daysToRevlogRange($days);\n    let searchRange: SearchRange;\n\n    if ($search === \"deck:current\") {\n        searchRange = SearchRange.Deck;\n    } else if ($search === \"\") {\n        searchRange = SearchRange.Collection;\n    } else {\n        searchRange = SearchRange.Custom;\n    }\n\n    let displayedSearch = $search;\n\n    $: {\n        switch (searchRange) {\n            case SearchRange.Deck:\n                $search = displayedSearch = \"deck:current\";\n                break;\n            case SearchRange.Collection:\n                $search = displayedSearch = \"\";\n                break;\n        }\n    }\n\n    $: {\n        switch (revlogRange) {\n            case RevlogRange.Year:\n                $days = 365;\n                break;\n            case RevlogRange.All:\n                $days = 0;\n                break;\n        }\n    }\n\n    function updateSearch(): void {\n        $search = displayedSearch;\n    }\n\n    const year = tr.statisticsRange1YearHistory();\n    const deck = tr.statisticsRangeDeck();\n    const collection = tr.statisticsRangeCollection();\n    const searchLabel = tr.statisticsRangeSearch();\n    const all = tr.statisticsRangeAllHistory();\n</script>\n\n<div class=\"range-box\">\n    <div class=\"spin\" class:loading>◐</div>\n\n    <InputBox>\n        <label>\n            <input type=\"radio\" bind:group={searchRange} value={SearchRange.Deck} />\n            {deck}\n        </label>\n        <label>\n            <input\n                type=\"radio\"\n                bind:group={searchRange}\n                value={SearchRange.Collection}\n            />\n            {collection}\n        </label>\n\n        <!-- This form is an external API and care should be taken when changed -\n\tother clients e.g. AnkiDroid programmatically update this form by id -->\n        <input\n            type=\"text\"\n            id=\"statisticsSearchText\"\n            bind:value={displayedSearch}\n            on:change={updateSearch}\n            on:focus={() => {\n                searchRange = SearchRange.Custom;\n            }}\n            placeholder={searchLabel}\n        />\n    </InputBox>\n\n    <InputBox>\n        <label>\n            <input type=\"radio\" bind:group={revlogRange} value={RevlogRange.Year} />\n            {year}\n        </label>\n        <label>\n            <input type=\"radio\" bind:group={revlogRange} value={RevlogRange.All} />\n            {all}\n        </label>\n    </InputBox>\n</div>\n\n<div class=\"range-box-pad\"></div>\n\n<style lang=\"scss\">\n    label {\n        display: inline-flex;\n        align-items: center;\n    }\n\n    input[type=\"radio\"] {\n        margin-inline-end: 0.3em;\n    }\n\n    .range-box {\n        position: sticky;\n        z-index: 1;\n        top: 0;\n        width: 100vw;\n        color: var(--fg);\n        background: var(--canvas);\n        padding: 0.5em;\n        border-bottom: 1px solid var(--border);\n\n        @media print {\n            position: absolute;\n        }\n    }\n\n    @keyframes spin {\n        0% {\n            -webkit-transform: rotate(0deg);\n        }\n        100% {\n            -webkit-transform: rotate(360deg);\n        }\n    }\n\n    .spin {\n        display: inline-block;\n        position: absolute;\n        font-size: 2em;\n        animation: spin;\n        animation-duration: 1s;\n        animation-iteration-count: infinite;\n        z-index: -1;\n\n        opacity: 0;\n\n        &.loading {\n            opacity: 0.5;\n            z-index: 1;\n            transition: opacity var(--transition-slow);\n        }\n    }\n\n    .range-box-pad {\n        height: 1.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/RetrievabilityGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import { gatherData, prepareData } from \"./retrievability\";\n    import TableData from \"./TableData.svelte\";\n    import PercentageRange from \"./PercentageRange.svelte\";\n    import { PercentageRangeEnum, PercentageRangeToQuantile } from \"./percentageRange\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let range = PercentageRangeEnum.All;\n\n    $: if (sourceData) {\n        [histogramData, tableData] = prepareData(\n            gatherData(sourceData),\n            dispatch,\n            $prefs.browserLinksSupported,\n            PercentageRangeToQuantile(range),\n        );\n    }\n\n    const title = tr.statisticsCardRetrievabilityTitle();\n    const subtitle = tr.statisticsRetrievabilitySubtitle();\n</script>\n\n{#if sourceData?.fsrs}\n    <Graph {title} {subtitle}>\n        <PercentageRange bind:range />\n\n        <HistogramGraph data={histogramData} />\n\n        <TableData {tableData} />\n    </Graph>\n{/if}\n"
  },
  {
    "path": "ts/routes/graphs/ReviewsGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n\n    import AxisTicks from \"./AxisTicks.svelte\";\n    import CumulativeOverlay from \"./CumulativeOverlay.svelte\";\n    import Graph from \"./Graph.svelte\";\n    import type { RevlogRange, TableDatum } from \"./graph-helpers\";\n    import { defaultGraphBounds, GraphRange } from \"./graph-helpers\";\n    import GraphRangeRadios from \"./GraphRangeRadios.svelte\";\n    import HoverColumns from \"./HoverColumns.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import NoDataOverlay from \"./NoDataOverlay.svelte\";\n    import type { GraphData } from \"./reviews\";\n    import { gatherData, renderReviews } from \"./reviews\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let revlogRange: RevlogRange;\n\n    let graphData: GraphData | null = null;\n\n    const bounds = defaultGraphBounds();\n    let svg: HTMLElement | SVGElement | null = null;\n    let graphRange: GraphRange = GraphRange.Month;\n    let showTime = false;\n\n    $: if (sourceData) {\n        graphData = gatherData(sourceData);\n    }\n\n    let tableData: TableDatum[] = [];\n    $: if (graphData) {\n        tableData = renderReviews(\n            svg as SVGElement,\n            bounds,\n            graphData,\n            graphRange,\n            showTime,\n        );\n    }\n\n    const title = tr.statisticsReviewsTitle();\n    const time = tr.statisticsReviewsTimeCheckbox();\n\n    let subtitle = \"\";\n    $: if (showTime) {\n        subtitle = tr.statisticsReviewsTimeSubtitle();\n    } else {\n        subtitle = tr.statisticsReviewsCountSubtitle();\n    }\n</script>\n\n<Graph {title} {subtitle}>\n    <InputBox>\n        <label>\n            <input type=\"checkbox\" bind:checked={showTime} />\n            {time}\n        </label>\n\n        <GraphRangeRadios bind:graphRange {revlogRange} followRevlog={true} />\n    </InputBox>\n\n    <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>\n        {#each [4, 3, 2, 1, 0] as i}\n            <g class=\"bars{i}\" />\n        {/each}\n        <CumulativeOverlay />\n        <HoverColumns />\n        <AxisTicks {bounds} />\n        <NoDataOverlay {bounds} />\n    </svg>\n\n    <TableData {tableData} />\n</Graph>\n"
  },
  {
    "path": "ts/routes/graphs/StabilityGraph.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { MONTH, timeSpan } from \"@tslib/time\";\n    import { createEventDispatcher } from \"svelte\";\n\n    import Graph from \"./Graph.svelte\";\n    import type { GraphPrefs } from \"./graph-helpers\";\n    import type { SearchEventMap, TableDatum } from \"./graph-helpers\";\n    import type { HistogramData } from \"./histogram-graph\";\n    import HistogramGraph from \"./HistogramGraph.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import type { IntervalGraphData } from \"./intervals\";\n    import {\n        gatherIntervalData,\n        IntervalRange,\n        prepareIntervalData,\n    } from \"./intervals\";\n    import TableData from \"./TableData.svelte\";\n\n    export let sourceData: GraphsResponse | null = null;\n    export let prefs: GraphPrefs;\n\n    const dispatch = createEventDispatcher<SearchEventMap>();\n\n    let intervalData: IntervalGraphData | null = null;\n    let histogramData: HistogramData | null = null;\n    let tableData: TableDatum[] = [];\n    let range = IntervalRange.Percentile95;\n\n    $: if (sourceData) {\n        intervalData = gatherIntervalData(sourceData.stability!);\n    }\n\n    $: if (intervalData) {\n        [histogramData, tableData] = prepareIntervalData(\n            intervalData,\n            range,\n            dispatch,\n            $prefs.browserLinksSupported,\n            true,\n        );\n    }\n\n    const title = tr.statisticsCardStabilityTitle();\n    const subtitle = tr.statisticsCardStabilitySubtitle();\n    const month = timeSpan(1 * MONTH);\n    const all = tr.statisticsRangeAllTime();\n</script>\n\n{#if sourceData?.fsrs}\n    <Graph {title} {subtitle}>\n        <InputBox>\n            <label>\n                <input type=\"radio\" bind:group={range} value={IntervalRange.Month} />\n                {month}\n            </label>\n            <label>\n                <input\n                    type=\"radio\"\n                    bind:group={range}\n                    value={IntervalRange.Percentile50}\n                />\n                50%\n            </label>\n            <label>\n                <input\n                    type=\"radio\"\n                    bind:group={range}\n                    value={IntervalRange.Percentile95}\n                />\n                95%\n            </label>\n            <label>\n                <input type=\"radio\" bind:group={range} value={IntervalRange.All} />\n                {all}\n            </label>\n        </InputBox>\n\n        <HistogramGraph data={histogramData} />\n\n        <TableData {tableData} />\n    </Graph>\n{/if}\n"
  },
  {
    "path": "ts/routes/graphs/TableData.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { direction } from \"@tslib/i18n\";\n\n    import type { TableDatum } from \"./graph-helpers\";\n\n    export let tableData: TableDatum[];\n</script>\n\n<div>\n    <table dir={direction()}>\n        <tbody>\n            {#each tableData as { label, value }}\n                <tr>\n                    <td class=\"align-end\">{label}:</td>\n                    <td class=\"align-start\">{value}</td>\n                </tr>\n            {/each}\n        </tbody>\n    </table>\n</div>\n\n<style lang=\"scss\">\n    div {\n        display: flex;\n        justify-content: center;\n    }\n\n    td {\n        padding: 3px;\n    }\n\n    .align-end {\n        text-align: end;\n    }\n\n    .align-start {\n        text-align: start;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/TodayStats.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n\n    import Graph from \"./Graph.svelte\";\n    import type { TodayData } from \"./today\";\n    import { gatherData } from \"./today\";\n\n    export let sourceData: GraphsResponse | null = null;\n\n    let todayData: TodayData | null = null;\n    $: if (sourceData) {\n        todayData = gatherData(sourceData);\n    }\n</script>\n\n{#if todayData}\n    <Graph title={todayData.title}>\n        <div class=\"legend\">\n            {#each todayData.lines as line}\n                <div>{line}</div>\n            {/each}\n        </div>\n    </Graph>\n{/if}\n\n<style lang=\"scss\">\n    .legend {\n        text-align: center;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/Tooltip.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { tick } from \"svelte\";\n\n    export let html = \"\";\n    export let x: number = 0;\n    export let y: number = 0;\n    export let show = true;\n\n    let container: HTMLDivElement | null = null;\n\n    let adjustedX: number, adjustedY: number;\n\n    let shiftLeftAmount = 0;\n    $: onXChange(x);\n\n    async function onXChange(xPos: number) {\n        await tick();\n        shiftLeftAmount = container\n            ? Math.round(\n                  container.clientWidth * 1.2 * (xPos / document.body.clientWidth),\n              )\n            : 0;\n    }\n\n    $: {\n        // move tooltip away from edge as user approaches right side\n        adjustedX = x + 40 - shiftLeftAmount;\n        adjustedY = y + 40;\n    }\n</script>\n\n<div\n    bind:this={container}\n    class=\"tooltip\"\n    style=\"left: {adjustedX}px; top: {adjustedY}px; opacity: {show ? 1 : 0}\"\n>\n    {@html html}\n</div>\n\n<style lang=\"scss\">\n    .tooltip {\n        position: absolute;\n        white-space: nowrap;\n        padding: 15px;\n        border-radius: 5px;\n        font-family: inherit;\n        font-size: 15px;\n        opacity: 0;\n        pointer-events: none;\n        transition: opacity var(--transition);\n        color: var(--fg);\n        background: var(--canvas-overlay);\n        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n\n        :global(table) {\n            border-spacing: 1em 0;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/TrueRetention.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import { type RevlogRange } from \"./graph-helpers\";\n    import { DisplayMode, type PeriodTrueRetentionData, Scope } from \"./true-retention\";\n\n    import Graph from \"./Graph.svelte\";\n    import InputBox from \"./InputBox.svelte\";\n    import TrueRetentionCombined from \"./TrueRetentionCombined.svelte\";\n    import TrueRetentionSingle from \"./TrueRetentionSingle.svelte\";\n    import { assertUnreachable } from \"@tslib/typing\";\n\n    interface Props {\n        revlogRange: RevlogRange;\n        sourceData: GraphsResponse | null;\n    }\n\n    const { revlogRange, sourceData = null }: Props = $props();\n\n    const retentionData: PeriodTrueRetentionData | null = $derived.by(() => {\n        if (sourceData === null) {\n            return null;\n        } else {\n            // Assert that all the True Retention data will be defined\n            return sourceData.trueRetention as PeriodTrueRetentionData;\n        }\n    });\n\n    const retentionHelp = {\n        trueRetention: {\n            title: tr.statisticsTrueRetentionTitle(),\n            help: tr.statisticsTrueRetentionTooltip(),\n        },\n    };\n\n    const helpSections: HelpItem[] = Object.values(retentionHelp);\n\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n\n    let mode: DisplayMode = $state(DisplayMode.Summary);\n\n    const title = tr.statisticsTrueRetentionTitle();\n    const subtitle = tr.statisticsTrueRetentionSubtitle();\n    const onHelpClick = () => {\n        openHelpModal(Object.keys(retentionHelp).indexOf(\"trueRetention\"));\n    };\n</script>\n\n<Graph {title} {subtitle}>\n    <div\n        slot=\"tooltip\"\n        onclick={onHelpClick}\n        onkeydown={onHelpClick}\n        role=\"button\"\n        tabindex=\"-1\"\n    >\n        <HelpModal\n            title={tr.statisticsTrueRetentionTitle()}\n            url={HelpPage.DeckOptions.desiredRetention}\n            linkLabel={tr.deckConfigDesiredRetention()}\n            {helpSections}\n            on:mount={(e) => {\n                modal = e.detail.modal;\n                carousel = e.detail.carousel;\n            }}\n        />\n    </div>\n    <InputBox>\n        <label>\n            <input type=\"radio\" bind:group={mode} value={DisplayMode.Young} />\n            {tr.statisticsTrueRetentionYoung()}\n        </label>\n\n        <label>\n            <input type=\"radio\" bind:group={mode} value={DisplayMode.Mature} />\n            {tr.statisticsTrueRetentionMature()}\n        </label>\n\n        <label>\n            <input type=\"radio\" bind:group={mode} value={DisplayMode.Summary} />\n            {tr.statisticsTrueRetentionAll()}\n        </label>\n    </InputBox>\n\n    <div class=\"table-container\">\n        {#if retentionData === null}\n            <div>{tr.statisticsNoData()}</div>\n        {:else if mode === DisplayMode.Young}\n            <TrueRetentionSingle\n                {revlogRange}\n                data={retentionData}\n                scope={Scope.Young}\n            />\n        {:else if mode === DisplayMode.Mature}\n            <TrueRetentionSingle\n                {revlogRange}\n                data={retentionData}\n                scope={Scope.Mature}\n            />\n        {:else if mode === DisplayMode.All}\n            <TrueRetentionSingle {revlogRange} data={retentionData} scope={Scope.All} />\n        {:else if mode === DisplayMode.Summary}\n            <TrueRetentionCombined {revlogRange} data={retentionData} />\n        {:else}\n            {assertUnreachable(mode)}\n        {/if}\n    </div>\n</Graph>\n\n<style>\n    .table-container {\n        margin-top: 1rem;\n        overflow-x: auto;\n\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/TrueRetentionCombined.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { localizedNumber } from \"@tslib/i18n\";\n\n    import { type RevlogRange } from \"./graph-helpers\";\n    import {\n        calculateRetentionPercentageString,\n        getRowData,\n        type PeriodTrueRetentionData,\n        type RowData,\n    } from \"./true-retention\";\n\n    interface Props {\n        revlogRange: RevlogRange;\n        data: PeriodTrueRetentionData;\n    }\n\n    const { revlogRange, data }: Props = $props();\n\n    const rowData: RowData[] = $derived(getRowData(data, revlogRange));\n</script>\n\n<table>\n    <thead>\n        <tr>\n            <td></td>\n            <th scope=\"col\" class=\"col-header young\">\n                {tr.statisticsTrueRetentionYoung()}\n            </th>\n            <th scope=\"col\" class=\"col-header mature\">\n                {tr.statisticsTrueRetentionMature()}\n            </th>\n            <th scope=\"col\" class=\"col-header total\">\n                {tr.statisticsTrueRetentionTotal()}\n            </th>\n            <th scope=\"col\" class=\"col-header count\">\n                {tr.statisticsTrueRetentionCount()}\n            </th>\n        </tr>\n    </thead>\n\n    <tbody>\n        {#each rowData as row}\n            {@const totalPassed = row.data.youngPassed + row.data.maturePassed}\n            {@const totalFailed = row.data.youngFailed + row.data.matureFailed}\n\n            <tr>\n                <th scope=\"row\" class=\"row-header\">{row.title}</th>\n\n                <td class=\"young\">\n                    {calculateRetentionPercentageString(\n                        row.data.youngPassed,\n                        row.data.youngFailed,\n                    )}\n                </td>\n                <td class=\"mature\">\n                    {calculateRetentionPercentageString(\n                        row.data.maturePassed,\n                        row.data.matureFailed,\n                    )}\n                </td>\n                <td class=\"total\">\n                    {calculateRetentionPercentageString(totalPassed, totalFailed)}\n                </td>\n\n                <td class=\"count\">{localizedNumber(totalPassed + totalFailed)}</td>\n            </tr>\n        {/each}\n    </tbody>\n</table>\n\n<style lang=\"scss\">\n    @use \"true-retention-base\";\n\n    .young,\n    .mature,\n    .total,\n    .count {\n        text-align: right;\n    }\n\n    .young {\n        color: #64c476;\n    }\n\n    .mature {\n        color: #31a354;\n    }\n\n    .total {\n        color: var(--fg);\n    }\n\n    .count {\n        color: var(--fg-subtle);\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/TrueRetentionSingle.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { type RevlogRange } from \"./graph-helpers\";\n    import {\n        calculateRetentionPercentageString,\n        getFailed,\n        getPassed,\n        getRowData,\n        type PeriodTrueRetentionData,\n        type RowData,\n        type Scope,\n    } from \"./true-retention\";\n    import { localizedNumber } from \"@tslib/i18n\";\n\n    interface Props {\n        revlogRange: RevlogRange;\n        data: PeriodTrueRetentionData;\n        scope: Scope;\n    }\n\n    const { revlogRange, data, scope }: Props = $props();\n\n    const rowData: RowData[] = $derived(getRowData(data, revlogRange));\n</script>\n\n<table>\n    <thead>\n        <tr>\n            <td></td>\n            <th scope=\"col\" class=\"col-header pass\">\n                {tr.statisticsTrueRetentionPass()}\n            </th>\n            <th scope=\"col\" class=\"col-header fail\">\n                {tr.statisticsTrueRetentionFail()}\n            </th>\n            <th scope=\"col\" class=\"col-header retention\">\n                {tr.statisticsTrueRetentionRetention()}\n            </th>\n        </tr>\n    </thead>\n\n    <tbody>\n        {#each rowData as row}\n            {@const passed = getPassed(row.data, scope)}\n            {@const failed = getFailed(row.data, scope)}\n\n            <tr>\n                <th scope=\"row\" class=\"row-header\">{row.title}</th>\n\n                <td class=\"pass\">{localizedNumber(passed)}</td>\n                <td class=\"fail\">{localizedNumber(failed)}</td>\n                <td class=\"retention\">\n                    {calculateRetentionPercentageString(passed, failed)}\n                </td>\n            </tr>\n        {/each}\n    </tbody>\n</table>\n\n<style lang=\"scss\">\n    @use \"true-retention-base\";\n\n    .pass,\n    .fail,\n    .retention {\n        text-align: right;\n    }\n\n    .pass {\n        color: #3bc464;\n    }\n\n    .fail {\n        color: #c43b3b;\n    }\n\n    .retention {\n        color: var(--fg);\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/graphs/WithGraphData.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { GraphsResponse } from \"@generated/anki/stats_pb\";\n    import {\n        getGraphPreferences,\n        graphs,\n        setGraphPreferences,\n    } from \"@generated/backend\";\n    import type { Writable } from \"svelte/store\";\n\n    import { autoSavingPrefs } from \"$lib/sveltelib/preferences\";\n\n    import { daysToRevlogRange } from \"./graph-helpers\";\n\n    export let search: Writable<string>;\n    export let days: Writable<number>;\n\n    const prefsPromise = autoSavingPrefs(\n        () => getGraphPreferences({}),\n        setGraphPreferences,\n    );\n\n    let sourceData: GraphsResponse | null = null;\n    let loading = true;\n    $: updateSourceData($search, $days);\n\n    async function updateSourceData(search: string, days: number): Promise<void> {\n        // ensure the fast-loading preferences come first\n        await prefsPromise;\n        loading = true;\n        try {\n            sourceData = await graphs({ search, days });\n        } finally {\n            loading = false;\n        }\n    }\n\n    $: revlogRange = daysToRevlogRange($days);\n</script>\n\n<!--\nWe block graphs loading until the preferences have been fetched, so graphs\ndon't have to worry about a null initial value. We don't do the same for the\ngraph data, as it gets updated as the user changes options, and we don't want\nthe current graphs to disappear until the new graphs have finished loading.\n-->\n{#await prefsPromise then prefs}\n    <slot {revlogRange} {prefs} {sourceData} {loading} />\n{/await}\n"
  },
  {
    "path": "ts/routes/graphs/_true-retention-base.scss",
    "content": "table {\n    border-collapse: collapse;\n\n    margin-left: auto;\n    margin-right: auto;\n}\n\ntr {\n    border-top: 1px solid var(--border);\n    border-bottom: 1px solid var(--border);\n}\n\ntd,\nth {\n    padding-left: 0.5em;\n    padding-right: 0.5em;\n}\n\n.row-header {\n    color: var(--fg);\n}\n"
  },
  {
    "path": "ts/routes/graphs/added.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { dayLabel } from \"@tslib/time\";\nimport type { Bin } from \"d3\";\nimport { bin, interpolateBlues, min, scaleLinear, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { getNumericMapBinValue, GraphRange, numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\n\nexport interface GraphData {\n    daysAdded: Map<number, number>;\n}\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    return { daysAdded: numericMap(data.added!.added) };\n}\n\nfunction makeQuery(start: number, end: number): string {\n    const include = `\"added:${start}\"`;\n\n    if (start === 1) {\n        return include;\n    }\n\n    const exclude = `-\"added:${end}\"`;\n    return `${include} AND ${exclude}`;\n}\n\nexport function buildHistogram(\n    data: GraphData,\n    range: GraphRange,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n): [HistogramData | null, TableDatum[]] {\n    // get min/max\n    const total = data.daysAdded.size;\n    if (!total) {\n        return [null, []];\n    }\n\n    let xMin: number;\n\n    // cap max to selected range\n    switch (range) {\n        case GraphRange.Month:\n            xMin = -31;\n            break;\n        case GraphRange.ThreeMonths:\n            xMin = -90;\n            break;\n        case GraphRange.Year:\n            xMin = -365;\n            break;\n        case GraphRange.AllTime:\n            xMin = min(data.daysAdded.keys())!;\n            break;\n    }\n    const xMax = 1;\n    const desiredBars = Math.min(70, Math.abs(xMin!));\n\n    const scale = scaleLinear().domain([xMin!, xMax]);\n    const bins = bin()\n        .value((m) => {\n            return m[0];\n        })\n        .domain(scale.domain() as any)\n        .thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);\n\n    // empty graph?\n    const accessor = getNumericMapBinValue as any;\n    if (!sum(bins, accessor)) {\n        return [null, []];\n    }\n\n    const adjustedRange = scaleLinear().range([0.7, 0.3]);\n    const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]);\n\n    const totalInPeriod = sum(bins, accessor);\n    const periodDays = Math.abs(xMin!);\n    const cardsPerDay = Math.round(totalInPeriod / periodDays);\n    const tableData = [\n        {\n            label: tr.statisticsTotal(),\n            value: tr.statisticsCards({ cards: totalInPeriod }),\n        },\n        {\n            label: tr.statisticsAverage(),\n            value: tr.statisticsCardsPerDay({ count: cardsPerDay }),\n        },\n    ];\n\n    function hoverText(\n        bin: Bin<number, number>,\n        cumulative: number,\n        _percent: number,\n    ): string {\n        const day = dayLabel(bin.x0!, bin.x1!);\n        const cards = tr.statisticsCards({ cards: accessor(bin) });\n        const total = tr.statisticsRunningTotal();\n        const totalCards = tr.statisticsCards({ cards: cumulative });\n        return `${day}:<br>${cards}<br>${total}: ${totalCards}`;\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = Math.abs(bin.x0!) + 1;\n        const end = Math.abs(bin.x1!) + 1;\n        const query = makeQuery(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    return [\n        {\n            scale,\n            bins,\n            total: totalInPeriod,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            colourScale,\n            binValue: getNumericMapBinValue,\n            showArea: true,\n        },\n        tableData,\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/buttons.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport {\n    axisBottom,\n    axisLeft,\n    interpolateRdYlGn,\n    pointer,\n    scaleBand,\n    scaleLinear,\n    scaleSequential,\n    select,\n    sum,\n} from \"d3\";\n\nimport type { GraphBounds } from \"./graph-helpers\";\nimport { GraphRange } from \"./graph-helpers\";\nimport { setDataAvailable } from \"./graph-helpers\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\n/** 4 element array */\ntype ButtonCounts = number[];\n\nexport interface GraphData {\n    learning: ButtonCounts;\n    young: ButtonCounts;\n    mature: ButtonCounts;\n}\n\nexport function gatherData(data: GraphsResponse, range: GraphRange): GraphData {\n    const buttons = data.buttons!;\n    switch (range) {\n        case GraphRange.Month:\n            return buttons.oneMonth!;\n        case GraphRange.ThreeMonths:\n            return buttons.threeMonths!;\n        case GraphRange.Year:\n            return buttons.oneYear!;\n        case GraphRange.AllTime:\n            return buttons.allTime!;\n    }\n}\n\ntype GroupKind = \"learning\" | \"young\" | \"mature\";\n\ninterface Datum {\n    buttonNum: number;\n    group: GroupKind;\n    count: number;\n}\n\ninterface TotalCorrect {\n    total: number;\n    correct: number;\n    percent: string;\n}\n\nexport function renderButtons(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    origData: GraphsResponse,\n    range: GraphRange,\n): void {\n    const sourceData = gatherData(origData, range);\n    const data = [\n        ...sourceData.learning.map((count: number, idx: number) => {\n            return {\n                buttonNum: idx + 1,\n                group: \"learning\",\n                count,\n            } satisfies Datum;\n        }),\n        ...sourceData.young.map((count: number, idx: number) => {\n            return {\n                buttonNum: idx + 1,\n                group: \"young\",\n                count,\n            } satisfies Datum;\n        }),\n        ...sourceData.mature.map((count: number, idx: number) => {\n            return {\n                buttonNum: idx + 1,\n                group: \"mature\",\n                count,\n            } satisfies Datum;\n        }),\n    ];\n\n    const totalCorrect = (kind: GroupKind): TotalCorrect => {\n        const groupData = data.filter((d) => d.group == kind);\n        const total = sum(groupData, (d) => d.count);\n        const correct = sum(\n            groupData.filter((d) => d.buttonNum > 1),\n            (d) => d.count,\n        );\n        const percent = total ? localizedNumber((correct / total) * 100) : \"0\";\n        return { total, correct, percent };\n    };\n\n    const totalPressedStr = (data: Datum): string => {\n        const groupTotal = totalCorrect(data.group).total;\n        const buttonTotal = data.count;\n        const percent = groupTotal\n            ? localizedNumber((buttonTotal / groupTotal) * 100)\n            : \"0\";\n\n        return `${localizedNumber(buttonTotal)} (${percent}%)`;\n    };\n\n    const yMax = Math.max(...data.map((d) => d.count));\n\n    const svg = select(svgElem);\n    const trans = svg.transition().duration(600) as any;\n\n    if (!yMax) {\n        setDataAvailable(svg, false);\n        return;\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    const xGroup = scaleBand()\n        .domain([\"learning\", \"young\", \"mature\"])\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisBottom(xGroup)\n                    .tickFormat(\n                        ((d: GroupKind) => {\n                            let kind: string;\n                            switch (d) {\n                                case \"learning\":\n                                    kind = tr.statisticsCountsLearningCards();\n                                    break;\n                                case \"young\":\n                                    kind = tr.statisticsCountsYoungCards();\n                                    break;\n                                case \"mature\":\n                                default:\n                                    kind = tr.statisticsCountsMatureCards();\n                                    break;\n                            }\n                            return `${kind}`;\n                        }) as any,\n                    )\n                    .tickSizeOuter(0),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    const xButton = scaleBand()\n        .domain([\"1\", \"2\", \"3\", \"4\"])\n        .range([0, xGroup.bandwidth()])\n        .paddingOuter(1)\n        .paddingInner(0.1);\n\n    const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]);\n\n    // y scale\n    const yTickFormat = (n: number): string => localizedNumber(n);\n\n    const y = scaleLinear()\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop])\n        .domain([0, yMax]);\n    svg.select<SVGGElement>(\".y-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisLeft(y)\n                    .ticks(bounds.height / 50)\n                    .tickSizeOuter(0)\n                    .tickFormat(yTickFormat as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    // x bars\n\n    const updateBar = (sel: any): any => {\n        return sel\n            .attr(\"width\", xButton.bandwidth())\n            .attr(\"opacity\", 0.8)\n            .transition(trans)\n            .attr(\n                \"x\",\n                (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!,\n            )\n            .attr(\"y\", (d: Datum) => y(d.count)!)\n            .attr(\"height\", (d: Datum) => y(0)! - y(d.count)!)\n            .attr(\"fill\", (d: Datum) => colour(d.buttonNum));\n    };\n\n    svg.select(\"g.bars\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\n            (enter) =>\n                enter\n                    .append(\"rect\")\n                    .attr(\"rx\", 1)\n                    .attr(\n                        \"x\",\n                        (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!,\n                    )\n                    .attr(\"y\", y(0)!)\n                    .attr(\"height\", 0)\n                    .call(updateBar),\n            (update) => update.call(updateBar),\n            (remove) => remove.call((remove) => remove.transition(trans).attr(\"height\", 0).attr(\"y\", y(0)!)),\n        );\n\n    // hover/tooltip\n\n    function tooltipText(d: Datum): string {\n        const button = tr.statisticsAnswerButtonsButtonNumber();\n        const timesPressed = tr.statisticsAnswerButtonsButtonPressed();\n        const correctStr = tr.statisticsHoursCorrect(totalCorrect(d.group));\n        const correctStrInfo = tr.statisticsHoursCorrectInfo();\n        const pressedStr = `${timesPressed}: ${totalPressedStr(d)}`;\n\n        let buttonText: string;\n        if (d.buttonNum === 1) {\n            buttonText = tr.studyingAgain();\n        } else if (d.buttonNum === 2) {\n            buttonText = tr.studyingHard();\n        } else if (d.buttonNum === 3) {\n            buttonText = tr.studyingGood();\n        } else if (d.buttonNum === 4) {\n            buttonText = tr.studyingEasy();\n        } else {\n            buttonText = \"\";\n        }\n\n        return `${button}: ${d.buttonNum} (${buttonText})<br>${pressedStr}<br>${correctStr} ${correctStrInfo}`;\n    }\n\n    svg.select(\"g.hover-columns\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\"rect\")\n        .attr(\"x\", (d: Datum) => xGroup(d.group)! + xButton(d.buttonNum.toString())!)\n        .attr(\"y\", () => y(yMax!)!)\n        .attr(\"width\", xButton.bandwidth())\n        .attr(\"height\", () => y(0)! - y(yMax!)!)\n        .on(\"mousemove\", (event: MouseEvent, d: Datum) => {\n            const [x, y] = pointer(event, document.body);\n            showTooltip(tooltipText(d), x, y);\n        })\n        .on(\"mouseout\", hideTooltip);\n}\n"
  },
  {
    "path": "ts/routes/graphs/calendar.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport { GraphPreferences_Weekday as Weekday } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { firstLanguage } from \"@generated/ftl\";\nimport { localizedDate, weekdayLabel } from \"@tslib/i18n\";\nimport type { CountableTimeInterval } from \"d3\";\nimport { timeHour } from \"d3\";\nimport {\n    interpolateBlues,\n    pointer,\n    scaleLinear,\n    scaleSequentialSqrt,\n    select,\n    timeDay,\n    timeFriday,\n    timeMonday,\n    timeSaturday,\n    timeSunday,\n    timeYear,\n} from \"d3\";\n\nimport type { GraphBounds, SearchDispatch } from \"./graph-helpers\";\nimport { RevlogRange, setDataAvailable } from \"./graph-helpers\";\nimport { clickableClass } from \"./graph-styles\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\nexport interface GraphData {\n    // indexed by day, where day is relative to today\n    reviewCount: Map<number, number>;\n    timeFunction: CountableTimeInterval;\n    weekdayLabels: number[];\n    rolloverHour: number;\n}\n\ninterface DayDatum {\n    day: number;\n    count: number;\n    // 0-51\n    weekNumber: number;\n    // 0-6\n    weekDay: number;\n    date: Date;\n}\n\nexport function gatherData(\n    data: GraphsResponse,\n    firstDayOfWeek: Weekday,\n): GraphData {\n    const reviewCount = new Map(\n        Object.entries(data.reviews!.count).map(([k, v]) => {\n            return [Number(k), v.learn + v.relearn + v.mature + v.filtered + v.young];\n        }),\n    );\n    const timeFunction = timeFunctionForDay(firstDayOfWeek);\n    const weekdayLabels: number[] = [];\n    for (let i = 0; i < 7; i++) {\n        weekdayLabels.push((firstDayOfWeek + i) % 7);\n    }\n\n    return { reviewCount, timeFunction, weekdayLabels, rolloverHour: data.rolloverHour };\n}\n\nexport function renderCalendar(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    sourceData: GraphData,\n    dispatch: SearchDispatch,\n    targetYear: number,\n    nightMode: boolean,\n    revlogRange: RevlogRange,\n    setFirstDayOfWeek: (d: number) => void,\n): void {\n    const svg = select(svgElem);\n    const now = new Date();\n    const nowForYear = new Date();\n    nowForYear.setFullYear(targetYear);\n\n    const x = scaleLinear()\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight])\n        .domain([-1, 53]);\n\n    // map of 0-365 -> day\n    const dayMap: Map<number, DayDatum> = new Map();\n    let maxCount = 0;\n    for (const [day, count] of sourceData.reviewCount.entries()) {\n        let date = timeDay.offset(now, day);\n        // anki day does not necessarily roll over at midnight, we account for this when mapping onto calendar days\n        date = timeHour.offset(date, -1 * sourceData.rolloverHour);\n        if (count > maxCount) {\n            maxCount = count;\n        }\n        if (date.getFullYear() != targetYear) {\n            continue;\n        }\n        const weekNumber = sourceData.timeFunction.count(timeYear(date), date);\n        const weekDay = timeDay.count(sourceData.timeFunction(date), date);\n        const yearDay = timeDay.count(timeYear(date), date);\n        dayMap.set(yearDay, { day, count, weekNumber, weekDay, date });\n    }\n\n    if (!maxCount) {\n        setDataAvailable(svg, false);\n        return;\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    // fill in any blanks, including the current calendar day even if the anki day has not rolled over\n    const startDate = timeYear(nowForYear);\n    const oneYearAgoFromNow = new Date(now);\n    oneYearAgoFromNow.setFullYear(now.getFullYear() - 1);\n    for (let i = 0; i < 365; i++) {\n        const date = timeDay.offset(startDate, i);\n        if (date > now) {\n            // don't fill out future dates\n            continue;\n        }\n        if (revlogRange == RevlogRange.Year && date < oneYearAgoFromNow) {\n            // don't fill out dates older than a year\n            continue;\n        }\n        const yearDay = timeDay.count(timeYear(date), date);\n        if (!dayMap.has(yearDay)) {\n            const weekNumber = sourceData.timeFunction.count(timeYear(date), date);\n            const weekDay = timeDay.count(sourceData.timeFunction(date), date);\n            dayMap.set(yearDay, {\n                day: yearDay,\n                count: 0,\n                weekNumber,\n                weekDay,\n                date,\n            });\n        }\n    }\n    const data = Array.from(dayMap.values());\n    const cappedRange = scaleLinear().range([0.2, nightMode ? 0.8 : 1]);\n    const blues = scaleSequentialSqrt()\n        .domain([0, maxCount])\n        .interpolator((n) => interpolateBlues(cappedRange(n)!));\n\n    function tooltipText(d: DayDatum): string {\n        const date = localizedDate(d.date, {\n            weekday: \"long\",\n            year: \"numeric\",\n            month: \"long\",\n            day: \"numeric\",\n        });\n        const cards = tr.statisticsReviews({ reviews: d.count });\n        return `${date}<br>${cards}`;\n    }\n\n    const height = bounds.height / 10;\n    const emptyColour = nightMode ? \"#333\" : \"#ddd\";\n\n    const firstLang = firstLanguage();\n\n    svg.select(\"g.weekdays\")\n        .selectAll(\"text\")\n        .data(sourceData.weekdayLabels)\n        .join(\"text\")\n        .text((d: number) => weekdayLabel(d))\n        .attr(\"width\", x(-1)! - 2)\n        .attr(\"height\", height - 2)\n        .attr(\"x\", x(1)! - 3)\n        .attr(\"y\", (_d, index) => bounds.marginTop + index * height)\n        .attr(\"fill\", nightMode ? \"#ddd\" : \"black\")\n        .attr(\"dominant-baseline\", \"hanging\")\n        .attr(\"text-anchor\", \"end\")\n        .attr(\"font-size\", firstLang.includes(\"zh\") ? \"xx-small\" : \"small\")\n        .attr(\"font-family\", \"monospace\")\n        .attr(\"direction\", \"ltr\")\n        .style(\"user-select\", \"none\")\n        .on(\"click\", null)\n        .filter((d: number) =>\n            [Weekday.SUNDAY, Weekday.MONDAY, Weekday.FRIDAY, Weekday.SATURDAY].includes(\n                d,\n            )\n        )\n        .on(\"click\", (_event: MouseEvent, d: number) => setFirstDayOfWeek(d));\n\n    svg.select(\"g.days\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\"rect\")\n        .attr(\"fill\", emptyColour)\n        .attr(\"width\", (d: DayDatum) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2)\n        .attr(\"height\", height - 2)\n        .attr(\"x\", (d: DayDatum) => x(d.weekNumber + 1)!)\n        .attr(\"y\", (d: DayDatum) => bounds.marginTop + d.weekDay * height)\n        .on(\"mousemove\", (event: MouseEvent, d: DayDatum) => {\n            const [x, y] = pointer(event, document.body);\n            showTooltip(tooltipText(d), x, y);\n        })\n        .on(\"mouseout\", hideTooltip)\n        .attr(\"class\", (d: DayDatum): string => (d.count > 0 ? clickableClass : \"\"))\n        .on(\"click\", function(_event: MouseEvent, d: DayDatum) {\n            if (d.count > 0) {\n                dispatch(\"search\", { query: `\"prop:rated=${d.day}\"` });\n            }\n        })\n        .transition()\n        .duration(800)\n        .attr(\"fill\", (d: DayDatum) => (d.count === 0 ? emptyColour : blues(d.count)!));\n}\n\nfunction timeFunctionForDay(firstDayOfWeek: Weekday): CountableTimeInterval {\n    switch (firstDayOfWeek) {\n        case Weekday.MONDAY:\n            return timeMonday;\n        case Weekday.FRIDAY:\n            return timeFriday;\n        case Weekday.SATURDAY:\n            return timeSaturday;\n        default:\n            return timeSunday;\n    }\n}\n"
  },
  {
    "path": "ts/routes/graphs/card-counts.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport {\n    arc,\n    cumsum,\n    interpolate,\n    pie,\n    scaleLinear,\n    schemeBlues,\n    schemeGreens,\n    schemeOranges,\n    schemeReds,\n    select,\n    sum,\n} from \"d3\";\n\nimport type { GraphBounds } from \"./graph-helpers\";\n\ntype Count = [string, number, boolean, string];\nexport interface GraphData {\n    title: string;\n    counts: Count[];\n    totalCards: string;\n}\n\nconst barColours = [\n    schemeBlues[5][2], /* new */\n    schemeOranges[5][2], /* learn */\n    schemeReds[5][2], /* relearn */\n    schemeGreens[5][2], /* young */\n    schemeGreens[5][3], /* mature */\n    \"#FFDC41\", /* suspended */\n    \"grey\", /* buried */\n];\n\nfunction countCards(data: GraphsResponse, separateInactive: boolean): Count[] {\n    const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!;\n\n    const extraQuery = separateInactive ? \"AND -(\\\"is:buried\\\" OR \\\"is:suspended\\\")\" : \"\";\n\n    const counts: Count[] = [\n        [tr.statisticsCountsNewCards(), countData.newCards, true, `\"is:new\"${extraQuery}`],\n        [\n            tr.statisticsCountsLearningCards(),\n            countData.learn,\n            true,\n            `(-\"is:review\" AND \"is:learn\")${extraQuery}`,\n        ],\n        [\n            tr.statisticsCountsRelearningCards(),\n            countData.relearn,\n            true,\n            `(\"is:review\" AND \"is:learn\")${extraQuery}`,\n        ],\n        [\n            tr.statisticsCountsYoungCards(),\n            countData.young,\n            true,\n            `(\"is:review\" AND -\"is:learn\") AND \"prop:ivl<21\"${extraQuery}`,\n        ],\n        [\n            tr.statisticsCountsMatureCards(),\n            countData.mature,\n            true,\n            `(\"is:review\" -\"is:learn\") AND \"prop:ivl>=21\"${extraQuery}`,\n        ],\n        [\n            tr.statisticsCountsSuspendedCards(),\n            countData.suspended,\n            separateInactive,\n            \"\\\"is:suspended\\\"\",\n        ],\n        [tr.statisticsCountsBuriedCards(), countData.buried, separateInactive, \"\\\"is:buried\\\"\"],\n    ];\n\n    return counts;\n}\n\nexport function gatherData(\n    data: GraphsResponse,\n    separateInactive: boolean,\n): GraphData {\n    const counts = countCards(data, separateInactive);\n    const totalCards = localizedNumber(sum(counts, e => e[1]));\n\n    return {\n        title: tr.statisticsCountsTitle(),\n        counts,\n        totalCards,\n    };\n}\n\nexport interface SummedDatum {\n    label: string;\n    // count of this particular item\n    count: number;\n    // show up in the table\n    show: boolean;\n    query: string;\n    // running total\n    total: number;\n}\n\nexport interface TableDatum {\n    label: string;\n    count: string;\n    query: string;\n    percent: string;\n    colour: string;\n}\n\nexport function renderCards(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    sourceData: GraphData,\n): TableDatum[] {\n    const summed = cumsum(sourceData.counts, (d: Count) => d[1]);\n    const data = Array.from(summed).map((n, idx) => {\n        const count = sourceData.counts[idx];\n        return {\n            label: count[0],\n            count: count[1],\n            show: count[2],\n            query: count[3],\n            total: n,\n        } satisfies SummedDatum;\n    });\n    // ensuring a non-zero range makes the percentages not break\n    // in an empty collection\n    const xMax = Math.max(1, summed.slice(-1)[0]);\n    const x = scaleLinear().domain([0, xMax]);\n    const svg = select(svgElem);\n    const paths = svg.select(\".counts\");\n    const pieData = pie()(sourceData.counts.map((d: Count) => d[1]));\n    const radius = bounds.height / 2 - bounds.marginTop - bounds.marginBottom;\n    const arcGen = arc().innerRadius(0).outerRadius(radius);\n    const trans = svg.transition().duration(600) as any;\n\n    paths\n        .attr(\"transform\", `translate(${radius},${radius + bounds.marginTop})`)\n        .selectAll(\"path\")\n        .data(pieData)\n        .join(\n            (enter) =>\n                enter\n                    .append(\"path\")\n                    .attr(\"fill\", (_d, idx) => {\n                        return barColours[idx];\n                    })\n                    .attr(\"d\", arcGen as any),\n            function(update) {\n                return update.call((d) =>\n                    d.transition(trans).attrTween(\"d\", (d) => {\n                        const interpolator = interpolate(\n                            { startAngle: 0, endAngle: 0 },\n                            d,\n                        );\n                        return (t): string => arcGen(interpolator(t) as any) as string;\n                    })\n                );\n            },\n        );\n\n    x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n\n    const tableData = data.flatMap((d: SummedDatum, idx: number) => {\n        const percent = localizedNumber((d.count / xMax) * 100, 2);\n        return d.show\n            ? ({\n                label: d.label,\n                count: localizedNumber(d.count),\n                percent: `${percent}%`,\n                colour: barColours[idx],\n                query: d.query,\n            } satisfies TableDatum)\n            : [];\n    });\n\n    return tableData;\n}\n"
  },
  {
    "path": "ts/routes/graphs/difficulty.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport type { Bin } from \"d3\";\nimport { bin, interpolateRdYlGn, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { getNumericMapBinValue, numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\nimport { getAdjustedScaleAndTicks, percentageRangeMinMax } from \"./percentageRange\";\n\nexport interface GraphData {\n    eases: Map<number, number>;\n    average: number;\n}\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    return { eases: numericMap(data.difficulty!.eases), average: data.difficulty!.average };\n}\n\nfunction makeQuery(start: number, end: number): string {\n    const fromQuery = `\"prop:d>=${start / 100}\"`;\n    let tillQuery = `\"prop:d<${(end + 1) / 100}\"`;\n    if (end === 99) {\n        tillQuery = tillQuery.replace(\"<\", \"<=\");\n    }\n    return `${fromQuery} AND ${tillQuery}`;\n}\n\nexport function prepareData(\n    data: GraphData,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n    quantile?: number,\n): [HistogramData | null, TableDatum[]] {\n    // get min/max\n    const allEases = data.eases;\n    if (!allEases.size) {\n        return [null, []];\n    }\n    const [xMin, xMax] = percentageRangeMinMax(allEases, quantile);\n    const desiredBars = 20;\n\n    const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);\n\n    const bins = bin()\n        .value((m) => {\n            return m[0];\n        })\n        .domain(scale.domain() as [number, number])\n        .thresholds(ticks)(allEases.entries() as any);\n    const total = sum(bins as any, getNumericMapBinValue);\n\n    const colourScale = scaleSequential(interpolateRdYlGn).domain([100, 0]);\n\n    function hoverText(bin: Bin<number, number>, _percent: number): string {\n        const percent = `${bin.x0}%-${bin.x1}%`;\n        return tr.statisticsCardDifficultyTooltip({\n            cards: getNumericMapBinValue(bin as any),\n            percent,\n        });\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = bin.x0!;\n        const end = bin.x1! - 1;\n        const query = makeQuery(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    const xTickFormat = (num: number): string => localizedNumber(num, 0) + \"%\";\n    const tableData = [\n        {\n            label: tr.statisticsMedianDifficulty(),\n            value: xTickFormat(data.average),\n        },\n    ];\n\n    return [\n        {\n            scale,\n            bins,\n            total,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            colourScale,\n            showArea: false,\n            binValue: getNumericMapBinValue,\n            xTickFormat,\n        },\n        tableData,\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/ease.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport type { Bin, ScaleLinear } from \"d3\";\nimport { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { getNumericMapBinValue, numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\n\nexport interface GraphData {\n    eases: Map<number, number>;\n    average: number;\n}\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    return { eases: numericMap(data.eases!.eases), average: data.eases!.average };\n}\n\nfunction makeQuery(start: number, end: number): string {\n    if (start === end) {\n        return `\"prop:ease=${start / 100}\"`;\n    }\n\n    const fromQuery = `\"prop:ease>=${start / 100}\"`;\n    const tillQuery = `\"prop:ease<${(end + 1) / 100}\"`;\n\n    return `${fromQuery} AND ${tillQuery}`;\n}\n\nfunction getAdjustedScaleAndTicks(\n    min: number,\n    max: number,\n    desiredBars: number,\n): [ScaleLinear<number, number, never>, number[]] {\n    const prescale = scaleLinear().domain([min, max]).nice();\n    const ticks = prescale.ticks(desiredBars);\n\n    const predomain = prescale.domain() as [number, number];\n\n    const minOffset = min - predomain[0];\n    const tickSize = ticks[1] - ticks[0];\n\n    if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) {\n        return [prescale, ticks];\n    }\n\n    const add = (n: number): number => n + minOffset;\n    return [\n        scaleLinear().domain(predomain.map(add) as [number, number]),\n        ticks.map(add),\n    ];\n}\n\nexport function prepareData(\n    data: GraphData,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n): [HistogramData | null, TableDatum[]] {\n    // get min/max\n    const allEases = data.eases;\n    if (!allEases.size) {\n        return [null, []];\n    }\n    const [, origXMax] = extent(allEases.keys());\n    const xMin = 130;\n    const xMax = origXMax! + 1;\n    const desiredBars = 20;\n\n    const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);\n\n    const bins = bin()\n        .value((m) => {\n            return m[0];\n        })\n        .domain(scale.domain() as [number, number])\n        .thresholds(ticks)(allEases.entries() as any);\n    const total = sum(bins as any, getNumericMapBinValue);\n\n    const colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]);\n\n    function hoverText(bin: Bin<number, number>, _percent: number): string {\n        const minPct = Math.floor(bin.x0!);\n        const maxPct = Math.floor(bin.x1!);\n        const percent = maxPct - minPct <= 10 ? `${bin.x0}%` : `${bin.x0}%-${bin.x1}%`;\n        return tr.statisticsCardEaseTooltip({\n            cards: getNumericMapBinValue(bin as any),\n            percent,\n        });\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = bin.x0!;\n        const end = bin.x1! - 1;\n        const query = makeQuery(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    const xTickFormat = (num: number): string => localizedNumber(num, 0) + \"%\";\n    const tableData = [\n        {\n            label: tr.statisticsMedianEase(),\n            value: xTickFormat(data.average),\n        },\n    ];\n\n    return [\n        {\n            scale,\n            bins,\n            total,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            colourScale,\n            showArea: false,\n            binValue: getNumericMapBinValue,\n            xTickFormat,\n        },\n        tableData,\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/future-due.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport { dayLabel } from \"@tslib/time\";\nimport type { Bin } from \"d3\";\nimport { bin, extent, interpolateGreens, scaleLinear, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { getNumericMapBinValue, GraphRange, numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\n\nexport interface GraphData {\n    dueCounts: Map<number, number>;\n    haveBacklog: boolean;\n    dailyLoad: number;\n}\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    const msg = data.futureDue!;\n    return {\n        dueCounts: numericMap(msg.futureDue),\n        haveBacklog: msg.haveBacklog,\n        dailyLoad: msg.dailyLoad,\n    };\n}\n\nexport interface FutureDueResponse {\n    histogramData: HistogramData | null;\n    tableData: TableDatum[];\n}\n\nfunction makeQuery(start: number, end: number): string {\n    if (start === end) {\n        return `\"prop:due=${start}\"`;\n    } else {\n        const fromQuery = `\"prop:due>=${start}\"`;\n        const tillQuery = `\"prop:due<=${end}\"`;\n        return `${fromQuery} AND ${tillQuery}`;\n    }\n}\n\nfunction withoutBacklog(data: Map<number, number>): Map<number, number> {\n    const map = new Map();\n    for (const [day, count] of data.entries()) {\n        if (day >= 0) {\n            map.set(day, count);\n        }\n    }\n    return map;\n}\n\nexport function buildHistogram(\n    sourceData: GraphData,\n    range: GraphRange,\n    includeBacklog: boolean,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n): FutureDueResponse {\n    const output = { histogramData: null, tableData: [] };\n    // get min/max\n    const data = includeBacklog ? sourceData.dueCounts : withoutBacklog(sourceData.dueCounts);\n    if (!data) {\n        return output;\n    }\n\n    const [xMinOrig, origXMax] = extent<number>(data.keys());\n    let xMin = xMinOrig;\n    if (!includeBacklog) {\n        xMin = 0;\n    }\n    let xMax = origXMax;\n\n    // cap max to selected range\n    switch (range) {\n        case GraphRange.Month:\n            xMax = 31;\n            break;\n        case GraphRange.ThreeMonths:\n            xMax = 90;\n            break;\n        case GraphRange.Year:\n            xMax = 365;\n            break;\n        case GraphRange.AllTime:\n            break;\n    }\n    // cap bars to available range\n    const desiredBars = Math.min(70, xMax! - xMin!);\n\n    const x = scaleLinear().domain([xMin!, xMax!]);\n    const bins = bin()\n        .value((m) => {\n            return m[0];\n        })\n        .domain(x.domain() as any)\n        .thresholds(x.ticks(desiredBars))(data.entries() as any);\n\n    // empty graph?\n    if (!sum(bins, (bin) => bin.length)) {\n        return output;\n    }\n\n    const xTickFormat = (n: number): string => localizedNumber(n);\n    const adjustedRange = scaleLinear().range([0.7, 0.3]);\n    const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]);\n\n    const total = sum(bins as any, getNumericMapBinValue);\n\n    function hoverText(\n        bin: Bin<number, number>,\n        cumulative: number,\n        _percent: number,\n    ): string {\n        const days = dayLabel(bin.x0!, bin.x1 === xMax ? bin.x1! + 1 : bin.x1!);\n        const cards = tr.statisticsCardsDue({\n            cards: getNumericMapBinValue(bin as any),\n        });\n        const totalLabel = tr.statisticsRunningTotal();\n\n        return `${days}:<br>${cards}<br>${totalLabel}: ${localizedNumber(cumulative)}`;\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = bin.x0!;\n        // x1 in last bin is inclusive\n        const end = bin.x1 === xMax ? bin.x1! : bin.x1! - 1;\n        const query = makeQuery(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    const periodDays = xMax! - xMin!;\n    const tableData = [\n        {\n            label: tr.statisticsTotal(),\n            value: tr.statisticsReviews({ reviews: total }),\n        },\n        {\n            label: tr.statisticsAverage(),\n            value: tr.statisticsReviewsPerDay({\n                count: Math.round(total / periodDays),\n            }),\n        },\n        {\n            label: tr.statisticsDueTomorrow(),\n            value: tr.statisticsReviews({\n                reviews: sourceData.dueCounts.get(1) ?? 0,\n            }),\n        },\n        {\n            label: tr.statisticsDailyLoad(),\n            value: tr.statisticsReviewsPerDay({\n                count: sourceData.dailyLoad,\n            }),\n        },\n    ];\n\n    return {\n        histogramData: {\n            scale: x,\n            bins,\n            total,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            showArea: true,\n            colourScale,\n            binValue: getNumericMapBinValue,\n            xTickFormat,\n        },\n        tableData,\n    };\n}\n"
  },
  {
    "path": "ts/routes/graphs/graph-helpers.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n@typescript-eslint/ban-ts-comment: \"off\" */\n\nimport type { GraphPreferences } from \"@generated/anki/stats_pb\";\nimport type { Bin, Selection } from \"d3\";\nimport { sum } from \"d3\";\n\nimport type { PreferenceStore } from \"$lib/sveltelib/preferences\";\n\n// amount of data to fetch from backend\nexport enum RevlogRange {\n    Year = 1,\n    All = 2,\n}\n\nexport function daysToRevlogRange(days: number): RevlogRange {\n    return days > 365 || days === 0 ? RevlogRange.All : RevlogRange.Year;\n}\n\n// period a graph should cover\nexport enum GraphRange {\n    Month = 0,\n    ThreeMonths = 1,\n    Year = 2,\n    AllTime = 3,\n}\n\nexport interface GraphBounds {\n    width: number;\n    height: number;\n    marginLeft: number;\n    marginRight: number;\n    marginTop: number;\n    marginBottom: number;\n}\n\nexport function defaultGraphBounds(): GraphBounds {\n    return {\n        width: 600,\n        height: 250,\n        marginLeft: 70,\n        marginRight: 70,\n        marginTop: 20,\n        marginBottom: 25,\n    };\n}\n\nexport type GraphPrefs = PreferenceStore<GraphPreferences>;\n\nexport function setDataAvailable(\n    svg: Selection<SVGElement, any, any, any>,\n    available: boolean,\n): void {\n    svg.select(\".no-data\")\n        .attr(\"pointer-events\", available ? \"none\" : \"all\")\n        .transition()\n        .duration(600)\n        .attr(\"opacity\", available ? 0 : 1);\n}\n\nexport function millisecondCutoffForRange(\n    range: GraphRange,\n    nextDayAtSecs: number,\n): number {\n    let days: number;\n    switch (range) {\n        case GraphRange.Month:\n            days = 31;\n            break;\n        case GraphRange.ThreeMonths:\n            days = 90;\n            break;\n        case GraphRange.Year:\n            days = 365;\n            break;\n        case GraphRange.AllTime:\n        default:\n            return 0;\n    }\n\n    return (nextDayAtSecs - 86400 * days) * 1000;\n}\n\nexport interface TableDatum {\n    label: string;\n    value: string;\n}\n\nexport type SearchEventMap = { search: { query: string } };\nexport type SearchDispatch = <EventKey extends Extract<keyof SearchEventMap, string>>(\n    type: EventKey,\n    detail: SearchEventMap[EventKey],\n) => void;\n\n/** Convert a protobuf map that protobufjs represents as an object with string\nkeys into a Map with numeric keys. */\nexport function numericMap<T>(obj: { [k: string]: T }): Map<number, T> {\n    return new Map(Object.entries(obj).map(([k, v]) => [Number(k), v]));\n}\n\nexport function getNumericMapBinValue(d: Bin<Map<number, number>, number>): number {\n    return sum(d, (d) => d[1]);\n}\n"
  },
  {
    "path": "ts/routes/graphs/graph-styles.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// Global css classes used by subcomponents\n\n// Graph.svelte\nexport const oddTickClass = \"tick-odd\";\nexport const clickableClass = \"graph-element-clickable\";\n\n// It would be nice to define these in the svelte file that declares them,\n// but currently this trips the tooling up:\n// https://github.com/sveltejs/svelte/issues/5817\n// export { oddTickClass, clickableClass } from \"./Graph.svelte\";\n"
  },
  {
    "path": "ts/routes/graphs/graphs-base.scss",
    "content": "@use \"$lib/sass/root-vars\";\n@import \"$lib/sass/base\";\n\nbutton {\n    margin-bottom: 5px;\n}\n\nhtml {\n    height: initial;\n}\n"
  },
  {
    "path": "ts/routes/graphs/histogram-graph.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport { localizedNumber } from \"@tslib/i18n\";\nimport type { Bin, ScaleLinear, ScaleSequential } from \"d3\";\nimport { area, axisBottom, axisLeft, axisRight, cumsum, curveBasis, max, pointer, scaleLinear, select } from \"d3\";\n\nimport type { GraphBounds } from \"./graph-helpers\";\nimport { setDataAvailable } from \"./graph-helpers\";\nimport { clickableClass } from \"./graph-styles\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\nexport interface HistogramData {\n    scale: ScaleLinear<number, number>;\n    bins: Bin<number, number>[];\n    total: number;\n    hoverText: (\n        bin: Bin<number, number>,\n        cumulative: number,\n        percent: number,\n    ) => string;\n    onClick: ((data: Bin<number, number>) => void) | null;\n    showArea: boolean;\n    colourScale: ScaleSequential<string>;\n    binValue?: (bin: Bin<any, any>) => number;\n    xTickFormat?: (d: any) => string;\n}\n\nexport function histogramGraph(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    data: HistogramData | null,\n): void {\n    const svg = select(svgElem);\n    const trans = svg.transition().duration(600) as any;\n    const axisTickFormat = (n: number): string => localizedNumber(n);\n\n    if (!data) {\n        setDataAvailable(svg, false);\n        return;\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    const binValue = data.binValue ?? ((bin: Bin<any, any>) => bin.length);\n\n    const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisBottom(x)\n                    .ticks(7)\n                    .tickSizeOuter(0)\n                    .tickFormat((data.xTickFormat ?? axisTickFormat) as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    // y scale\n\n    const yMax = max(data.bins, (d) => binValue(d))!;\n    const y = scaleLinear()\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop])\n        .domain([0, yMax])\n        .nice();\n    svg.select<SVGGElement>(\".y-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisLeft(y)\n                    .ticks(bounds.height / 50)\n                    .tickSizeOuter(0)\n                    .tickFormat(axisTickFormat as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    // x bars\n\n    function barWidth(d: Bin<number, number>): number {\n        const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1);\n        return width ?? 0;\n    }\n\n    const updateBar = (sel: any): any => {\n        return sel\n            .attr(\"width\", barWidth)\n            .transition(trans)\n            .attr(\"x\", (d: any) => x(d.x0))\n            .attr(\"y\", (d: any) => y(binValue(d))!)\n            .attr(\"height\", (d: any) => y(0)! - y(binValue(d))!)\n            .attr(\"fill\", (d: any) => data.colourScale(d.x1));\n    };\n\n    svg.select(\"g.bars\")\n        .selectAll(\"rect\")\n        .data(data.bins)\n        .join(\n            (enter) =>\n                enter\n                    .append(\"rect\")\n                    .attr(\"rx\", 1)\n                    .attr(\"x\", (d: any) => x(d.x0)!)\n                    .attr(\"y\", y(0)!)\n                    .attr(\"height\", 0)\n                    .call(updateBar),\n            (update) => update.call(updateBar),\n            (remove) => remove.call((remove) => remove.transition(trans).attr(\"height\", 0).attr(\"y\", y(0)!)),\n        );\n\n    // cumulative area\n\n    const areaCounts = data.bins.map((d) => binValue(d));\n    areaCounts.unshift(0);\n    const areaData = cumsum(areaCounts);\n    const yAreaScale = y.copy().domain([0, data.total]).nice();\n\n    if (data.showArea && data.bins.length && areaData.slice(-1)[0]) {\n        svg.select<SVGGElement>(\".y2-ticks\")\n            .call((selection) =>\n                selection.transition(trans).call(\n                    axisRight(yAreaScale)\n                        .ticks(bounds.height / 50)\n                        .tickSizeOuter(0)\n                        .tickFormat(axisTickFormat as any),\n                )\n            )\n            .attr(\"direction\", \"ltr\");\n\n        svg.select(\"path.cumulative-overlay\")\n            .datum(areaData as any)\n            .attr(\n                \"d\",\n                area()\n                    .curve(curveBasis)\n                    .x((_d, idx) => {\n                        if (idx === 0) {\n                            return x(data.bins[0].x0!)!;\n                        } else {\n                            return x(data.bins[idx - 1].x1!)!;\n                        }\n                    })\n                    .y0(bounds.height - bounds.marginBottom)\n                    .y1((d: any) => yAreaScale(d)!) as any,\n            );\n    }\n\n    const hoverData: [Bin<number, number>, number][] = data.bins.map(\n        (bin: Bin<number, number>, index: number) => [bin, areaData[index + 1]],\n    );\n\n    // hover/tooltip\n    const hoverzone = svg\n        .select(\"g.hover-columns\")\n        .selectAll(\"rect\")\n        .data(hoverData)\n        .join(\"rect\")\n        .attr(\"x\", ([bin]) => x(bin.x0!))\n        .attr(\"y\", () => y(yMax))\n        .attr(\"width\", ([bin]) => barWidth(bin))\n        .attr(\"height\", () => y(0) - y(yMax))\n        .on(\"mousemove\", (event: MouseEvent, [bin, area]) => {\n            const [x, y] = pointer(event, document.body);\n            const pct = data.showArea ? (area / data.total) * 100 : 0;\n            showTooltip(data.hoverText(bin, area, pct), x, y);\n        })\n        .on(\"mouseout\", hideTooltip);\n\n    if (data.onClick) {\n        hoverzone\n            .filter(([bin]) => bin.length > 0)\n            .attr(\"class\", clickableClass)\n            .on(\"click\", (_event, [bin]) => data.onClick!(bin));\n    }\n}\n"
  },
  {
    "path": "ts/routes/graphs/hours.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport type { GraphsResponse_Hours_Hour } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport {\n    area,\n    axisBottom,\n    axisLeft,\n    axisRight,\n    curveBasis,\n    interpolateBlues,\n    pointer,\n    scaleBand,\n    scaleLinear,\n    scaleSequential,\n    select,\n} from \"d3\";\n\nimport type { GraphBounds } from \"./graph-helpers\";\nimport { GraphRange, setDataAvailable } from \"./graph-helpers\";\nimport { oddTickClass } from \"./graph-styles\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\ninterface Hour {\n    hour: number;\n    totalCount: number;\n    correctCount: number;\n}\n\nfunction gatherData(data: GraphsResponse, range: GraphRange): Hour[] {\n    function convert(hours: GraphsResponse_Hours_Hour[]): Hour[] {\n        return hours.map((e, idx) => {\n            return { hour: idx, totalCount: e.total!, correctCount: e.correct! };\n        });\n    }\n    switch (range) {\n        case GraphRange.Month:\n            return convert(data.hours!.oneMonth);\n        case GraphRange.ThreeMonths:\n            return convert(data.hours!.threeMonths);\n        case GraphRange.Year:\n            return convert(data.hours!.oneYear);\n        case GraphRange.AllTime:\n            return convert(data.hours!.allTime);\n    }\n}\n\nexport function renderHours(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    origData: GraphsResponse,\n    range: GraphRange,\n): void {\n    const data = gatherData(origData, range);\n\n    const yMax = Math.max(...data.map((d) => d.totalCount));\n\n    const svg = select(svgElem);\n    const trans = svg.transition().duration(600) as any;\n\n    if (!yMax) {\n        setDataAvailable(svg, false);\n        return;\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    const x = scaleBand()\n        .domain(data.map((d) => d.hour.toString()))\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight])\n        .paddingInner(0.1);\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) => selection.transition(trans).call(axisBottom(x).tickSizeOuter(0)))\n        .selectAll(\".tick\")\n        .selectAll(\"text\")\n        .classed(oddTickClass, (d: any): boolean => d % 2 != 0)\n        .attr(\"direction\", \"ltr\");\n\n    const cappedRange = scaleLinear().range([0.1, 0.8]);\n    const colour = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([\n        0,\n        yMax,\n    ]);\n\n    // y scale\n    const yTickFormat = (n: number): string => localizedNumber(n);\n\n    const y = scaleLinear()\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop])\n        .domain([0, yMax])\n        .nice();\n    svg.select<SVGGElement>(\".y-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisLeft(y)\n                    .ticks(bounds.height / 50)\n                    .tickSizeOuter(0)\n                    .tickFormat(yTickFormat as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    const yArea = y.copy().domain([0, 1]);\n\n    // x bars\n\n    const updateBar = (sel: any): any => {\n        return sel\n            .attr(\"width\", x.bandwidth())\n            .transition(trans)\n            .attr(\"x\", (d: Hour) => x(d.hour.toString())!)\n            .attr(\"y\", (d: Hour) => y(d.totalCount)!)\n            .attr(\"height\", (d: Hour) => y(0)! - y(d.totalCount)!)\n            .attr(\"fill\", (d: Hour) => colour(d.totalCount!));\n    };\n\n    svg.select(\"g.bars\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\n            (enter) =>\n                enter\n                    .append(\"rect\")\n                    .attr(\"rx\", 1)\n                    .attr(\"x\", (d: Hour) => x(d.hour.toString())!)\n                    .attr(\"y\", y(0)!)\n                    .attr(\"height\", 0)\n                    // .attr(\"opacity\", 0.7)\n                    .call(updateBar),\n            (update) => update.call(updateBar),\n            (remove) => remove.call((remove) => remove.transition(trans).attr(\"height\", 0).attr(\"y\", y(0)!)),\n        );\n\n    svg.select<SVGGElement>(\".y2-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisRight(yArea)\n                    .ticks(bounds.height / 50)\n                    .tickFormat((n: any) => `${Math.round(n * 100)}%`)\n                    .tickSizeOuter(0),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    svg.select(\"path.cumulative-overlay\")\n        .datum(data)\n        .attr(\n            \"d\",\n            area<Hour>()\n                .curve(curveBasis)\n                .defined((d) => d.totalCount > 0)\n                .x((d: Hour) => {\n                    return x(d.hour.toString())! + x.bandwidth() / 2;\n                })\n                .y0(bounds.height - bounds.marginBottom)\n                .y1((d: Hour) => {\n                    const correctRatio = d.correctCount! / d.totalCount!;\n                    return yArea(isNaN(correctRatio) ? 0 : correctRatio)!;\n                }),\n        );\n\n    function tooltipText(d: Hour): string {\n        const hour = tr.statisticsHoursRange({\n            hourStart: d.hour,\n            hourEnd: d.hour + 1,\n        });\n        const reviews = tr.statisticsHoursReviews({ reviews: d.totalCount });\n        const correct = tr.statisticsHoursCorrectReviews({\n            percent: d.totalCount ? (d.correctCount / d.totalCount) * 100 : 0,\n            reviews: d.correctCount,\n        });\n        return `${hour}<br>${reviews}<br>${correct}`;\n    }\n\n    // hover/tooltip\n    svg.select(\"g.hover-columns\")\n        .selectAll(\"rect\")\n        .data(data)\n        .join(\"rect\")\n        .attr(\"x\", (d: Hour) => x(d.hour.toString())!)\n        .attr(\"y\", () => y(yMax)!)\n        .attr(\"width\", x.bandwidth())\n        .attr(\"height\", () => y(0)! - y(yMax!)!)\n        .on(\"mousemove\", (event: MouseEvent, d: Hour) => {\n            const [x, y] = pointer(event, document.body);\n            showTooltip(tooltipText(d), x, y);\n        })\n        .on(\"mouseout\", hideTooltip);\n}\n"
  },
  {
    "path": "ts/routes/graphs/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport \"./graphs-base.scss\";\n\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\nimport type { Component } from \"svelte\";\n\nimport GraphsPage from \"./GraphsPage.svelte\";\n\nconst i18n = setupI18n({ modules: [ModuleName.STATISTICS, ModuleName.SCHEDULING] });\n\nexport async function setupGraphs(\n    graphs: Component<any>[],\n    {\n        search = \"deck:current\",\n        days = 365,\n        controller = null satisfies Component<any> | null,\n    } = {},\n): Promise<GraphsPage> {\n    checkNightMode();\n    await i18n;\n\n    return new GraphsPage({\n        target: document.body,\n        props: {\n            initialSearch: search,\n            initialDays: days,\n            graphs,\n            controller,\n        },\n    });\n}\n\nimport AddedGraph from \"./AddedGraph.svelte\";\nimport ButtonsGraph from \"./ButtonsGraph.svelte\";\nimport CalendarGraph from \"./CalendarGraph.svelte\";\nimport CardCounts from \"./CardCounts.svelte\";\nimport DifficultyGraph from \"./DifficultyGraph.svelte\";\nimport EaseGraph from \"./EaseGraph.svelte\";\nimport FutureDue from \"./FutureDue.svelte\";\nimport { RevlogRange } from \"./graph-helpers\";\nimport HourGraph from \"./HourGraph.svelte\";\nimport IntervalsGraph from \"./IntervalsGraph.svelte\";\nimport RangeBox from \"./RangeBox.svelte\";\nimport RetrievabilityGraph from \"./RetrievabilityGraph.svelte\";\nimport ReviewsGraph from \"./ReviewsGraph.svelte\";\nimport StabilityGraph from \"./StabilityGraph.svelte\";\nimport TodayStats from \"./TodayStats.svelte\";\n\nexport const graphComponents = {\n    TodayStats,\n    FutureDue,\n    CalendarGraph,\n    ReviewsGraph,\n    CardCounts,\n    IntervalsGraph,\n    StabilityGraph,\n    EaseGraph,\n    DifficultyGraph,\n    RetrievabilityGraph,\n    HourGraph,\n    ButtonsGraph,\n    AddedGraph,\n    RangeBox,\n    RevlogRange,\n};\n"
  },
  {
    "path": "ts/routes/graphs/intervals.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse_Intervals } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport { timeSpan } from \"@tslib/time\";\nimport type { Bin } from \"d3\";\nimport { bin, extent, interpolateBlues, quantile, scaleLinear, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\n\nexport interface IntervalGraphData {\n    intervals: number[];\n}\n\nexport enum IntervalRange {\n    Month = 0,\n    Percentile50 = 1,\n    Percentile95 = 2,\n    All = 3,\n}\n\nexport function gatherIntervalData(data: GraphsResponse_Intervals): IntervalGraphData {\n    // This could be made more efficient - this graph currently expects a flat list of individual intervals which it\n    // uses to calculate a percentile and then converts into a histogram, and the percentile/histogram calculations\n    // in JS are relatively slow.\n    const map = numericMap(data.intervals);\n    const totalCards = sum(map, ([_k, v]) => v);\n    const allIntervals: number[] = Array(totalCards);\n    let position = 0;\n    for (const entry of map.entries()) {\n        allIntervals.fill(entry[0], position, position + entry[1]);\n        position += entry[1];\n    }\n    allIntervals.sort((a, b) => a - b);\n    return { intervals: allIntervals };\n}\n\nexport function intervalLabel(\n    daysStart: number,\n    daysEnd: number,\n    cards: number,\n    fsrs: boolean,\n): string {\n    if (daysEnd - daysStart <= 1) {\n        // singular\n        const fn = fsrs ? tr.statisticsStabilityDaySingle : tr.statisticsIntervalsDaySingle;\n        return fn({\n            day: daysStart,\n            cards,\n        });\n    } else {\n        // range\n        const fn = fsrs ? tr.statisticsStabilityDayRange : tr.statisticsIntervalsDayRange;\n        return fn({\n            daysStart,\n            daysEnd: daysEnd - 1,\n            cards,\n        });\n    }\n}\n\nfunction makeSm2Query(start: number, end: number): string {\n    if (start === end) {\n        return `\"prop:ivl=${start}\"`;\n    }\n\n    const fromQuery = `\"prop:ivl>=${start}\"`;\n    const tillQuery = `\"prop:ivl<=${end}\"`;\n    return `${fromQuery} ${tillQuery}`;\n}\n\nfunction makeFsrsQuery(start: number, end: number): string {\n    if (start === 0) {\n        start = 0.5;\n    }\n    const fromQuery = `\"prop:s>=${start - 0.5}\"`;\n    const tillQuery = `\"prop:s<${end + 0.5}\"`;\n    return `${fromQuery} ${tillQuery}`;\n}\n\nexport function prepareIntervalData(\n    data: IntervalGraphData,\n    range: IntervalRange,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n    fsrs: boolean,\n): [HistogramData | null, TableDatum[]] {\n    // get min/max\n    const allIntervals = data.intervals;\n    if (!allIntervals.length) {\n        return [null, []];\n    }\n\n    const xMin = 0;\n    let [, xMax] = extent(allIntervals);\n    let niceNecessary = false;\n\n    // cap max to selected range\n    switch (range) {\n        case IntervalRange.Month:\n            xMax = Math.min(xMax!, 30);\n            break;\n        case IntervalRange.Percentile50:\n            xMax = quantile(allIntervals, 0.5);\n            niceNecessary = true;\n            break;\n        case IntervalRange.Percentile95:\n            xMax = quantile(allIntervals, 0.95);\n            niceNecessary = true;\n            break;\n        case IntervalRange.All:\n            niceNecessary = true;\n            break;\n    }\n\n    xMax = xMax! + 1;\n\n    // do not show the zero interval for intervals\n    const increment = fsrs ? x => x : (x: number): number => x + 1;\n\n    const adjustTicks = (x: number, idx: number, ticks: number[]): number[] =>\n        idx === ticks.length - 1 ? [x - (ticks[0] - 1), x + 1] : [x - (ticks[0] - 1)];\n\n    // cap bars to available range\n    const desiredBars = Math.min(70, xMax! - xMin!);\n\n    const prescale = scaleLinear().domain([xMin!, xMax!]);\n    const scale = scaleLinear().domain(\n        (niceNecessary ? prescale.nice() : prescale).domain().map(increment),\n    );\n\n    const bins = bin()\n        .domain(scale.domain() as [number, number])\n        .thresholds(scale.ticks(desiredBars).flatMap(adjustTicks))(allIntervals);\n\n    // empty graph?\n    const totalInPeriod = sum(bins, (bin) => bin.length);\n    if (!totalInPeriod) {\n        return [null, []];\n    }\n\n    const adjustedRange = scaleLinear().range([0.7, 0.3]);\n    const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]);\n\n    function hoverText(\n        bin: Bin<number, number>,\n        _cumulative: number,\n        percent: number,\n    ): string {\n        // const day = dayLabel(bin.x0!, bin.x1!);\n        const interval = intervalLabel(bin.x0!, bin.x1!, bin.length, fsrs);\n        const total = tr.statisticsRunningTotal();\n        return `${interval}<br>${total}: \\u200e${localizedNumber(percent, 1)}%`;\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = bin.x0!;\n        const end = bin.x1! - 1;\n        const query = (fsrs ? makeFsrsQuery : makeSm2Query)(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    const medianInterval = Math.round(quantile(allIntervals, 0.5) ?? 0);\n    const medianIntervalString = timeSpan(medianInterval * 86400, false);\n    const tableData = [\n        {\n            label: fsrs ? tr.statisticsMedianStability() : tr.statisticsMedianInterval(),\n            value: medianIntervalString,\n        },\n    ];\n\n    return [\n        {\n            scale,\n            bins,\n            total: totalInPeriod,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            colourScale,\n            showArea: true,\n        },\n        tableData,\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/percentageRange.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n\nimport { range, type ScaleLinear, scaleLinear, sum } from \"d3\";\n\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nexport enum PercentageRangeEnum {\n    All = 0,\n    Percentile100 = 1,\n    Percentile95 = 2,\n    Percentile50 = 3,\n}\n\nexport function PercentageRangeToQuantile(range: PercentageRangeEnum) {\n    // These are halved because the quantiles are in both directions\n    return ({\n        [PercentageRangeEnum.Percentile100]: 1,\n        [PercentageRangeEnum.Percentile95]: 0.975,\n        [PercentageRangeEnum.Percentile50]: 0.75,\n        [PercentageRangeEnum.All]: undefined,\n    })[range];\n}\n\nexport function easeQuantile(data: Map<number, number>, quantile: number) {\n    let count = sum(data.values()) * quantile;\n    for (const [key, value] of data.entries()) {\n        count -= value;\n        if (count <= 0) {\n            return key;\n        }\n    }\n}\n\nexport function percentageRangeMinMax(data: Map<number, number>, range: number | undefined) {\n    const xMin = range ? easeQuantile(data, 1 - range) ?? 0 : 0;\n    const xMax = range ? easeQuantile(data, range) ?? 0 : 100;\n\n    return [xMin, xMax];\n}\n\nexport function getAdjustedScaleAndTicks(\n    min: number,\n    max: number,\n    desiredBars: number,\n): [ScaleLinear<number, number, never>, number[]] {\n    const prescale = scaleLinear().domain([min, max]).nice();\n    let ticks = prescale.ticks(desiredBars);\n\n    const predomain = prescale.domain() as [number, number];\n\n    const minOffset = min - predomain[0];\n    let tickSize = ticks[1] - ticks[0];\n\n    const minBinSize = 1;\n    if (tickSize < minBinSize) {\n        ticks = range(min, max, minBinSize);\n        tickSize = minBinSize;\n    }\n\n    if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) {\n        return [prescale, ticks];\n    }\n\n    const add = (n: number): number => n + minOffset;\n    return [\n        scaleLinear().domain(predomain.map(add) as [number, number]),\n        ticks.map(add),\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/retrievability.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport type { Bin } from \"d3\";\nimport { bin, interpolateRdYlGn, scaleSequential, sum } from \"d3\";\n\nimport type { SearchDispatch, TableDatum } from \"./graph-helpers\";\nimport { getNumericMapBinValue, numericMap } from \"./graph-helpers\";\nimport type { HistogramData } from \"./histogram-graph\";\nimport { getAdjustedScaleAndTicks, percentageRangeMinMax } from \"./percentageRange\";\n\nexport interface GraphData {\n    retrievability: Map<number, number>;\n    average: number;\n    sumByCard: number;\n    sumByNote: number;\n}\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    return {\n        retrievability: numericMap(data.retrievability!.retrievability),\n        average: data.retrievability!.average,\n        sumByCard: data.retrievability!.sumByCard,\n        sumByNote: data.retrievability!.sumByNote,\n    };\n}\n\nfunction makeQuery(start: number, end: number): string {\n    const fromQuery = `\"prop:r>=${start / 100}\"`;\n    let tillQuery = `\"prop:r<${(end + 1) / 100}\"`;\n    if (end === 99) {\n        tillQuery = tillQuery.replace(\"<\", \"<=\");\n    }\n    return `${fromQuery} AND ${tillQuery}`;\n}\n\nexport function prepareData(\n    data: GraphData,\n    dispatch: SearchDispatch,\n    browserLinksSupported: boolean,\n    quantile?: number,\n): [HistogramData | null, TableDatum[]] {\n    // get min/max\n    const allEases = data.retrievability;\n    if (!allEases.size) {\n        return [null, []];\n    }\n    const [xMin, xMax] = percentageRangeMinMax(allEases, quantile);\n    const desiredBars = 20;\n\n    const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);\n\n    const bins = bin()\n        .value((m) => {\n            return m[0];\n        })\n        .domain(scale.domain() as [number, number])\n        .thresholds(ticks)(allEases.entries() as any);\n    const total = sum(bins as any, getNumericMapBinValue);\n\n    const colourScale = scaleSequential(interpolateRdYlGn).domain([0, 100]);\n\n    function hoverText(bin: Bin<number, number>, _percent: number): string {\n        const percent = `${bin.x0}%-${bin.x1}%`;\n        return tr.statisticsRetrievabilityTooltip({\n            cards: getNumericMapBinValue(bin as any),\n            percent,\n        });\n    }\n\n    function onClick(bin: Bin<number, number>): void {\n        const start = bin.x0!;\n        const end = bin.x1! - 1;\n        const query = makeQuery(start, end);\n        dispatch(\"search\", { query });\n    }\n\n    const xTickFormat = (num: number): string => localizedNumber(num, 0) + \"%\";\n    const tableData = [\n        {\n            label: tr.statisticsAverageRetrievability(),\n            value: xTickFormat(data.average),\n        },\n        {\n            label: tr.statisticsEstimatedTotalKnowledge(),\n            value: `${tr.statisticsCards({ cards: +data.sumByCard.toFixed(0) })} / ${\n                tr.statisticsNotes({ notes: +data.sumByNote.toFixed(0) })\n            }`,\n        },\n    ];\n\n    return [\n        {\n            scale,\n            bins,\n            total,\n            hoverText,\n            onClick: browserLinksSupported ? onClick : null,\n            colourScale,\n            showArea: false,\n            binValue: getNumericMapBinValue,\n            xTickFormat,\n        },\n        tableData,\n    ];\n}\n"
  },
  {
    "path": "ts/routes/graphs/reviews.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n */\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport { dayLabel, timeSpan, TimespanUnit } from \"@tslib/time\";\nimport type { Bin, ScaleSequential } from \"d3\";\nimport {\n    area,\n    axisBottom,\n    axisLeft,\n    axisRight,\n    bin,\n    cumsum,\n    curveBasis,\n    interpolateGreens,\n    interpolateOranges,\n    interpolatePurples,\n    interpolateReds,\n    max,\n    min,\n    pointer,\n    scaleLinear,\n    scaleSequential,\n    select,\n    sum,\n} from \"d3\";\n\nimport type { GraphBounds, TableDatum } from \"./graph-helpers\";\nimport { GraphRange, numericMap, setDataAvailable } from \"./graph-helpers\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\ninterface Reviews {\n    learn: number;\n    relearn: number;\n    young: number;\n    mature: number;\n    filtered: number;\n}\n\nexport interface GraphData {\n    // indexed by day, where day is relative to today\n    reviewCount: Map<number, Reviews>;\n    reviewTime: Map<number, Reviews>;\n}\n\ntype BinType = Bin<Map<number, Reviews[]>, number>;\n\nexport function gatherData(data: GraphsResponse): GraphData {\n    return { reviewCount: numericMap(data.reviews!.count), reviewTime: numericMap(data.reviews!.time) };\n}\n\nenum BinIndex {\n    Mature = 0,\n    Young = 1,\n    Relearn = 2,\n    Learn = 3,\n    Filtered = 4,\n}\n\nfunction totalsForBin(bin: BinType): number[] {\n    const total = [0, 0, 0, 0, 0];\n    for (const entry of bin) {\n        total[BinIndex.Mature] += entry[1].mature;\n        total[BinIndex.Young] += entry[1].young;\n        total[BinIndex.Relearn] += entry[1].relearn;\n        total[BinIndex.Learn] += entry[1].learn;\n        total[BinIndex.Filtered] += entry[1].filtered;\n    }\n\n    return total;\n}\n\n/** eg idx=0 is mature count, idx=1 is mature+young count, etc */\nfunction cumulativeBinValue(bin: BinType, idx: number): number {\n    return sum(totalsForBin(bin).slice(0, idx + 1));\n}\n\nexport function renderReviews(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    sourceData: GraphData,\n    range: GraphRange,\n    showTime: boolean,\n): TableDatum[] {\n    const svg = select(svgElem);\n    const trans = svg.transition().duration(600) as any;\n\n    const xMax = 1;\n    let xMin = 0;\n    // cap max to selected range\n    switch (range) {\n        case GraphRange.Month:\n            xMin = -30;\n            break;\n        case GraphRange.ThreeMonths:\n            xMin = -89;\n            break;\n        case GraphRange.Year:\n            xMin = -364;\n            break;\n        case GraphRange.AllTime:\n            xMin = min(sourceData.reviewCount.keys())!;\n            break;\n    }\n    const desiredBars = Math.min(70, Math.abs(xMin!));\n    const unboundRange = range == GraphRange.AllTime;\n    const originalXMin = xMin!;\n\n    // Create initial scale to determine tick spacing\n    let x = scaleLinear().domain([xMin!, xMax]);\n    let thresholds = x.ticks(desiredBars);\n    // For unbound ranges, extend xMin backward so that the oldest bin has the same width as others\n    if (unboundRange && thresholds.length >= 2) {\n        const spacing = thresholds[1] - thresholds[0];\n        const partial = thresholds[0] - xMin!;\n        if (spacing > 0 && partial > 0 && partial < spacing) {\n            xMin = thresholds[0] - spacing;\n            x = scaleLinear().domain([xMin, xMax]);\n            thresholds = x.ticks(desiredBars);\n        }\n    }\n    // For Year & All Time, shift thresholds forward by one day to make first bin 0-4 instead of 0-5\n    if (range === GraphRange.Year || range === GraphRange.AllTime) {\n        thresholds = [...new Set(thresholds.map(t => Math.min(t + 1, 1)))].sort((a, b) => a - b);\n    }\n\n    const sourceMap = showTime ? sourceData.reviewTime : sourceData.reviewCount;\n    const bins = bin()\n        .value((m) => m[0])\n        .domain(x.domain() as any)\n        .thresholds(thresholds)(sourceMap.entries() as any);\n\n    // empty graph?\n    const totalDays = sum(bins, (bin) => bin.length);\n    if (!totalDays) {\n        setDataAvailable(svg, false);\n        return [];\n    } else {\n        setDataAvailable(svg, true);\n    }\n\n    x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))\n        .attr(\"direction\", \"ltr\");\n\n    // y scale\n\n    const yTickFormat = (n: number): string => {\n        if (showTime) {\n            return timeSpan(n / 1000, true, true, TimespanUnit.Hours);\n        } else {\n            if (Math.round(n) != n) {\n                return \"\";\n            } else {\n                return localizedNumber(n);\n            }\n        }\n    };\n\n    const yMax = max(bins, (b: Bin<any, any>) => cumulativeBinValue(b, 4))!;\n    const y = scaleLinear()\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop])\n        .domain([0, yMax])\n        .nice();\n    svg.select<SVGGElement>(\".y-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisLeft(y)\n                    .ticks(bounds.height / 50)\n                    .tickSizeOuter(0)\n                    .tickFormat(yTickFormat as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    // x bars\n\n    function barWidth(d: Bin<number, number>): number {\n        const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1);\n        return width ?? 0;\n    }\n\n    const cappedRange = scaleLinear().range([0.3, 0.5]);\n    const shiftedRange = scaleLinear().range([0.4, 0.7]);\n    const darkerGreens = scaleSequential((n) => interpolateGreens(shiftedRange(n)!)).domain(x.domain() as any);\n    const lighterGreens = scaleSequential((n) => interpolateGreens(cappedRange(n)!)).domain(x.domain() as any);\n    const reds = scaleSequential((n) => interpolateReds(cappedRange(n)!)).domain(\n        x.domain() as any,\n    );\n    const oranges = scaleSequential((n) => interpolateOranges(cappedRange(n)!)).domain(\n        x.domain() as any,\n    );\n    const purples = scaleSequential((n) => interpolatePurples(cappedRange(n)!)).domain(\n        x.domain() as any,\n    );\n\n    function binColor(idx: BinIndex): ScaleSequential<string> {\n        switch (idx) {\n            case BinIndex.Mature:\n                return darkerGreens;\n            case BinIndex.Young:\n                return lighterGreens;\n            case BinIndex.Learn:\n                return oranges;\n            case BinIndex.Relearn:\n                return reds;\n            case BinIndex.Filtered:\n                return purples;\n        }\n    }\n\n    function valueLabel(n: number): string {\n        if (showTime) {\n            return timeSpan(n / 1000, false, true, TimespanUnit.Hours);\n        } else {\n            return tr.statisticsReviews({ reviews: n });\n        }\n    }\n\n    function tooltipText(d: BinType, cumulative: number): string {\n        // Convert bin boundaries [x0, x1) for dayLabel\n        // If bin ends at 0, treat it as crossing zero so day 0 is included\n        // For the first (oldest) bin, use the original xMin to ensure labels match the intended range\n        const isFirstBin = bins.length > 0 && d.x0 === bins[0].x0;\n        const startDay = isFirstBin ? originalXMin : Math.floor(d.x0!);\n        const endDay = d.x1! === 0 ? 1 : d.x1!;\n        const day = dayLabel(startDay, endDay);\n        const totals = totalsForBin(d);\n        const dayTotal = valueLabel(sum(totals));\n        let buf = `<table><tr><td>${day}</td><td align=end>${dayTotal}</td></tr>`;\n        const lines: [BinIndex | null, string][] = [\n            [BinIndex.Filtered, tr.statisticsCountsFilteredCards()],\n            [BinIndex.Learn, tr.statisticsCountsLearningCards()],\n            [BinIndex.Relearn, tr.statisticsCountsRelearningCards()],\n            [BinIndex.Young, tr.statisticsCountsYoungCards()],\n            [BinIndex.Mature, tr.statisticsCountsMatureCards()],\n            [null, tr.statisticsRunningTotal()],\n        ];\n        for (const [idx, label] of lines) {\n            let color: string;\n            let detail: string;\n            if (idx == null) {\n                color = \"transparent\";\n                detail = valueLabel(cumulative);\n            } else {\n                color = binColor(idx)(1);\n                detail = valueLabel(totals[idx]);\n            }\n            buf += `<tr>\n            <td><span style=\"color: ${color};\">■</span> ${label}</td>\n            <td align=end>${detail}</td>\n            </tr>`;\n        }\n        return buf;\n    }\n\n    const updateBar = (sel: any, idx: number): any => {\n        return sel\n            .attr(\"width\", barWidth)\n            .transition(trans)\n            .attr(\"x\", (d: any) => x(d.x0))\n            .attr(\"y\", (d: any) => y(cumulativeBinValue(d, idx))!)\n            .attr(\"height\", (d: any) => y(0)! - y(cumulativeBinValue(d, idx))!)\n            .attr(\"fill\", (d: any) => binColor(idx)(d.x0));\n    };\n\n    for (const barNum of [0, 1, 2, 3, 4]) {\n        svg.select(`g.bars${barNum}`)\n            .selectAll(\"rect\")\n            .data(bins)\n            .join(\n                (enter) =>\n                    enter\n                        .append(\"rect\")\n                        .attr(\"rx\", 1)\n                        .attr(\"x\", (d: any) => x(d.x0)!)\n                        .attr(\"y\", y(0)!)\n                        .attr(\"height\", 0)\n                        .call((d) => updateBar(d, barNum)),\n                (update) => update.call((d) => updateBar(d, barNum)),\n                (remove) => remove.call((remove) => remove.transition(trans).attr(\"height\", 0).attr(\"y\", y(0)!)),\n            );\n    }\n\n    // cumulative area\n\n    const areaCounts = bins.map((d: any) => cumulativeBinValue(d, 4));\n    areaCounts.unshift(0);\n    const areaData = cumsum(areaCounts);\n    const yCumMax = areaData.slice(-1)[0];\n    const yAreaScale = y.copy().domain([0, yCumMax]).nice();\n\n    if (yCumMax) {\n        svg.select<SVGGElement>(\".y2-ticks\")\n            .call((selection) =>\n                selection.transition(trans).call(\n                    axisRight(yAreaScale)\n                        .ticks(bounds.height / 50)\n                        .tickFormat(yTickFormat as any)\n                        .tickSizeOuter(0),\n                )\n            )\n            .attr(\"direction\", \"ltr\");\n\n        svg.select(\"path.cumulative-overlay\")\n            .datum(areaData)\n            .attr(\n                \"d\",\n                area()\n                    .curve(curveBasis)\n                    .x((_d: [number, number], idx: number) => {\n                        if (idx === 0) {\n                            return x(bins[0].x0!)!;\n                        } else {\n                            return x(bins[idx - 1].x1!)!;\n                        }\n                    })\n                    .y0(bounds.height - bounds.marginBottom)\n                    .y1((d: any) => yAreaScale(d)!) as any,\n            );\n    }\n\n    const hoverData: [Bin<number, number>, number][] = bins.map(\n        (bin: Bin<number, number>, index: number) => [bin, areaData[index + 1]],\n    );\n\n    // hover/tooltip\n    svg.select(\"g.hover-columns\")\n        .selectAll(\"rect\")\n        .data(hoverData)\n        .join(\"rect\")\n        .attr(\"x\", ([bin]) => x(bin.x0!))\n        .attr(\"y\", () => y(yMax))\n        .attr(\"width\", ([bin]) => barWidth(bin))\n        .attr(\"height\", () => y(0) - y(yMax))\n        .on(\"mousemove\", (event: MouseEvent, [bin, area]): void => {\n            const [x, y] = pointer(event, document.body);\n            showTooltip(tooltipText(bin as any, area), x, y);\n        })\n        .on(\"mouseout\", hideTooltip);\n\n    // The xMin might be extended for bin alignment, so use the original xMin\n    const periodDays = -originalXMin + 1;\n    const studiedDays = sum(bins, (bin) => bin.length);\n    const studiedPercent = (studiedDays / periodDays) * 100;\n    const total = yCumMax;\n    const periodAvg = total / periodDays;\n    const studiedAvg = total / studiedDays;\n\n    let totalString: string,\n        averageForDaysStudied: string,\n        averageForPeriod: string,\n        averageAnswerTime: string,\n        averageAnswerTimeLabel: string;\n    if (showTime) {\n        totalString = timeSpan(total / 1000, false, true, TimespanUnit.Hours);\n        averageForDaysStudied = tr.statisticsMinutesPerDay({\n            count: Math.round(studiedAvg / 1000 / 60),\n        });\n        averageForPeriod = tr.statisticsMinutesPerDay({\n            count: Math.round(periodAvg / 1000 / 60),\n        });\n        averageAnswerTimeLabel = tr.statisticsAverageAnswerTimeLabel();\n\n        // need to get total review count to calculate average time\n        const countBins = bin()\n            .value((m) => {\n                return m[0];\n            })\n            .domain(x.domain() as any)(sourceData.reviewCount.entries() as any);\n        const totalReviews = sum(countBins, (bin) => cumulativeBinValue(bin as any, 4));\n        const totalSecs = total / 1000;\n        const avgSecs = totalSecs / totalReviews;\n        const cardsPerMin = (totalReviews * 60) / totalSecs;\n        averageAnswerTime = tr.statisticsAverageAnswerTime({\n            averageSeconds: avgSecs,\n            cardsPerMinute: cardsPerMin,\n        });\n    } else {\n        totalString = tr.statisticsReviews({ reviews: total });\n        averageForDaysStudied = tr.statisticsReviewsPerDay({\n            count: Math.round(studiedAvg),\n        });\n        averageForPeriod = tr.statisticsReviewsPerDay({\n            count: Math.round(periodAvg),\n        });\n        averageAnswerTime = averageAnswerTimeLabel = \"\";\n    }\n\n    const tableData: TableDatum[] = [\n        {\n            label: tr.statisticsDaysStudied(),\n            value: tr.statisticsAmountOfTotalWithPercentage({\n                amount: studiedDays,\n                total: periodDays,\n                percent: (() => {\n                    if (studiedPercent < 99.5) {\n                        return localizedNumber(studiedPercent);\n                    } else if (studiedPercent < 99.95) {\n                        return localizedNumber(studiedPercent, 1);\n                    } else if (studiedPercent < 100) {\n                        return localizedNumber(studiedPercent, 2);\n                    } else {\n                        return \"100\";\n                    }\n                })(),\n            }),\n        },\n\n        { label: tr.statisticsTotal(), value: totalString },\n\n        {\n            label: tr.statisticsAverageOverPeriod(),\n            value: averageForPeriod,\n        },\n    ];\n\n    if (studiedPercent < 100) {\n        tableData.push({\n            label: tr.statisticsAverageForDaysStudied(),\n            value: averageForDaysStudied,\n        });\n    }\n\n    if (averageAnswerTime) {\n        tableData.push({ label: averageAnswerTimeLabel, value: averageAnswerTime });\n    }\n\n    return tableData;\n}\n"
  },
  {
    "path": "ts/routes/graphs/simulator.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { createLocaleNumberFormat, localizedDate } from \"@tslib/i18n\";\nimport {\n    axisBottom,\n    axisLeft,\n    bisector,\n    line,\n    max,\n    pointer,\n    rollup,\n    scaleLinear,\n    scaleTime,\n    schemeCategory10,\n    select,\n} from \"d3\";\n\nimport * as tr from \"@generated/ftl\";\nimport { timeSpan } from \"@tslib/time\";\nimport { sumBy } from \"lodash-es\";\nimport type { GraphBounds, TableDatum } from \"./graph-helpers\";\nimport { setDataAvailable } from \"./graph-helpers\";\nimport { hideTooltip, showTooltip } from \"./tooltip-utils.svelte\";\n\nexport interface Point {\n    x: number;\n    timeCost: number;\n    count: number;\n    memorized: number;\n    label: number;\n}\n\nexport type WorkloadPoint = Point & {\n    learnSpan: number;\n};\n\nexport enum SimulateSubgraph {\n    time,\n    count,\n    memorized,\n}\n\nexport enum SimulateWorkloadSubgraph {\n    ratio,\n    time,\n    count,\n    memorized,\n}\n\nexport function renderWorkloadChart(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    data: WorkloadPoint[],\n    subgraph: SimulateWorkloadSubgraph,\n) {\n    const xMin = 70;\n    const xMax = 99;\n\n    const x = scaleLinear()\n        .domain([xMin, xMax])\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n\n    const subgraph_data = ({\n        [SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })),\n        [SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })),\n        [SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })),\n        [SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })),\n    })[subgraph];\n\n    const yTickFormat = (n: number): string => {\n        return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio\n            ? timeSpan(n, true)\n            : n.toString();\n    };\n\n    const formatter = createLocaleNumberFormat({\n        style: \"percent\",\n        minimumFractionDigits: 0,\n        maximumFractionDigits: 0,\n    });\n    const xTickFormat = (n: number) => formatter.format(n / 100);\n\n    const formatY: (value: number) => string = ({\n        [SimulateWorkloadSubgraph.ratio]: (value: number) =>\n            tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }),\n        [SimulateWorkloadSubgraph.time]: (value: number) =>\n            tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }),\n        [SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }),\n        [SimulateWorkloadSubgraph.memorized]: (value: number) =>\n            tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),\n    })[subgraph];\n\n    function formatX(dr: number) {\n        return `${tr.deckConfigDesiredRetention()}: ${xTickFormat(dr)}<br>`;\n    }\n\n    return _renderSimulationChart(\n        svgElem,\n        bounds,\n        subgraph_data,\n        x,\n        formatY,\n        formatX,\n        (_e: MouseEvent, _d: number) => undefined,\n        yTickFormat,\n        xTickFormat,\n    );\n}\n\nexport function renderSimulationChart(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    data: Point[],\n    subgraph: SimulateSubgraph,\n): TableDatum[] {\n    const today = new Date();\n    const convertedData = data.map(d => ({\n        ...d,\n        x: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),\n    }));\n\n    const subgraph_data = ({\n        [SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })),\n        [SimulateSubgraph.time]: convertedData.map(d => ({ ...d, y: d.timeCost })),\n        [SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })),\n    })[subgraph];\n\n    const xMin = today;\n    const xMax = max(subgraph_data, d => d.x);\n\n    const x = scaleTime()\n        .domain([xMin, xMax!])\n        .range([bounds.marginLeft, bounds.width - bounds.marginRight]);\n\n    const yTickFormat = (n: number): string => {\n        return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();\n    };\n\n    const formatY: (value: number) => string = ({\n        [SimulateSubgraph.time]: timeSpan,\n        [SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),\n        [SimulateSubgraph.memorized]: (value: number) =>\n            tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),\n    })[subgraph];\n\n    const perDay = ({\n        [SimulateSubgraph.count]: tr.statisticsReviewsPerDay,\n        [SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),\n        [SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,\n    })[subgraph];\n\n    function legendMouseMove(e: MouseEvent, d: number) {\n        const data = subgraph_data.filter(datum => datum.label == d);\n\n        const total = subgraph == SimulateSubgraph.memorized\n            ? data[data.length - 1].memorized - data[0].memorized\n            : sumBy(data, d => d.y);\n        const average = total / (data?.length || 1);\n\n        showTooltip(\n            `#${d}:<br/>\n                ${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>\n                ${tr.statisticsTotal()}: ${formatY(total)}`,\n            e.pageX,\n            e.pageY,\n        );\n    }\n\n    function formatX(date: Date) {\n        const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();\n        return `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;\n    }\n\n    return _renderSimulationChart(\n        svgElem,\n        bounds,\n        subgraph_data,\n        x,\n        formatY,\n        formatX,\n        legendMouseMove,\n        yTickFormat,\n        undefined,\n    );\n}\n\nfunction _renderSimulationChart<T extends { x: any; y: any; label: number }>(\n    svgElem: SVGElement,\n    bounds: GraphBounds,\n    subgraph_data: T[],\n    x: any,\n    formatY: (n: T[\"y\"]) => string,\n    formatX: (n: T[\"x\"]) => string,\n    legendMouseMove: (e: MouseEvent, d: number) => void,\n    yTickFormat?: (n: number) => string,\n    xTickFormat?: (n: number) => string,\n): TableDatum[] {\n    const svg = select(svgElem);\n    svg.selectAll(\".lines\").remove();\n    svg.selectAll(\".hover-columns\").remove();\n    svg.selectAll(\".focus-line\").remove();\n    svg.selectAll(\".legend\").remove();\n    if (subgraph_data.length == 0) {\n        setDataAvailable(svg, false);\n        return [];\n    }\n    const trans = svg.transition().duration(600) as any;\n\n    svg.select<SVGGElement>(\".x-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0).tickFormat(xTickFormat as any))\n        )\n        .attr(\"direction\", \"ltr\");\n    // y scale\n\n    const yMax = max(subgraph_data, d => d.y)!;\n    const y = scaleLinear()\n        .range([bounds.height - bounds.marginBottom, bounds.marginTop])\n        .domain([0, yMax])\n        .nice();\n    svg.select<SVGGElement>(\".y-ticks\")\n        .call((selection) =>\n            selection.transition(trans).call(\n                axisLeft(y)\n                    .ticks(bounds.height / 50)\n                    .tickSizeOuter(0)\n                    .tickFormat(yTickFormat as any),\n            )\n        )\n        .attr(\"direction\", \"ltr\");\n\n    svg.select(\".y-ticks .y-axis-title\").remove();\n    svg.select(\".y-ticks\")\n        .append(\"text\")\n        .attr(\"class\", \"y-axis-title\")\n        .attr(\"transform\", \"rotate(-90)\")\n        .attr(\"y\", 0 - bounds.marginLeft)\n        .attr(\"x\", 0 - (bounds.height / 2))\n        .attr(\"font-size\", \"1rem\")\n        .attr(\"dy\", \"1.1em\")\n        .attr(\"fill\", \"currentColor\");\n\n    // x lines\n    const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]);\n    const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);\n\n    const color = schemeCategory10;\n\n    svg.append(\"g\")\n        .attr(\"class\", \"lines\")\n        .attr(\"fill\", \"none\")\n        .attr(\"stroke-width\", 1.5)\n        .attr(\"stroke-linejoin\", \"round\")\n        .attr(\"stroke-linecap\", \"round\")\n        .selectAll(\"path\")\n        .data(Array.from(groups.entries()))\n        .join(\"path\")\n        .attr(\"vector-effect\", \"non-scaling-stroke\")\n        .attr(\"stroke\", (d, i) => color[i % color.length])\n        .attr(\"d\", d => line()(d[1].map(p => [p[0], p[1]])))\n        .attr(\"data-group\", d => d[0]);\n\n    const focusLine = svg.append(\"line\")\n        .attr(\"class\", \"focus-line\")\n        .attr(\"y1\", bounds.marginTop)\n        .attr(\"y2\", bounds.height - bounds.marginBottom)\n        .attr(\"stroke\", \"black\")\n        .attr(\"stroke-width\", 1)\n        .style(\"opacity\", 0);\n\n    const LongestGroupData = Array.from(groups.values()).reduce((a, b) => a.length > b.length ? a : b);\n    const barWidth = bounds.width / LongestGroupData.length;\n\n    // hover/tooltip\n    svg.append(\"g\")\n        .attr(\"class\", \"hover-columns\")\n        .selectAll(\"rect\")\n        .data(LongestGroupData)\n        .join(\"rect\")\n        .attr(\"x\", d => d[0] - barWidth / 2)\n        .attr(\"y\", bounds.marginTop)\n        .attr(\"width\", barWidth)\n        .attr(\"height\", bounds.height - bounds.marginTop - bounds.marginBottom)\n        .attr(\"fill\", \"transparent\")\n        .on(\"mousemove\", mousemove)\n        .on(\"mouseout\", () => {\n            focusLine.style(\"opacity\", 0);\n            hideTooltip();\n        });\n\n    function mousemove(event: MouseEvent, d: any): void {\n        pointer(event, document.body);\n        const date = x.invert(d[0]);\n\n        const groupData: { [key: string]: number } = {};\n\n        groups.forEach((groupPoints, key) => {\n            const bisect = bisector((d: number[]) => x.invert(d[0])).left;\n            const index = bisect(groupPoints, date);\n            const dataPoint = groupPoints[index];\n\n            if (dataPoint) {\n                groupData[key] = y.invert(dataPoint[1]);\n            }\n        });\n\n        focusLine.attr(\"x1\", d[0]).attr(\"x2\", d[0]).style(\"opacity\", 1);\n\n        let tooltipContent = formatX(date);\n        for (const [key, value] of Object.entries(groupData)) {\n            const path = svg.select(`path[data-group=\"${key}\"]`);\n            const hidden = path.classed(\"hidden\");\n\n            if (!hidden) {\n                tooltipContent += `<span style=\"color:${color[(parseInt(key) - 1) % color.length]}\">■</span> #${key}: ${\n                    formatY(value)\n                }<br>`;\n            }\n        }\n\n        showTooltip(tooltipContent, event.pageX, event.pageY);\n    }\n\n    const legend = svg.append(\"g\")\n        .attr(\"class\", \"legend\")\n        .attr(\"font-family\", \"sans-serif\")\n        .attr(\"font-size\", 10)\n        .attr(\"text-anchor\", \"start\")\n        .selectAll(\"g\")\n        .data(Array.from(groups.keys()))\n        .join(\"g\")\n        .attr(\"transform\", (d, i) => `translate(0,${i * 20})`)\n        .attr(\"cursor\", \"pointer\")\n        .on(\"click\", (event, d) => toggleGroup(event, d))\n        .on(\"mousemove\", legendMouseMove)\n        .on(\"mouseout\", hideTooltip);\n\n    legend.append(\"rect\")\n        .attr(\"x\", bounds.width - bounds.marginRight + 36)\n        .attr(\"width\", 12)\n        .attr(\"height\", 12)\n        .attr(\"fill\", (d, i) => color[i % color.length]);\n\n    legend.append(\"text\")\n        .attr(\"x\", bounds.width - bounds.marginRight + 52)\n        .attr(\"y\", 7)\n        .attr(\"dy\", \"0.3em\")\n        .attr(\"fill\", \"currentColor\")\n        .text(d => `#${d}`);\n\n    const toggleGroup = (event: MouseEvent, d: number) => {\n        const group = d;\n        const path = svg.select(`path[data-group=\"${group}\"]`);\n        const hidden = path.classed(\"hidden\");\n        const target = event.currentTarget as HTMLElement;\n\n        path.classed(\"hidden\", !hidden);\n        path.style(\"display\", () => hidden ? null : \"none\");\n\n        select(target).select(\"rect\")\n            .style(\"opacity\", hidden ? 1 : 0.5);\n    };\n\n    setDataAvailable(svg, true);\n\n    const tableData: TableDatum[] = [];\n\n    return tableData;\n}\n"
  },
  {
    "path": "ts/routes/graphs/today.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { GraphsResponse } from \"@generated/anki/stats_pb\";\nimport * as tr from \"@generated/ftl\";\nimport { localizedNumber } from \"@tslib/i18n\";\nimport { studiedToday } from \"@tslib/time\";\n\nexport interface TodayData {\n    title: string;\n    lines: string[];\n}\n\nexport function gatherData(data: GraphsResponse): TodayData {\n    let lines: string[];\n    const today = data.today!;\n    if (today.answerCount) {\n        const studiedTodayText = studiedToday(today.answerCount, today.answerMillis / 1000);\n        const againCount = today.answerCount - today.correctCount;\n        let againCountText = tr.statisticsTodayAgainCount();\n        againCountText += ` ${againCount} (${\n            localizedNumber(\n                (againCount / today.answerCount) * 100,\n            )\n        }%)`;\n        const typeCounts = tr.statisticsTodayTypeCounts({\n            learnCount: today.learnCount,\n            reviewCount: today.reviewCount,\n            relearnCount: today.relearnCount,\n            filteredCount: today.earlyReviewCount,\n        });\n        let matureText: string;\n        if (today.matureCount) {\n            matureText = tr.statisticsTodayCorrectMature({\n                correct: today.matureCorrect,\n                total: today.matureCount,\n                percent: (today.matureCorrect / today.matureCount) * 100,\n            });\n        } else {\n            matureText = tr.statisticsTodayNoMatureCards();\n        }\n        lines = [studiedTodayText, againCountText, typeCounts, matureText];\n    } else {\n        lines = [tr.statisticsTodayNoCards()];\n    }\n\n    return {\n        title: tr.statisticsTodayTitle(),\n        lines,\n    };\n}\n"
  },
  {
    "path": "ts/routes/graphs/tooltip-utils.svelte.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { DebouncedFunc } from \"lodash-es\";\nimport { throttle } from \"lodash-es\";\nimport { mount } from \"svelte\";\n\nimport Tooltip from \"./Tooltip.svelte\";\n\ntype TooltipProps = {\n    html: string;\n    x: number;\n    y: number;\n    show: boolean;\n};\nlet tooltip: Record<string, any> | null = null;\nlet props: TooltipProps = { html: \"\", x: 0, y: 0, show: false };\n\nfunction getOrCreateTooltip(): TooltipProps {\n    if (tooltip) {\n        return props;\n    }\n\n    const target = document.createElement(\"div\");\n    const p = $state(props);\n    props = p;\n    tooltip = mount(Tooltip, { target, props });\n\n    document.body.appendChild(target);\n\n    return props;\n}\n\nfunction showTooltipInner(msg: string, x: number, y: number): void {\n    const props = getOrCreateTooltip();\n    props.html = msg;\n    props.x = x;\n    props.y = y;\n    props.show = true;\n}\n\nexport const showTooltip: DebouncedFunc<(msg: string, x: number, y: number) => void> = throttle(showTooltipInner, 16);\n\nexport function hideTooltip(): void {\n    const props = getOrCreateTooltip();\n    showTooltip.cancel();\n    props.show = false;\n}\n"
  },
  {
    "path": "ts/routes/graphs/true-retention.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport * as tr from \"@generated/ftl\";\nimport { createLocaleNumberFormat } from \"@tslib/i18n\";\nimport { assertUnreachable } from \"@tslib/typing\";\nimport { RevlogRange } from \"./graph-helpers\";\n\nexport interface TrueRetentionData {\n    youngPassed: number;\n    youngFailed: number;\n    maturePassed: number;\n    matureFailed: number;\n}\n\nexport interface PeriodTrueRetentionData {\n    today: TrueRetentionData;\n    yesterday: TrueRetentionData;\n    week: TrueRetentionData;\n    month: TrueRetentionData;\n    year: TrueRetentionData;\n    allTime: TrueRetentionData;\n}\n\nexport enum DisplayMode {\n    Young,\n    Mature,\n    All,\n    Summary,\n}\n\nexport enum Scope {\n    Young,\n    Mature,\n    All,\n}\n\nexport function getPassed(data: TrueRetentionData, scope: Scope): number {\n    switch (scope) {\n        case Scope.Young:\n            return data.youngPassed;\n        case Scope.Mature:\n            return data.maturePassed;\n        case Scope.All:\n            return data.youngPassed + data.maturePassed;\n        default:\n            assertUnreachable(scope);\n    }\n}\n\nexport function getFailed(data: TrueRetentionData, scope: Scope): number {\n    switch (scope) {\n        case Scope.Young:\n            return data.youngFailed;\n        case Scope.Mature:\n            return data.matureFailed;\n        case Scope.All:\n            return data.youngFailed + data.matureFailed;\n        default:\n            assertUnreachable(scope);\n    }\n}\n\nexport interface RowData {\n    title: string;\n    data: TrueRetentionData;\n}\n\nexport function getRowData(\n    allData: PeriodTrueRetentionData,\n    revlogRange: RevlogRange,\n): RowData[] {\n    const rowData: RowData[] = [\n        {\n            title: tr.statisticsTrueRetentionToday(),\n            data: allData.today,\n        },\n        {\n            title: tr.statisticsTrueRetentionYesterday(),\n            data: allData.yesterday,\n        },\n        {\n            title: tr.statisticsTrueRetentionWeek(),\n            data: allData.week,\n        },\n        {\n            title: tr.statisticsTrueRetentionMonth(),\n            data: allData.month,\n        },\n        {\n            title: tr.statisticsTrueRetentionYear(),\n            data: allData.year,\n        },\n    ];\n\n    if (revlogRange === RevlogRange.All) {\n        rowData.push({\n            title: tr.statisticsTrueRetentionAllTime(),\n            data: allData.allTime,\n        });\n    }\n\n    return rowData;\n}\n\nexport function calculateRetentionPercentageString(\n    passed: number,\n    failed: number,\n): string {\n    const total = passed + failed;\n\n    if (total === 0) {\n        return tr.statisticsTrueRetentionNotApplicable();\n    }\n\n    const numberFormat = createLocaleNumberFormat({\n        minimumFractionDigits: 1,\n        maximumFractionDigits: 1,\n        style: \"percent\",\n    });\n\n    return numberFormat.format(passed / total);\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/ImageOcclusionPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Container from \"$lib/components/Container.svelte\";\n\n    import type { IOMode } from \"./lib\";\n    import MasksEditor from \"./MaskEditor.svelte\";\n    import Notes from \"./Notes.svelte\";\n    import { textEditingState } from \"./store\";\n\n    export let mode: IOMode;\n\n    const items = [\n        { label: tr.notetypesOcclusionMask(), value: 1 },\n        { label: tr.notetypesOcclusionNote(), value: 2 },\n    ];\n\n    let activeTabValue = 1;\n    const tabChange = (tabValue) => {\n        textEditingState.set(tabValue === 2);\n        activeTabValue = tabValue;\n    };\n</script>\n\n<Container class=\"image-occlusion\">\n    <div class=\"tab-buttons\">\n        {#each items as item}\n            <button\n                class=\"tab-item {activeTabValue === item.value ? 'active' : ''} \n                    {item.value === 1 ? 'left-border-radius' : 'right-border-radius'}\"\n                on:click={() => tabChange(item.value)}\n            >\n                {item.label}\n            </button>\n        {/each}\n    </div>\n\n    <div hidden={activeTabValue != 1}>\n        <MasksEditor {mode} on:save on:image-loaded />\n    </div>\n\n    <div hidden={activeTabValue != 2}>\n        <Notes />\n    </div>\n</Container>\n\n<style lang=\"scss\">\n    .tab-buttons {\n        display: flex;\n        position: absolute;\n        top: 2px;\n        left: 2px;\n    }\n    .tab-buttons .active {\n        background: var(--button-primary-bg);\n        color: white;\n    }\n\n    .tab-item {\n        justify-content: center;\n        align-items: center;\n        display: flex;\n        padding: 0px 6px 0px 6px;\n        height: 38px;\n        max-width: 60px;\n        font-size: small;\n        white-space: normal;\n        word-break: break-all;\n        hyphens: auto;\n    }\n\n    :global(.image-occlusion) {\n        --gutter-inline: 0.5rem;\n\n        :global(.row) {\n            // rows have negative margins by default\n            --bs-gutter-x: 0;\n            // ensure equal spacing between tall rows like\n            // dropdowns, and short rows like checkboxes\n            min-height: 2.5em;\n            align-items: center;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/ImageOcclusionPicker.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Container from \"$lib/components/Container.svelte\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n\n    export let onPickImage: () => void;\n    export let onPickImageFromClipboard: () => void;\n</script>\n\n<Container class=\"image-occlusion-picker\">\n    <div id=\"io-pick-image-file\" style=\"padding-top: 60px; text-align: center;\">\n        <LabelButton\n            tabbable={true}\n            --border-left-radius=\"5px\"\n            --border-right-radius=\"5px\"\n            class=\"io-image-picker-button\"\n            on:click={onPickImage}\n        >\n            {tr.notetypesIoSelectImage()}\n        </LabelButton>\n    </div>\n    <div id=\"io-pick-image-clipboard\" style=\"padding-top: 30px; text-align: center;\">\n        <LabelButton\n            tabbable={true}\n            --border-left-radius=\"5px\"\n            --border-right-radius=\"5px\"\n            class=\"io-image-picker-button\"\n            on:click={onPickImageFromClipboard}\n        >\n            {tr.notetypesIoPasteImageFromClipboard()}\n        </LabelButton>\n    </div>\n</Container>\n\n<style lang=\"scss\">\n    :global(.io-image-picker-button) {\n        margin: auto;\n        padding: 0px 8px 0px 8px !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/MaskEditor.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n\n<script lang=\"ts\">\n    import type { fabric } from \"fabric\";\n    import { createEventDispatcher, onDestroy, onMount } from \"svelte\";\n\n    import type { IOMode } from \"./lib\";\n    import {\n        type ImageLoadedEvent,\n        setupMaskEditor,\n        setupMaskEditorForEdit,\n    } from \"./mask-editor\";\n    import Toolbar from \"./Toolbar.svelte\";\n    import { MaskEditorAPI } from \"./tools/api\";\n    import { onResize } from \"./tools/tool-zoom\";\n    import { saveNeededStore } from \"./store\";\n\n    export let mode: IOMode;\n    const iconSize = 80;\n    let innerWidth = 0;\n    const startingTool = mode.kind === \"add\" ? \"draw-rectangle\" : \"cursor\";\n    let canvas: fabric.Canvas | null = null;\n\n    $: {\n        globalThis.maskEditor = canvas ? new MaskEditorAPI(canvas) : null;\n    }\n\n    const dispatch = createEventDispatcher();\n\n    function onImageLoaded({ path, noteId }: ImageLoadedEvent) {\n        dispatch(\"image-loaded\", { path, noteId });\n    }\n\n    const unsubscribe = saveNeededStore.subscribe((saveNeeded: boolean) => {\n        if (saveNeeded === false) {\n            return;\n        }\n        dispatch(\"save\");\n        saveNeededStore.set(false);\n    });\n\n    function init(_node: HTMLDivElement) {\n        if (mode.kind == \"add\") {\n            if (\"clonedNoteId\" in mode) {\n                // Editing occlusions on a new note cloned from an existing note via \"Create copy\"\n                setupMaskEditorForEdit(mode.clonedNoteId, onImageLoaded).then(\n                    (canvas1) => {\n                        canvas = canvas1;\n                    },\n                );\n            } else {\n                // Editing occlusions on a new note through the \"Add\" window\n                setupMaskEditor(mode.imagePath, onImageLoaded).then((canvas1) => {\n                    canvas = canvas1;\n                });\n            }\n        } else {\n            // Editing occlusions on an existing note through the \"Browser\" window\n            setupMaskEditorForEdit(mode.noteId, onImageLoaded).then((canvas1) => {\n                canvas = canvas1;\n            });\n        }\n    }\n\n    onMount(() => {\n        window.addEventListener(\"resize\", resizeEvent);\n    });\n\n    onDestroy(() => {\n        window.removeEventListener(\"resize\", resizeEvent);\n        unsubscribe();\n    });\n\n    const resizeEvent = () => {\n        if (canvas === null) {\n            return;\n        }\n        onResize(canvas);\n    };\n</script>\n\n<Toolbar {canvas} {iconSize} activeTool={startingTool} />\n<div class=\"editor-main\" bind:clientWidth={innerWidth}>\n    <div class=\"editor-container\" use:init>\n        <!-- svelte-ignore a11y-missing-attribute -->\n        <img id=\"image\" />\n        <canvas id=\"canvas\"></canvas>\n    </div>\n</div>\n\n<style lang=\"scss\">\n    .editor-main {\n        position: absolute;\n        top: 42px;\n        left: 36px;\n        bottom: 2px;\n        right: 2px;\n        border: 1px solid var(--border);\n        overflow: auto;\n        outline: none !important;\n    }\n\n    :global([dir=\"rtl\"]) .editor-main {\n        left: 2px;\n        right: 36px;\n    }\n\n    .editor-container {\n        width: 100%;\n        height: 100%;\n        position: relative;\n        direction: ltr;\n        overflow: hidden;\n    }\n\n    #image {\n        position: absolute;\n    }\n\n    :global(.upper-canvas) {\n        border: 0.5px solid var(--border-strong);\n        border-width: thin;\n    }\n\n    :global(.canvas-container) {\n        position: absolute;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/Notes.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Col from \"$lib/components/Col.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n\n    import NotesToolbar from \"./notes-toolbar/NotesToolbar.svelte\";\n    import { notesDataStore, tagsWritable } from \"./store\";\n    import Tags from \"./Tags.svelte\";\n\n    const notesFields = [\n        {\n            id: \"header\",\n            title: tr.notetypesHeader(),\n            divValue: \"\",\n            textareaValue: \"\",\n        },\n        {\n            id: \"back-extra\",\n            title: tr.notetypesBackExtraField(),\n            divValue: \"\",\n            textareaValue: \"\",\n        },\n    ];\n    $: notesDataStore.set(notesFields);\n</script>\n\n<div class=\"note-toolbar\">\n    <NotesToolbar />\n</div>\n\n{#each notesFields as field}\n    <Row --cols={1}>\n        <Col --col-size={1}>\n            {field.title}\n        </Col>\n    </Row>\n    <Row --cols={1}>\n        <div class=\"note-container\">\n            <div\n                id=\"{field.id}--div\"\n                bind:innerHTML={field.divValue}\n                class=\"text-editor\"\n                on:input={() => {\n                    field.textareaValue = field.divValue;\n                }}\n                contenteditable\n            ></div>\n            <textarea\n                id=\"{field.id}--textarea\"\n                class=\"text-area\"\n                bind:value={field.textareaValue}\n            ></textarea>\n        </div>\n    </Row>\n{/each}\n<div style=\"margin-top: 10px;\">\n    <Tags {tagsWritable} />\n</div>\n\n<style lang=\"scss\">\n    .text-area {\n        height: 120px;\n        width: 100%;\n        display: none;\n        background: var(--canvas-elevated);\n        border: 2px solid var(--border);\n        border-radius: var(--border-radius);\n        outline: none;\n        resize: none;\n        overflow: auto;\n    }\n\n    .text-editor {\n        height: 80px;\n        border: 1px solid var(--border);\n        border-radius: var(--border-radius);\n        padding: 5px;\n        overflow: auto;\n        outline: none;\n        background: var(--canvas-elevated);\n    }\n\n    .note-toolbar {\n        margin-left: 106px;\n        margin-top: 2px;\n        display: flex;\n        overflow-x: auto;\n        height: 38px;\n    }\n\n    ::-webkit-scrollbar {\n        width: 0.1em !important;\n        height: 0.1em !important;\n    }\n\n    .note-container {\n        width: 100%;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/StickyFooter.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import ButtonGroup from \"$lib/components/ButtonGroup.svelte\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n\n    import type { IOMode } from \"./lib\";\n\n    export let mode: IOMode;\n    export let addNote: () => void;\n</script>\n\n<div style:flex-grow=\"1\"></div>\n<div class=\"sticky-footer\">\n    <ButtonGroup size={2}>\n        <LabelButton\n            --border-left-radius=\"5px\"\n            --border-right-radius=\"5px\"\n            on:click={addNote}\n            class=\" bottom-btn\"\n        >\n            {mode.kind === \"add\" ? tr.actionsAdd() : tr.importingUpdate()}\n        </LabelButton>\n    </ButtonGroup>\n</div>\n\n<style lang=\"scss\">\n    .sticky-footer {\n        position: fixed;\n        bottom: 0;\n        left: 0;\n        right: 0;\n        z-index: 99;\n        margin: 0;\n        padding: 0.25rem;\n        background: var(--canvas);\n        border-style: solid none none;\n        border-color: var(--border);\n        border-width: thin;\n        display: flex;\n        justify-content: flex-end;\n    }\n\n    @media only screen and (max-width: 640px) {\n        .sticky-footer {\n            justify-content: center;\n        }\n    }\n\n    :global(.bottom-btn) {\n        margin: 2px;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/Tags.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Row from \"$lib/components/Row.svelte\";\n    import TagEditor from \"$lib/tag-editor/TagEditor.svelte\";\n\n    export let tagsWritable;\n    let globalTags: string[];\n</script>\n\n<Row --cols={1}>\n    <TagEditor\n        tags={tagsWritable}\n        on:tagsupdate={({ detail }) => {\n            globalTags = detail.tags;\n            tagsWritable.set(globalTags);\n        }}\n        keyCombination={\"Control+T\"}\n    />\n</Row>\n\n<style lang=\"scss\">\n    :global(.tag-editor) {\n        margin: 10px 0px 10px 0px;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/Toast.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { mdiClose } from \"$lib/components/icons\";\n\n    export let type: \"success\" | \"error\" = \"success\";\n    export let message;\n    export let showToast = false;\n    const closeToast = () => {\n        showToast = false;\n    };\n</script>\n\n{#if showToast}\n    <div class=\"toast-container desktop-only\">\n        <div class=\"toast {type === 'success' ? 'success' : 'error'}\">\n            {message}\n            <IconButton iconSize={96} on:click={closeToast} class=\"toast-icon\">\n                <Icon icon={mdiClose} />\n            </IconButton>\n        </div>\n    </div>\n{/if}\n\n<style>\n    .toast-container {\n        position: fixed;\n        bottom: 3rem;\n        z-index: 100;\n        width: 100%;\n        text-align: center;\n        display: flex;\n        justify-content: center;\n    }\n    .toast {\n        display: flex;\n        align-items: center;\n        padding: 1rem;\n        background-color: #fff;\n        border-radius: 0.5rem;\n        box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);\n        width: 60%;\n        justify-content: space-between;\n    }\n    .success {\n        background: #66bb6a;\n        color: white;\n    }\n    .error {\n        background: #ef5350;\n        color: white;\n    }\n    :global(.toast-icon) {\n        background: unset !important;\n        color: white !important;\n        border: none !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/Toolbar.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { directionKey } from \"@tslib/context-keys\";\n    import { on } from \"@tslib/events\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n    import type { Callback } from \"@tslib/typing\";\n    import { singleCallback } from \"@tslib/typing\";\n    import { getContext, onDestroy, onMount } from \"svelte\";\n    import { writable, type Readable } from \"svelte/store\";\n\n    import DropdownItem from \"$lib/components/DropdownItem.svelte\";\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import {\n        mdiEye,\n        mdiFormatAlignCenter,\n        mdiSquare,\n        mdiViewDashboard,\n    } from \"$lib/components/icons\";\n    import Popover from \"$lib/components/Popover.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import WithFloating from \"$lib/components/WithFloating.svelte\";\n\n    import {\n        hideAllGuessOne,\n        ioMaskEditorVisible,\n        textEditingState,\n        saveNeededStore,\n        opacityStateStore,\n    } from \"./store\";\n    import { get } from \"svelte/store\";\n    import { drawEllipse, drawPolygon, drawRectangle, drawText } from \"./tools/index\";\n    import { makeMaskTransparent, SHAPE_MASK_COLOR } from \"./tools/lib\";\n    import { enableSelectable, stopDraw } from \"./tools/lib\";\n    import {\n        alignTools,\n        deleteDuplicateTools,\n        groupUngroupTools,\n        zoomTools,\n    } from \"./tools/more-tools\";\n    import { toggleTranslucentKeyCombination } from \"./tools/shortcuts\";\n    import { tools, type ActiveTool } from \"./tools/tool-buttons\";\n    import { drawCursor } from \"./tools/tool-cursor\";\n    import { removeUnfinishedPolygon } from \"./tools/tool-polygon\";\n    import { undoRedoTools, undoStack } from \"./tools/tool-undo-redo\";\n    import {\n        disablePan,\n        disableZoom,\n        enablePan,\n        enableZoom,\n        onWheelDrag,\n        onWheelDragX,\n    } from \"./tools/tool-zoom\";\n    import { fillMask } from \"./tools/tool-fill\";\n    import { getCustomColours, saveCustomColours } from \"@generated/backend\";\n\n    export let canvas;\n    export let iconSize;\n    export let activeTool: ActiveTool = \"cursor\";\n    let showAlignTools = false;\n    let leftPos = 82;\n    let maskOpacity = false;\n    let showFloating = false;\n    const direction = getContext<Readable<\"ltr\" | \"rtl\">>(directionKey);\n    // handle zoom event when mouse scroll and ctrl key are hold for panzoom\n    let spaceClicked = false;\n    let controlClicked = false;\n    let shiftClicked = false;\n    let move = false;\n    const spaceKey = \" \";\n    const controlKey = \"Control\";\n    const shiftKey = \"Shift\";\n    let removeHandlers: Callback;\n    let colourRef: HTMLInputElement | undefined;\n    const colour = writable(SHAPE_MASK_COLOR);\n\n    const customColorPickerPalette = writable<string[]>([]);\n\n    async function loadCustomColours() {\n        customColorPickerPalette.set(\n            (await getCustomColours({})).colours.filter(\n                (hex) => !hex.startsWith(\"#ffffff\"),\n            ),\n        );\n    }\n\n    function onClick(event: MouseEvent) {\n        const upperCanvas = document.querySelector(\".upper-canvas\");\n        if (event.target == upperCanvas) {\n            showAlignTools = false;\n        }\n    }\n\n    function onMousemove() {\n        if (spaceClicked || move) {\n            disableFunctions();\n            enablePan(canvas);\n        }\n    }\n\n    function onMouseup() {\n        if (spaceClicked) {\n            spaceClicked = false;\n        }\n        if (move) {\n            move = false;\n        }\n    }\n\n    function onKeyup(event: KeyboardEvent) {\n        if (\n            event.key === spaceKey ||\n            event.key === controlKey ||\n            event.key === shiftKey\n        ) {\n            spaceClicked = false;\n            controlClicked = false;\n            shiftClicked = false;\n            move = false;\n\n            disableFunctions();\n            handleToolChanges(activeTool);\n        }\n    }\n\n    function onKeydown(event: KeyboardEvent) {\n        if (event.key === spaceKey) {\n            spaceClicked = true;\n        }\n        if (event.key === controlKey) {\n            controlClicked = true;\n        }\n        if (event.key === shiftKey) {\n            shiftClicked = true;\n        }\n    }\n\n    function onWheel(event: WheelEvent) {\n        // allow scroll in fields, when mask editor hidden\n        if (!$ioMaskEditorVisible) {\n            return;\n        }\n\n        if (event.ctrlKey) {\n            controlClicked = true;\n        }\n        if (event.shiftKey) {\n            shiftClicked = true;\n        }\n\n        event.preventDefault();\n\n        if (controlClicked) {\n            disableFunctions();\n            enableZoom(canvas);\n            return;\n        }\n\n        if (shiftClicked) {\n            onWheelDragX(canvas, event);\n            return;\n        }\n\n        onWheelDrag(canvas, event);\n    }\n\n    // initializes lastPosX and lastPosY because it is undefined in touchmove event\n    function onTouchstart(event: TouchEvent) {\n        const canvas = globalThis.canvas;\n        canvas.lastPosX = event.touches[0].clientX;\n        canvas.lastPosY = event.touches[0].clientY;\n    }\n\n    // initializes lastPosX and lastPosY because it is undefined before mousemove event\n    function onMousemoveDocument(event: MouseEvent) {\n        if (spaceClicked) {\n            canvas.lastPosX = event.clientX;\n            canvas.lastPosY = event.clientY;\n        }\n    }\n\n    const handleToolChanges = (newActiveTool: ActiveTool, clicked: boolean = false) => {\n        disableFunctions();\n        enableSelectable(canvas, true);\n        // remove unfinished polygon when switching to other tools\n        removeUnfinishedPolygon(canvas);\n\n        switch (newActiveTool) {\n            case \"cursor\":\n                drawCursor(canvas);\n                break;\n            case \"draw-rectangle\":\n                drawRectangle(canvas);\n                break;\n            case \"draw-ellipse\":\n                drawEllipse(canvas);\n                break;\n            case \"draw-polygon\":\n                drawPolygon(canvas);\n                break;\n            case \"draw-text\":\n                drawText(canvas, () => {\n                    activeTool = \"cursor\";\n                    handleToolChanges(activeTool);\n                });\n                break;\n            case \"fill-mask\":\n                if (clicked) {\n                    colourRef?.click();\n                }\n                fillMask(canvas, colour);\n                break;\n        }\n    };\n\n    // handle tool changes after initialization\n    $: if (canvas) {\n        handleToolChanges(activeTool);\n    }\n\n    const disableFunctions = () => {\n        stopDraw(canvas);\n        disableZoom(canvas);\n        disablePan(canvas);\n    };\n\n    function changeOcclusionType(occlusionType: \"all\" | \"one\"): void {\n        $hideAllGuessOne = occlusionType === \"all\";\n        saveNeededStore.set(true);\n    }\n\n    onMount(() => {\n        maskOpacity = get(opacityStateStore);\n        removeHandlers = singleCallback(\n            on(document, \"click\", onClick),\n            on(window, \"mousemove\", onMousemove),\n            on(window, \"mouseup\", onMouseup),\n            on(window, \"keyup\", onKeyup),\n            on(window, \"keydown\", onKeydown),\n            on(window, \"wheel\", onWheel, { passive: false }),\n            on(document, \"touchstart\", onTouchstart),\n            on(document, \"mousemove\", onMousemoveDocument),\n        );\n        loadCustomColours();\n    });\n\n    onDestroy(() => {\n        removeHandlers();\n    });\n</script>\n\n<datalist id=\"colour-palette\">\n    <option>{SHAPE_MASK_COLOR}</option>\n    {#each $customColorPickerPalette as colour}\n        <option>{colour}</option>\n    {/each}\n</datalist>\n\n<input\n    type=\"color\"\n    bind:this={colourRef}\n    style:display=\"none\"\n    list=\"colour-palette\"\n    value={SHAPE_MASK_COLOR}\n    on:input={(e) => ($colour = e.currentTarget!.value)}\n    on:change={() => saveCustomColours({})}\n/>\n\n<div class=\"tool-bar-container\" style:--fill-tool-colour={$colour}>\n    {#each tools as tool}\n        {@const active = activeTool == tool.id}\n        <IconButton\n            class=\"tool-icon-button {active ? 'active-tool' : ''} {tool.id}\"\n            iconSize={iconSize * (tool[\"iconSizeMult\"] ?? 1)}\n            tooltip=\"{tool.tooltip()} ({getPlatformString(tool.shortcut)})\"\n            {active}\n            on:click={() => {\n                activeTool = tool.id;\n                handleToolChanges(activeTool, true);\n            }}\n        >\n            <Icon icon={tool.icon} />\n        </IconButton>\n        {#if $ioMaskEditorVisible && !$textEditingState}\n            <Shortcut\n                keyCombination={tool.shortcut}\n                on:action={() => {\n                    activeTool = tool.id;\n                    handleToolChanges(activeTool, true);\n                }}\n            />\n        {/if}\n    {/each}\n</div>\n\n<div dir={$direction}>\n    <div class=\"top-tool-bar-container\">\n        <WithFloating\n            show={showFloating}\n            closeOnInsideClick\n            inline\n            on:close={() => (showFloating = false)}\n        >\n            <IconButton\n                class=\"top-tool-icon-button border-radius dropdown-tool-mode\"\n                slot=\"reference\"\n                tooltip={tr.editingImageOcclusionMode()}\n                {iconSize}\n                on:click={() => (showFloating = !showFloating)}\n            >\n                <Icon icon={$hideAllGuessOne ? mdiViewDashboard : mdiSquare} />\n            </IconButton>\n\n            <Popover slot=\"floating\">\n                <DropdownItem\n                    active={$hideAllGuessOne}\n                    on:click={() => changeOcclusionType(\"all\")}\n                >\n                    <span>{tr.notetypesHideAllGuessOne()}</span>\n                </DropdownItem>\n                <DropdownItem\n                    active={!$hideAllGuessOne}\n                    on:click={() => changeOcclusionType(\"one\")}\n                >\n                    <span>{tr.notetypesHideOneGuessOne()}</span>\n                </DropdownItem>\n            </Popover>\n        </WithFloating>\n\n        <!-- undo & redo tools -->\n        <div class=\"undo-redo-button\">\n            {#each undoRedoTools as tool}\n                <IconButton\n                    class=\"top-tool-icon-button {tool.name === 'undo'\n                        ? 'left-border-radius'\n                        : 'right-border-radius'}\"\n                    {iconSize}\n                    on:click={() => {\n                        tool.action();\n                        handleToolChanges(activeTool);\n                    }}\n                    tooltip=\"{tool.tooltip()} ({getPlatformString(tool.shortcut)})\"\n                    disabled={tool.name === \"undo\"\n                        ? !$undoStack.undoable\n                        : !$undoStack.redoable}\n                >\n                    <Icon icon={tool.icon} />\n                </IconButton>\n                {#if $ioMaskEditorVisible && !$textEditingState}\n                    <Shortcut keyCombination={tool.shortcut} on:action={tool.action} />\n                {/if}\n            {/each}\n        </div>\n\n        <!-- zoom tools -->\n        <div class=\"tool-button-container\">\n            {#each zoomTools as tool}\n                <IconButton\n                    class=\"top-tool-icon-button {tool.name === 'zoomOut'\n                        ? 'left-border-radius'\n                        : ''} {tool.name === 'zoomReset' ? 'right-border-radius' : ''}\"\n                    {iconSize}\n                    tooltip=\"{tool.tooltip()} ({getPlatformString(tool.shortcut)})\"\n                    on:click={() => {\n                        tool.action(canvas);\n                    }}\n                >\n                    <Icon icon={tool.icon} />\n                </IconButton>\n                {#if $ioMaskEditorVisible && !$textEditingState}\n                    <Shortcut\n                        keyCombination={tool.shortcut}\n                        on:action={() => {\n                            tool.action(canvas);\n                        }}\n                    />\n                {/if}\n            {/each}\n        </div>\n\n        <div class=\"tool-button-container\">\n            <!-- opacity tools -->\n            <IconButton\n                class=\"top-tool-icon-button left-border-radius\"\n                {iconSize}\n                tooltip=\"{tr.editingImageOcclusionToggleTranslucent()} ({getPlatformString(\n                    toggleTranslucentKeyCombination,\n                )})\"\n                on:click={() => {\n                    maskOpacity = !maskOpacity;\n                    makeMaskTransparent(canvas, maskOpacity);\n                }}\n            >\n                <Icon icon={mdiEye} />\n            </IconButton>\n            {#if $ioMaskEditorVisible && !$textEditingState}\n                <Shortcut\n                    keyCombination={toggleTranslucentKeyCombination}\n                    on:action={() => {\n                        maskOpacity = !maskOpacity;\n                        makeMaskTransparent(canvas, maskOpacity);\n                    }}\n                />\n            {/if}\n\n            <!-- cursor tools -->\n            {#each deleteDuplicateTools as tool}\n                <IconButton\n                    class=\"top-tool-icon-button {tool.name === 'duplicate'\n                        ? 'right-border-radius'\n                        : ''}\"\n                    {iconSize}\n                    tooltip=\"{tool.tooltip()} ({getPlatformString(tool.shortcut)})\"\n                    on:click={() => {\n                        tool.action(canvas);\n                        undoStack.onObjectModified();\n                    }}\n                >\n                    <Icon icon={tool.icon} />\n                </IconButton>\n                {#if $ioMaskEditorVisible && !$textEditingState}\n                    <Shortcut\n                        keyCombination={tool.shortcut}\n                        on:action={() => {\n                            tool.action(canvas);\n                            saveNeededStore.set(true);\n                        }}\n                    />\n                {/if}\n            {/each}\n        </div>\n\n        <div class=\"tool-button-container\">\n            <!-- group & ungroup tools -->\n            {#each groupUngroupTools as tool}\n                <IconButton\n                    class=\"top-tool-icon-button {tool.name === 'group'\n                        ? 'left-border-radius'\n                        : ''}\"\n                    {iconSize}\n                    tooltip=\"{tool.tooltip()} ({getPlatformString(tool.shortcut)})\"\n                    on:click={() => {\n                        tool.action(canvas);\n                        undoStack.onObjectModified();\n                    }}\n                >\n                    <Icon icon={tool.icon} />\n                </IconButton>\n                {#if $ioMaskEditorVisible && !$textEditingState}\n                    <Shortcut\n                        keyCombination={tool.shortcut}\n                        on:action={() => {\n                            tool.action(canvas);\n                            saveNeededStore.set(true);\n                        }}\n                    />\n                {/if}\n            {/each}\n\n            <IconButton\n                class=\"top-tool-icon-button dropdown-tool right-border-radius\"\n                {iconSize}\n                tooltip={tr.editingImageOcclusionAlignment()}\n                on:click={(e) => {\n                    showAlignTools = !showAlignTools;\n                    leftPos = e.pageX - 100;\n                }}\n            >\n                <Icon icon={mdiFormatAlignCenter} />\n            </IconButton>\n        </div>\n    </div>\n\n    <div class:show={showAlignTools} class=\"dropdown-content\" style=\"left:{leftPos}px;\">\n        {#each alignTools as alignTool}\n            <IconButton\n                class=\"top-tool-icon-button\"\n                {iconSize}\n                tooltip=\"{alignTool.tooltip()} ({getPlatformString(\n                    alignTool.shortcut,\n                )})\"\n                on:click={() => {\n                    alignTool.action(canvas);\n                    undoStack.onObjectModified();\n                }}\n            >\n                <Icon icon={alignTool.icon} />\n            </IconButton>\n            {#if $ioMaskEditorVisible && !$textEditingState}\n                <Shortcut\n                    keyCombination={alignTool.shortcut}\n                    on:action={() => {\n                        alignTool.action(canvas);\n                    }}\n                />\n            {/if}\n        {/each}\n    </div>\n</div>\n\n<style>\n    .top-tool-bar-container {\n        display: flex;\n        overflow-y: scroll;\n        z-index: 99;\n        margin-left: 106px;\n        margin-top: 2px;\n    }\n\n    :global([dir=\"rtl\"] .top-tool-bar-container) {\n        margin-left: unset;\n        margin-right: 28px;\n    }\n\n    .undo-redo-button {\n        margin-right: 2px;\n        display: flex;\n    }\n\n    .tool-button-container {\n        margin-left: 2px;\n        margin-right: 2px;\n        display: flex;\n    }\n\n    :global(.left-border-radius) {\n        border-radius: 5px 0 0 5px !important;\n    }\n\n    :global([dir=\"rtl\"] .left-border-radius) {\n        border-radius: 0 5px 5px 0 !important;\n    }\n\n    :global(.right-border-radius) {\n        border-radius: 0 5px 5px 0 !important;\n    }\n\n    :global([dir=\"rtl\"] .right-border-radius) {\n        border-radius: 5px 0 0 5px !important;\n    }\n\n    :global(.border-radius) {\n        border-radius: 5px !important;\n    }\n\n    :global(.top-tool-icon-button) {\n        border: unset;\n        display: inline;\n        width: 32px;\n        height: 32px;\n        margin: unset;\n        padding: 6px !important;\n        font-size: 16px !important;\n    }\n\n    :global(.top-tool-icon-button:active) {\n        background: var(--highlight-bg) !important;\n    }\n\n    .dropdown-content {\n        display: none;\n        position: absolute;\n        z-index: 100;\n        top: 40px;\n        margin-top: 1px;\n    }\n\n    .show {\n        display: table;\n    }\n\n    ::-webkit-scrollbar {\n        width: 0.1em !important;\n        height: 0.1em !important;\n    }\n\n    .tool-bar-container {\n        position: fixed;\n        top: 42px;\n        left: 2px;\n        height: 100%;\n        border-right: 1px solid var(--border);\n        overflow-y: auto;\n        width: 32px;\n        z-index: 99;\n        background: var(--canvas-elevated);\n        padding-bottom: 100px;\n    }\n\n    :global(.fill-mask svg) {\n        fill: var(--fill-tool-colour) !important;\n        stroke: black;\n        stroke-width: 1px;\n    }\n\n    :global([dir=\"rtl\"] .tool-bar-container) {\n        left: unset;\n        right: 2px;\n    }\n\n    :global(.tool-icon-button) {\n        border: unset;\n        display: block;\n        width: 32px;\n        height: 32px;\n        margin: unset;\n        padding: 6px !important;\n    }\n\n    :global(.active-tool) {\n        color: white !important;\n        background: var(--button-primary-bg) !important;\n    }\n\n    :global(.icon-border-radius) {\n        border-radius: 5px !important;\n    }\n\n    :global(.dropdown-tool-mode) {\n        height: 38px !important;\n        display: inline;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/[...imagePathOrNoteId]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ImageOcclusionPage from \"../ImageOcclusionPage.svelte\";\n    import type { PageData } from \"./$types\";\n\n    export let data: PageData;\n</script>\n\n<ImageOcclusionPage mode={data.mode} />\n"
  },
  {
    "path": "ts/routes/image-occlusion/[...imagePathOrNoteId]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { get } from \"svelte/store\";\n\nimport { addOrUpdateNote } from \"../add-or-update-note.svelte\";\nimport type { IOMode } from \"../lib\";\nimport { hideAllGuessOne } from \"../store\";\nimport type { PageLoad } from \"./$types\";\n\nasync function save(): Promise<void> {\n    addOrUpdateNote(globalThis[\"anki\"].imageOcclusion.mode, get(hideAllGuessOne));\n}\n\nexport const load = (async ({ params }) => {\n    let mode: IOMode;\n    if (/^\\d+/.test(params.imagePathOrNoteId)) {\n        mode = { kind: \"edit\", noteId: Number(params.imagePathOrNoteId) };\n    } else {\n        mode = { kind: \"add\", imagePath: params.imagePathOrNoteId, notetypeId: 0 };\n    }\n\n    // for adding note from mobile devices\n    globalThis.anki = globalThis.anki || {};\n    globalThis.anki.imageOcclusion = {\n        mode,\n        save,\n    };\n\n    return {\n        mode,\n    };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/image-occlusion/add-or-update-note.svelte.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { OpChanges } from \"@generated/anki/collection_pb\";\nimport { addImageOcclusionNote, updateImageOcclusionNote } from \"@generated/backend\";\nimport * as tr from \"@generated/ftl\";\nimport { get } from \"svelte/store\";\n\nimport { mount } from \"svelte\";\nimport type { IOAddingMode, IOMode } from \"./lib\";\nimport { exportShapesToClozeDeletions } from \"./shapes/to-cloze\";\nimport { notesDataStore, tagsWritable } from \"./store\";\nimport Toast from \"./Toast.svelte\";\n\nexport const addOrUpdateNote = async function(\n    mode: IOMode,\n    occludeInactive: boolean,\n): Promise<void> {\n    const { clozes: occlusionCloze, noteCount } = exportShapesToClozeDeletions(occludeInactive);\n    if (noteCount === 0) {\n        return;\n    }\n\n    const fieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(notesDataStore);\n    const tags = get(tagsWritable);\n    let header = fieldsData[0].textareaValue;\n    let backExtra = fieldsData[1].textareaValue;\n\n    header = header ? `<div>${header}</div>` : \"\";\n    backExtra = backExtra ? `<div>${backExtra}</div>` : \"\";\n\n    if (mode.kind == \"edit\") {\n        const result = await updateImageOcclusionNote({\n            noteId: BigInt(mode.noteId),\n            occlusions: occlusionCloze,\n            header,\n            backExtra,\n            tags,\n        });\n        if (result.note) {\n            showResult(mode.noteId, result, noteCount);\n        }\n    } else {\n        const result = await addImageOcclusionNote({\n            // IOCloningMode is not used on mobile\n            notetypeId: BigInt((<IOAddingMode> mode).notetypeId),\n            imagePath: (<IOAddingMode> mode).imagePath,\n            occlusions: occlusionCloze,\n            header,\n            backExtra,\n            tags,\n        });\n        showResult(null, result, noteCount);\n    }\n};\n\n// show toast message\nconst showResult = (noteId: number | null, result: OpChanges, count: number) => {\n    const props = $state({\n        message: noteId ? tr.browsingCardsUpdated({ count: count }) : tr.importingCardsAdded({ count: count }),\n        type: \"success\" as \"error\" | \"success\",\n        showToast: true,\n    });\n    mount(Toast, {\n        target: document.body,\n        props,\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/canvas-scale.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Size } from \"./types\";\n\n/**\n * - Choose an appropriate size for the canvas based on the current container,\n * so the masks are sharp and legible.\n * - Safari doesn't allow canvas elements to be over 16M (4096x4096), so we need\n * to ensure the canvas is smaller than that size.\n * - Returns the size in actual pixels, not CSS size.\n */\nexport function optimumPixelSizeForCanvas(imageSize: Size, containerSize: Size): Size {\n    let { width, height } = imageSize;\n\n    const pixelScale = window.devicePixelRatio;\n    containerSize.width *= pixelScale;\n    containerSize.height *= pixelScale;\n\n    // Scale image dimensions to fit in container, retaining aspect ratio.\n    // We take the minimum of width/height scales, as that's the one that is\n    // potentially limiting the image from expanding.\n    const containerScale = Math.min(containerSize.width / imageSize.width, containerSize.height / imageSize.height);\n    width *= containerScale;\n    height *= containerScale;\n\n    const maximumPixels = 4096 * 4096;\n    const requiredPixels = width * height;\n    if (requiredPixels > maximumPixels) {\n        const shrinkScale = Math.sqrt(maximumPixels) / Math.sqrt(requiredPixels);\n        width *= shrinkScale;\n        height *= shrinkScale;\n    }\n\n    return {\n        width: Math.floor(width),\n        height: Math.floor(height),\n    };\n}\n\n/** See {@link optimumPixelSizeForCanvas()} */\nexport function optimumCssSizeForCanvas(imageSize: Size, containerSize: Size): Size {\n    const { width, height } = optimumPixelSizeForCanvas(imageSize, containerSize);\n    return {\n        width: width / window.devicePixelRatio,\n        height: height / window.devicePixelRatio,\n    };\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/fabric.d.ts",
    "content": "export {};\n\ndeclare global {\n    namespace fabric {\n        interface Object {\n            id: string;\n            ordinal: number;\n            /** a custom property set on groups in the ungrouping routine to avoid adding a spurious undo entry */\n            destroyed: boolean;\n        }\n    }\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/image-occlusion-base.scss",
    "content": "@use \"../lib/sass/vars\";\n@use \"../lib/sass/bootstrap-dark\";\n\n@import \"../lib/sass/base\";\n\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/grid\";\n@import \"../lib/sass/bootstrap-forms\";\n\n.night-mode {\n    @include bootstrap-dark.night-mode;\n}\n\nhtml {\n    overflow: hidden;\n}\n\n/** consistent font size **/\n:root {\n    --font-size: 16px;\n}\nbody {\n    font-size: 16px;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./image-occlusion-base.scss\";\n\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\nimport { get } from \"svelte/store\";\n\nimport { addOrUpdateNote } from \"./add-or-update-note.svelte\";\nimport ImageOcclusionPage from \"./ImageOcclusionPage.svelte\";\nimport type { IOMode } from \"./lib\";\nimport { hideAllGuessOne } from \"./store\";\n\nglobalThis.anki = globalThis.anki || {};\n\nconst i18n = setupI18n({\n    modules: [\n        ModuleName.IMPORTING,\n        ModuleName.DECKS,\n        ModuleName.EDITING,\n        ModuleName.NOTETYPES,\n        ModuleName.ACTIONS,\n        ModuleName.BROWSING,\n        ModuleName.UNDO,\n    ],\n});\n\nexport async function setupImageOcclusion(mode: IOMode, target = document.body): Promise<ImageOcclusionPage> {\n    checkNightMode();\n    await i18n;\n\n    async function addNote(): Promise<void> {\n        addOrUpdateNote(mode, get(hideAllGuessOne));\n    }\n\n    // for adding note from mobile devices\n    globalThis.anki.imageOcclusion = {\n        mode,\n        addNote,\n    };\n\n    return new ImageOcclusionPage({\n        target: target,\n        props: {\n            mode,\n        },\n    });\n}\n\nif (window.location.hash.startsWith(\"#test-\")) {\n    const imagePath = window.location.hash.replace(\"#test-\", \"\");\n    setupImageOcclusion({ kind: \"add\", imagePath, notetypeId: 0 });\n}\n\nif (window.location.hash.startsWith(\"#testforedit-\")) {\n    const noteId = parseInt(window.location.hash.replace(\"#testforedit-\", \"\"));\n    setupImageOcclusion({ kind: \"edit\", noteId });\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport interface IOAddingMode {\n    kind: \"add\";\n    notetypeId: number;\n    imagePath: string;\n}\n\nexport interface IOCloningMode {\n    kind: \"add\";\n    clonedNoteId: number;\n}\n\nexport interface IOEditingMode {\n    kind: \"edit\";\n    noteId: number;\n}\n\nexport type IOMode = IOAddingMode | IOEditingMode | IOCloningMode;\n"
  },
  {
    "path": "ts/routes/image-occlusion/mask-editor.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { protoBase64 } from \"@bufbuild/protobuf\";\nimport { getImageForOcclusion, getImageOcclusionNote } from \"@generated/backend\";\nimport * as tr from \"@generated/ftl\";\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport { optimumCssSizeForCanvas } from \"./canvas-scale\";\nimport {\n    hideAllGuessOne,\n    notesDataStore,\n    opacityStateStore,\n    saveNeededStore,\n    tagsWritable,\n    textEditingState,\n} from \"./store\";\nimport Toast from \"./Toast.svelte\";\nimport { addShapesToCanvasFromCloze } from \"./tools/add-from-cloze\";\nimport {\n    enableSelectable,\n    makeMaskTransparent,\n    makeShapesRemainInCanvas,\n    moveShapeToCanvasBoundaries,\n} from \"./tools/lib\";\nimport { modifiedPolygon } from \"./tools/tool-polygon\";\nimport { undoStack } from \"./tools/tool-undo-redo\";\nimport { enablePinchZoom, onResize, setCanvasSize } from \"./tools/tool-zoom\";\nimport type { Size } from \"./types\";\n\nexport interface ImageLoadedEvent {\n    path?: string;\n    noteId?: bigint;\n}\n\nexport const setupMaskEditor = async (\n    path: string,\n    onImageLoaded: (event: ImageLoadedEvent) => void,\n): Promise<fabric.Canvas> => {\n    const imageData = await getImageForOcclusion({ path });\n    const canvas = initCanvas();\n\n    // get image width and height\n    const image = document.getElementById(\"image\") as HTMLImageElement;\n    image.src = getImageData(imageData.data!, path);\n    image.onload = function() {\n        const size = optimumCssSizeForCanvas({ width: image.width, height: image.height }, containerSize());\n        setCanvasSize(canvas);\n        onImageLoaded({ path });\n        setupBoundingBox(canvas, size);\n        undoStack.reset();\n    };\n\n    return canvas;\n};\n\nexport const setupMaskEditorForEdit = async (\n    noteId: number,\n    onImageLoaded: (event: ImageLoadedEvent) => void,\n): Promise<fabric.Canvas> => {\n    const clozeNoteResponse = await getImageOcclusionNote({ noteId: BigInt(noteId) });\n    const kind = clozeNoteResponse.value?.case;\n    if (!kind || kind === \"error\") {\n        new Toast({\n            target: document.body,\n            props: {\n                message: tr.notetypesErrorGettingImagecloze(),\n                type: \"error\",\n            },\n        }).$set({ showToast: true });\n        throw \"error getting cloze\";\n    }\n\n    const clozeNote = clozeNoteResponse.value.value;\n    const canvas = initCanvas();\n\n    hideAllGuessOne.set(clozeNote.occludeInactive);\n\n    // get image width and height\n    const image = document.getElementById(\"image\") as HTMLImageElement;\n    image.src = getImageData(clozeNote.imageData!, clozeNote.imageFileName!);\n\n    image.onload = async function() {\n        const size = optimumCssSizeForCanvas(\n            { width: image.naturalWidth, height: image.naturalHeight },\n            containerSize(),\n        );\n        setCanvasSize(canvas);\n        const boundingBox = setupBoundingBox(canvas, size);\n        addShapesToCanvasFromCloze(canvas, boundingBox, clozeNote.occlusions);\n        enableSelectable(canvas, true);\n        addClozeNotesToTextEditor(clozeNote.header, clozeNote.backExtra, clozeNote.tags);\n        undoStack.reset();\n        window.requestAnimationFrame(() => {\n            onImageLoaded({ noteId: BigInt(noteId) });\n        });\n        if (get(opacityStateStore)) { makeMaskTransparent(canvas, true); }\n    };\n\n    return canvas;\n};\n\nfunction initCanvas(): fabric.Canvas {\n    const canvas = new fabric.Canvas(\"canvas\");\n    tagsWritable.set([]);\n    globalThis.canvas = canvas;\n    undoStack.setCanvas(canvas);\n    // find object per-pixel basis rather than according to bounding box,\n    // allow click through transparent area\n    fabric.Object.prototype.perPixelTargetFind = true;\n    // Disable uniform scaling\n    canvas.uniformScaling = false;\n    canvas.uniScaleKey = \"none\";\n    // disable object caching\n    fabric.Object.prototype.objectCaching = false;\n    // add a border to corner to handle blend of control\n    fabric.Object.prototype.transparentCorners = false;\n    fabric.Object.prototype.cornerStyle = \"circle\";\n    fabric.Object.prototype.cornerStrokeColor = \"#000000\";\n    fabric.Object.prototype.padding = 8;\n    // snap rotation around 0 by +-3deg\n    fabric.Object.prototype.snapAngle = 360;\n    fabric.Object.prototype.snapThreshold = 3;\n    // populate canvas.targets with subtargets during mouse events\n    fabric.Group.prototype.subTargetCheck = true;\n    // disable rotation when selecting\n    canvas.on(\"selection:created\", () => {\n        const g = canvas.getActiveObject();\n        if (g && g instanceof fabric.Group) { g.setControlsVisibility({ mtr: false }); }\n    });\n    canvas.on(\"object:modified\", (evt) => {\n        if (evt.target instanceof fabric.Polygon) {\n            modifiedPolygon(canvas, evt.target);\n            undoStack.onObjectModified();\n        }\n    });\n    canvas.on(\"text:editing:entered\", function() {\n        textEditingState.set(true);\n    });\n\n    canvas.on(\"text:editing:exited\", function() {\n        textEditingState.set(false);\n    });\n    canvas.on(\"object:removed\", () => {\n        saveNeededStore.set(true);\n    });\n    return canvas;\n}\n\nconst setupBoundingBox = (canvas: fabric.Canvas, size: Size): fabric.Rect => {\n    const boundingBox = new fabric.Rect({\n        fill: \"transparent\",\n        width: size.width,\n        height: size.height,\n        hasBorders: false,\n        hasControls: false,\n        lockMovementX: true,\n        lockMovementY: true,\n        selectable: false,\n        evented: false,\n    });\n    boundingBox[\"id\"] = \"boundingBox\";\n\n    canvas.add(boundingBox);\n    onResize(canvas);\n    makeShapesRemainInCanvas(canvas, boundingBox);\n    moveShapeToCanvasBoundaries(canvas, boundingBox);\n    // enable pinch zoom for mobile devices\n    enablePinchZoom(canvas);\n    return boundingBox;\n};\n\nconst getImageData = (imageData, path): string => {\n    const b64encoded = protoBase64.enc(imageData);\n    const extension = path.split(\".\").pop();\n    const mimeTypes = {\n        \"jpg\": \"jpeg\",\n        \"jpeg\": \"jpeg\",\n        \"gif\": \"gif\",\n        \"svg\": \"svg+xml\",\n        \"webp\": \"webp\",\n        \"avif\": \"avif\",\n        \"png\": \"png\",\n    };\n\n    const type = mimeTypes[extension] || \"png\";\n    return `data:image/${type};base64,${b64encoded}`;\n};\n\nconst addClozeNotesToTextEditor = (header: string, backExtra: string, tags: string[]) => {\n    const noteFieldsData: { id: string; title: string; divValue: string; textareaValue: string }[] = get(\n        notesDataStore,\n    );\n    noteFieldsData[0].divValue = header;\n    noteFieldsData[1].divValue = backExtra;\n    noteFieldsData[0].textareaValue = header;\n    noteFieldsData[1].textareaValue = backExtra;\n    tagsWritable.set(tags);\n\n    noteFieldsData.forEach((note) => {\n        const divId = `${note.id}--div`;\n        const textAreaId = `${note.id}--textarea`;\n        const divElement = document.getElementById(divId)!;\n        const textAreaElement = document.getElementById(textAreaId)! as HTMLTextAreaElement;\n        divElement.innerHTML = note.divValue;\n        textAreaElement.value = note.textareaValue;\n    });\n};\n\nfunction containerSize(): Size {\n    const container = document.querySelector(\".editor-main\")!;\n    return {\n        width: container.clientWidth,\n        height: container.clientHeight,\n    };\n}\n\nexport async function resetIOImage(path: string, onImageLoaded: (event: ImageLoadedEvent) => void) {\n    const imageData = await getImageForOcclusion({ path });\n    const image = document.getElementById(\"image\") as HTMLImageElement;\n    image.src = getImageData(imageData.data!, path);\n    const canvas = globalThis.canvas;\n\n    image.onload = async function() {\n        const size = optimumCssSizeForCanvas(\n            { width: image.naturalWidth, height: image.naturalHeight },\n            containerSize(),\n        );\n        image.width = size.width;\n        image.height = size.height;\n        setCanvasSize(canvas);\n        onImageLoaded({ path });\n        setupBoundingBox(canvas, size);\n    };\n}\nglobalThis.resetIOImage = resetIOImage;\n"
  },
  {
    "path": "ts/routes/image-occlusion/notes-toolbar/MoreTools.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { mdiCodeTags } from \"$lib/components/icons\";\n\n    import { changePreviewHTMLView } from \"./lib\";\n\n    export let iconSize;\n\n    const moreTools = [\n        {\n            name: \"code\",\n            title: \"Code\",\n            icon: mdiCodeTags,\n            action: changePreviewHTMLView,\n        },\n    ];\n</script>\n\n{#each moreTools as tool}\n    <IconButton\n        class=\"note-tool-icon-button right-border-radius\"\n        {iconSize}\n        on:click={() => tool.action()}\n        tooltip={tool.title}\n    >\n        <Icon icon={tool.icon} />\n    </IconButton>\n{/each}\n"
  },
  {
    "path": "ts/routes/image-occlusion/notes-toolbar/NotesToolbar.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import MoreTools from \"./MoreTools.svelte\";\n    import TextFormatting from \"./TextFormatting.svelte\";\n\n    const iconSize = 80;\n</script>\n\n<TextFormatting {iconSize} />\n<MoreTools {iconSize} />\n"
  },
  {
    "path": "ts/routes/image-occlusion/notes-toolbar/TextFormatting.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import {\n        mdiFormatBold,\n        mdiFormatItalic,\n        mdiFormatUnderline,\n    } from \"$lib/components/icons\";\n    import { execCommand } from \"$lib/domlib\";\n\n    export let iconSize;\n\n    const textFormatting = [\n        {\n            name: \"b\",\n            title: tr.editingBoldText(),\n            icon: mdiFormatBold,\n            action: \"bold\",\n        },\n        {\n            name: \"i\",\n            title: tr.editingItalicText(),\n            icon: mdiFormatItalic,\n            action: \"italic\",\n        },\n        {\n            name: \"u\",\n            title: tr.editingUnderlineText(),\n            icon: mdiFormatUnderline,\n            action: \"underline\",\n        },\n    ];\n\n    const textFormat = (tool: { name; title; icon; action }) => {\n        execCommand(tool.action, false, tool.name);\n    };\n</script>\n\n{#each textFormatting as tool}\n    <IconButton\n        id={\"note-tool-\" + tool.name}\n        class=\"note-tool-icon-button {tool.name === 'b' ? 'left-border-radius' : ''}\"\n        {iconSize}\n        tooltip={tool.title}\n        on:click={() => {\n            // setActiveTool(tool);\n            textFormat(tool);\n        }}\n    >\n        <Icon icon={tool.icon} />\n    </IconButton>\n{/each}\n\n<style lang=\"scss\">\n    :global(.note-tool-icon-button) {\n        padding: 4px !important;\n        border-radius: 2px !important;\n        padding: 0px 6px 0px 6px !important;\n    }\n\n    :global(.left-border-radius) {\n        border-radius: 5px 0 0 5px !important;\n    }\n\n    :global(.right-border-radius) {\n        border-radius: 0 5px 5px 0 !important;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/image-occlusion/notes-toolbar/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport NotesToolbar from \"./NotesToolbar.svelte\";\n\nexport default NotesToolbar;\n"
  },
  {
    "path": "ts/routes/image-occlusion/notes-toolbar/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport const changePreviewHTMLView = (): void => {\n    const activeElement = document.activeElement!;\n    if (!activeElement || !activeElement.id.includes(\"--\")) {\n        return;\n    }\n\n    const noteId = activeElement.id.split(\"--\")[0];\n    const divId = `${noteId}--div`;\n    const textAreaId = `${noteId}--textarea`;\n    const divElement = document.getElementById(divId)!;\n    const textAreaElement = document.getElementById(textAreaId)! as HTMLTextAreaElement;\n\n    if (divElement.style.display == \"none\") {\n        divElement.style.display = \"block\";\n        textAreaElement.style.display = \"none\";\n        divElement.focus();\n    } else {\n        divElement.style.display = \"none\";\n        textAreaElement.style.display = \"block\";\n        textAreaElement.focus();\n    }\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/review.scss",
    "content": "#image-occlusion-container {\n    position: relative;\n    // if height-constrained, ensure container is centered\n    margin: 0 auto;\n    // allow for 20px margin on html element, or short windows can truncate\n    // image\n    max-height: calc(95vh - 40px);\n}\n\n#image-occlusion-container img {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    // remove the default image limits, as we rely on container\n    max-width: unset;\n    max-height: unset;\n}\n\n#image-occlusion-canvas {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/review.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\n\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { optimumPixelSizeForCanvas } from \"./canvas-scale\";\nimport { Shape } from \"./shapes\";\nimport { Ellipse, extractShapesFromRenderedClozes, Polygon, Rectangle, Text } from \"./shapes\";\nimport { SHAPE_MASK_COLOR, TEXT_BACKGROUND_COLOR, TEXT_FONT_FAMILY, TEXT_PADDING } from \"./tools/lib\";\nimport type { Size } from \"./types\";\n\nexport type DrawShapesData = {\n    activeShapes: Shape[];\n    inactiveShapes: Shape[];\n    highlightShapes: Shape[];\n    properties: ShapeProperties;\n};\n\nexport type DrawShapesFilter = (\n    data: DrawShapesData,\n    context: CanvasRenderingContext2D,\n) => DrawShapesData | void;\n\nexport type DrawShapesCallback = (\n    data: DrawShapesData,\n    context: CanvasRenderingContext2D,\n) => void;\n\nexport const imageOcclusionAPI = {\n    setup: setupImageOcclusion,\n    drawShape,\n    Ellipse,\n    Polygon,\n    Rectangle,\n    Shape,\n    Text,\n};\n\ninterface SetupImageOcclusionOptions {\n    onWillDrawShapes?: DrawShapesFilter;\n    onDidDrawShapes?: DrawShapesCallback;\n}\n\nasync function setupImageOcclusion(setupOptions?: SetupImageOcclusionOptions): Promise<void> {\n    await waitForImage();\n    window.addEventListener(\"load\", () => {\n        window.addEventListener(\"resize\", () => setupImageOcclusion(setupOptions));\n    });\n    window.requestAnimationFrame(() => setupImageOcclusionInner(setupOptions));\n}\n\n/** We must make sure the image has loaded before we can access its dimensions.\n * This can happen if not preloading, or if preloading takes too long. */\nasync function waitForImage(): Promise<void> {\n    const image = document.querySelector<HTMLImageElement>(\n        \"#image-occlusion-container img\",\n    );\n    if (!image) {\n        // error will be handled later\n        return;\n    }\n\n    if (image.complete) {\n        return;\n    }\n\n    // Wait for the image to load\n    await new Promise<void>(resolve => {\n        image.addEventListener(\"load\", () => {\n            resolve();\n        });\n        image.addEventListener(\"error\", () => {\n            resolve();\n        });\n    });\n}\n\n/**\n * Calculate the size of the container that will fit in the viewport while having\n * the same aspect ratio as the image. This is a workaround for Qt5 WebEngine not\n * supporting the `aspect-ratio` CSS property.\n */\nfunction calculateContainerSize(\n    container: HTMLDivElement,\n    img: HTMLImageElement,\n): { width: number; height: number } {\n    const compStyle = getComputedStyle(container);\n\n    const compMaxWidth = parseFloat(compStyle.getPropertyValue(\"max-width\"));\n    const vw = container.parentElement!.clientWidth;\n    // respect 'max-width' if it is set narrower than the viewport\n    const maxWidth = Number.isNaN(compMaxWidth) || compMaxWidth > vw ? vw : compMaxWidth;\n\n    // see ./review.scss\n    const defaultMaxHeight = document.documentElement.clientHeight * 0.95 - 40;\n    const compMaxHeight = parseFloat(compStyle.getPropertyValue(\"max-height\"));\n    let maxHeight: number;\n    // If 'max-height' is set to 'unset' or 'initial' and the image is taller than\n    // the default max height, the container height is up to the image height.\n    if (Number.isNaN(compMaxHeight)) {\n        maxHeight = Math.max(img.naturalHeight, defaultMaxHeight);\n    } else if (compMaxHeight < defaultMaxHeight) {\n        maxHeight = compMaxHeight;\n    } else {\n        maxHeight = Math.max(defaultMaxHeight, Math.min(img.naturalHeight, compMaxHeight));\n    }\n\n    const ratio = Math.min(\n        maxWidth / img.naturalWidth,\n        maxHeight / img.naturalHeight,\n    );\n    return { width: img.naturalWidth * ratio, height: img.naturalHeight * ratio };\n}\n\nlet oneTimeSetupDone = false;\n\nasync function setupImageOcclusionInner(setupOptions?: SetupImageOcclusionOptions): Promise<void> {\n    const canvas = document.querySelector<HTMLCanvasElement>(\n        \"#image-occlusion-canvas\",\n    );\n    if (canvas == null) {\n        return;\n    }\n\n    const container = document.getElementById(\n        \"image-occlusion-container\",\n    ) as HTMLDivElement;\n    const image = document.querySelector<HTMLImageElement>(\n        \"#image-occlusion-container img\",\n    );\n    if (image == null) {\n        await setupI18n({\n            modules: [\n                ModuleName.NOTETYPES,\n            ],\n        });\n        container.innerText = tr.notetypeErrorNoImageToShow();\n        return;\n    }\n\n    // Enforce aspect ratio of image\n    if (CSS.supports(\"aspect-ratio: 1\")) {\n        container.style.aspectRatio = `${image.naturalWidth / image.naturalHeight}`;\n    } else {\n        const containerSize = calculateContainerSize(container, image);\n        container.style.width = `${containerSize.width}px`;\n        container.style.height = `${containerSize.height}px`;\n    }\n\n    const size = optimumPixelSizeForCanvas(\n        { width: image.naturalWidth, height: image.naturalHeight },\n        { width: canvas.clientWidth, height: canvas.clientHeight },\n    );\n    canvas.width = size.width;\n    canvas.height = size.height;\n\n    if (!oneTimeSetupDone) {\n        window.addEventListener(\"keydown\", (event) => {\n            const button = document.getElementById(\"toggle\");\n            if (button && button.style.display !== \"none\" && event.key === \"M\") {\n                toggleMasks(setupOptions);\n            }\n        });\n        oneTimeSetupDone = true;\n    }\n\n    // setup button for toggle image occlusion\n    const button = document.getElementById(\"toggle\");\n    if (button) {\n        if (document.querySelector(\"[data-occludeinactive=\\\"1\\\"]\")) {\n            button.addEventListener(\"click\", () => toggleMasks(setupOptions));\n        } else {\n            button.style.display = \"none\";\n        }\n    }\n\n    drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes);\n}\n\nfunction drawShapes(\n    canvas: HTMLCanvasElement,\n    onWillDrawShapes?: DrawShapesFilter,\n    onDidDrawShapes?: DrawShapesCallback,\n    allowedShapes?: Array<typeof Shape>,\n): void {\n    const context: CanvasRenderingContext2D = canvas.getContext(\"2d\")!;\n    const size = canvas;\n    const filterByAllowedShapes = (el: Shape) =>\n        (allowedShapes && allowedShapes.length > 0) ? allowedShapes.some(s => el instanceof s) : true;\n\n    let activeShapes = extractShapesFromRenderedClozes(\".cloze\").filter(filterByAllowedShapes);\n    let inactiveShapes = extractShapesFromRenderedClozes(\".cloze-inactive\").filter(filterByAllowedShapes);\n    let highlightShapes = extractShapesFromRenderedClozes(\".cloze-highlight\").filter(filterByAllowedShapes);\n    let properties = getShapeProperties();\n\n    const processed = onWillDrawShapes?.({ activeShapes, inactiveShapes, highlightShapes, properties }, context);\n    if (processed) {\n        activeShapes = processed.activeShapes;\n        inactiveShapes = processed.inactiveShapes;\n        highlightShapes = processed.highlightShapes;\n        properties = processed.properties;\n    }\n\n    for (const shape of activeShapes) {\n        drawShape({\n            context,\n            size,\n            shape,\n            fill: properties.activeShapeColor,\n            stroke: properties.activeBorder.color,\n            strokeWidth: properties.activeBorder.width,\n        });\n    }\n    for (const shape of inactiveShapes.filter((s) => s.occludeInactive)) {\n        drawShape({\n            context,\n            size,\n            shape,\n            fill: shape.fill !== SHAPE_MASK_COLOR ? shape.fill : properties.inActiveShapeColor,\n            stroke: properties.inActiveBorder.color,\n            strokeWidth: properties.inActiveBorder.width,\n        });\n    }\n    for (const shape of highlightShapes) {\n        drawShape({\n            context,\n            size,\n            shape,\n            fill: properties.highlightShapeColor,\n            stroke: properties.highlightShapeBorder.color,\n            strokeWidth: properties.highlightShapeBorder.width,\n        });\n    }\n\n    onDidDrawShapes?.({\n        activeShapes,\n        inactiveShapes,\n        highlightShapes,\n        properties,\n    }, context);\n}\n\ninterface DrawShapeParameters {\n    context: CanvasRenderingContext2D;\n    size: Size;\n    shape: Shape;\n    fill: string;\n    stroke: string;\n    strokeWidth: number;\n}\n\nfunction drawShape({\n    context: ctx,\n    size,\n    shape,\n    fill,\n    stroke,\n    strokeWidth,\n}: DrawShapeParameters): void {\n    shape = shape.toAbsolute(size);\n\n    ctx.fillStyle = fill;\n    ctx.strokeStyle = stroke;\n    ctx.lineWidth = strokeWidth;\n    const angle = ((shape.angle ?? 0) * Math.PI) / 180;\n    if (shape instanceof Rectangle) {\n        if (angle) {\n            ctx.save();\n            ctx.translate(shape.left, shape.top);\n            ctx.rotate(angle);\n            ctx.translate(-shape.left, -shape.top);\n        }\n        ctx.fillRect(shape.left, shape.top, shape.width, shape.height);\n        // ctx stroke methods will draw a visible stroke, even if the width is 0\n        if (strokeWidth) {\n            ctx.strokeRect(shape.left, shape.top, shape.width, shape.height);\n        }\n        if (angle) { ctx.restore(); }\n    } else if (shape instanceof Ellipse) {\n        const adjustedLeft = shape.left + shape.rx;\n        const adjustedTop = shape.top + shape.ry;\n        if (angle) {\n            ctx.save();\n            ctx.translate(shape.left, shape.top);\n            ctx.rotate(angle);\n            ctx.translate(-shape.left, -shape.top);\n        }\n        ctx.beginPath();\n        ctx.ellipse(\n            adjustedLeft,\n            adjustedTop,\n            shape.rx,\n            shape.ry,\n            0,\n            0,\n            Math.PI * 2,\n            false,\n        );\n        ctx.closePath();\n        ctx.fill();\n        if (strokeWidth) {\n            ctx.stroke();\n        }\n        if (angle) { ctx.restore(); }\n    } else if (shape instanceof Polygon) {\n        const offset = getPolygonOffset(shape);\n        ctx.save();\n        ctx.translate(offset.x, offset.y);\n        ctx.beginPath();\n        ctx.moveTo(shape.points[0].x, shape.points[0].y);\n        for (let i = 0; i < shape.points.length; i++) {\n            ctx.lineTo(shape.points[i].x, shape.points[i].y);\n        }\n        ctx.closePath();\n        ctx.fill();\n        if (strokeWidth) {\n            ctx.stroke();\n        }\n        ctx.restore();\n    } else if (shape instanceof Text) {\n        ctx.save();\n        ctx.font = `${shape.fontSize}px ${TEXT_FONT_FAMILY}`;\n        ctx.textBaseline = \"top\";\n        ctx.scale(shape.scaleX, shape.scaleY);\n        const lines = shape.text.split(\"\\n\");\n        const baseMetrics = ctx.measureText(\"M\");\n        const fontHeight = baseMetrics.actualBoundingBoxAscent + baseMetrics.actualBoundingBoxDescent;\n        const lineHeight = 1.5 * fontHeight;\n        const linePositions: { text: string; x: number; y: number; width: number; height: number }[] = [];\n        let maxWidth = 0;\n        let totalHeight = 0;\n        for (let i = 0; i < lines.length; i++) {\n            const textMetrics = ctx.measureText(lines[i]);\n            linePositions.push({\n                text: lines[i],\n                x: shape.left / shape.scaleX,\n                y: shape.top / shape.scaleY + i * lineHeight,\n                width: textMetrics.width,\n                height: lineHeight,\n            });\n            if (textMetrics.width > maxWidth) {\n                maxWidth = textMetrics.width;\n            }\n            totalHeight += lineHeight;\n        }\n        const left = shape.left / shape.scaleX;\n        const top = shape.top / shape.scaleY;\n        if (angle) {\n            ctx.translate(left, top);\n            ctx.rotate(angle);\n            ctx.translate(-left, -top);\n        }\n        ctx.fillStyle = TEXT_BACKGROUND_COLOR;\n        ctx.fillRect(\n            left,\n            top,\n            maxWidth + TEXT_PADDING,\n            totalHeight + TEXT_PADDING,\n        );\n        ctx.fillStyle = shape.fill ?? \"#000\";\n        for (const line of linePositions) {\n            ctx.fillText(line.text, line.x, line.y);\n        }\n        ctx.restore();\n    }\n}\n\nfunction getPolygonOffset(polygon: Polygon): { x: number; y: number } {\n    const topLeft = topLeftOfPoints(polygon.points);\n    return { x: polygon.left - topLeft.x, y: polygon.top - topLeft.y };\n}\n\nfunction topLeftOfPoints(points: { x: number; y: number }[]): {\n    x: number;\n    y: number;\n} {\n    let top = points[0].y;\n    let left = points[0].x;\n    for (const point of points) {\n        if (point.y < top) {\n            top = point.y;\n        }\n        if (point.x < left) {\n            left = point.x;\n        }\n    }\n    return { x: left, y: top };\n}\n\nexport type ShapeProperties = {\n    activeShapeColor: string;\n    inActiveShapeColor: string;\n    highlightShapeColor: string;\n    activeBorder: { width: number; color: string };\n    inActiveBorder: { width: number; color: string };\n    highlightShapeBorder: { width: number; color: string };\n};\nfunction getShapeProperties(): ShapeProperties {\n    const canvas = document.getElementById(\"image-occlusion-canvas\");\n    const computedStyle = window.getComputedStyle(canvas!);\n    // it may throw error if the css variable is not defined\n    try {\n        // shape color\n        const activeShapeColor = computedStyle.getPropertyValue(\n            \"--active-shape-color\",\n        );\n        const inActiveShapeColor = computedStyle.getPropertyValue(\n            \"--inactive-shape-color\",\n        );\n        const highlightShapeColor = computedStyle.getPropertyValue(\n            \"--highlight-shape-color\",\n        );\n        // inactive shape border\n        const inActiveShapeBorder = computedStyle.getPropertyValue(\n            \"--inactive-shape-border\",\n        );\n        const inActiveBorder = inActiveShapeBorder.split(\" \").filter((x) => x);\n        const inActiveShapeBorderWidth = parseFloat(inActiveBorder[0]);\n        const inActiveShapeBorderColor = inActiveBorder[1];\n        // active shape border\n        const activeShapeBorder = computedStyle.getPropertyValue(\n            \"--active-shape-border\",\n        );\n        const activeBorder = activeShapeBorder.split(\" \").filter((x) => x);\n        const activeShapeBorderWidth = parseFloat(activeBorder[0]);\n        const activeShapeBorderColor = activeBorder[1];\n        // highlight shape border\n        const highlightShapeBorder = computedStyle.getPropertyValue(\n            \"--highlight-shape-border\",\n        );\n        const highlightBorder = highlightShapeBorder.split(\" \").filter((x) => x);\n        const highlightShapeBorderWidth = parseFloat(highlightBorder[0]);\n        const highlightShapeBorderColor = highlightBorder[1];\n\n        return {\n            activeShapeColor: activeShapeColor ? activeShapeColor : \"#ff8e8e\",\n            inActiveShapeColor: inActiveShapeColor\n                ? inActiveShapeColor\n                : SHAPE_MASK_COLOR,\n            highlightShapeColor: highlightShapeColor\n                ? highlightShapeColor\n                : \"#ff8e8e00\",\n            activeBorder: {\n                width: !isNaN(activeShapeBorderWidth) ? activeShapeBorderWidth : 1,\n                color: activeShapeBorderColor\n                    ? activeShapeBorderColor\n                    : \"#212121\",\n            },\n            inActiveBorder: {\n                width: !isNaN(inActiveShapeBorderWidth) ? inActiveShapeBorderWidth : 1,\n                color: inActiveShapeBorderColor\n                    ? inActiveShapeBorderColor\n                    : \"#212121\",\n            },\n            highlightShapeBorder: {\n                width: !isNaN(highlightShapeBorderWidth) ? highlightShapeBorderWidth : 1,\n                color: highlightShapeBorderColor\n                    ? highlightShapeBorderColor\n                    : \"#ff8e8e\",\n            },\n        };\n    } catch {\n        // return default values\n        return {\n            activeShapeColor: \"#ff8e8e\",\n            inActiveShapeColor: \"#ffeba2\",\n            highlightShapeColor: \"#ff8e8e00\",\n            activeBorder: {\n                width: 1,\n                color: \"#212121\",\n            },\n            inActiveBorder: {\n                width: 1,\n                color: \"#212121\",\n            },\n            highlightShapeBorder: {\n                width: 1,\n                color: \"#ff8e8e\",\n            },\n        };\n    }\n}\n\nlet hide = false;\nconst toggleMasks = (setupOptions?: SetupImageOcclusionOptions): void => {\n    const canvas = document.getElementById(\"image-occlusion-canvas\") as HTMLCanvasElement;\n    const context = canvas.getContext(\"2d\")!;\n\n    hide = !hide;\n    context.clearRect(0, 0, canvas.width, canvas.height);\n    if (hide) {\n        drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes, [Text]);\n        return;\n    }\n    drawShapes(canvas, setupOptions?.onWillDrawShapes, setupOptions?.onDidDrawShapes);\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/base.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { SHAPE_MASK_COLOR } from \"../tools/lib\";\nimport type { ConstructorParams, Size } from \"../types\";\nimport { angleToStored, floatToDisplay } from \"./lib\";\nimport { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from \"./position\";\n\nexport type ShapeOrShapes = Shape | Shape[];\n\n/** Defines a basic shape that can have its coordinates stored in either\n    absolute pixels (relative to a containing canvas), or in normalized 0-1\n    form. Can be converted to a fabric object, or to a format suitable for\n    storage in a cloze note.\n*/\nexport class Shape {\n    left: number;\n    top: number;\n    angle?: number; // polygons don't use it\n    fill: string;\n    /** Whether occlusions from other cloze numbers should be shown on the\n     * question side. Used only in reviewer code.\n     */\n    occludeInactive?: boolean;\n    /* Cloze ordinal */\n    ordinal: number | undefined;\n    id: string | undefined;\n\n    constructor(\n        { left = 0, top = 0, angle = 0, fill = SHAPE_MASK_COLOR, occludeInactive, ordinal = undefined }:\n            ConstructorParams<Shape> = {},\n    ) {\n        this.left = left;\n        this.top = top;\n        this.angle = angle;\n        this.fill = fill;\n        this.occludeInactive = occludeInactive;\n        this.ordinal = ordinal;\n    }\n\n    /** Format numbers and remove default values, for easier serialization to\n     * text.\n     */\n    toDataForCloze(): ShapeDataForCloze {\n        const angle = angleToStored(this.angle);\n        return {\n            left: floatToDisplay(this.left),\n            top: floatToDisplay(this.top),\n            ...(!angle ? {} : { angle: angle.toString() }),\n        };\n    }\n\n    toFabric(size: Size): fabric.Object {\n        const absolute = this.toAbsolute(size);\n        return new fabric.Object(absolute);\n    }\n\n    normalPosition(size: Size) {\n        return {\n            left: xToNormalized(size, this.left),\n            top: yToNormalized(size, this.top),\n        };\n    }\n\n    toNormal(size: Size): Shape {\n        return new Shape({\n            ...this,\n            ...this.normalPosition(size),\n        });\n    }\n\n    absolutePosition(size: Size) {\n        return {\n            left: xFromNormalized(size, this.left),\n            top: yFromNormalized(size, this.top),\n        };\n    }\n\n    toAbsolute(size: Size): Shape {\n        return new Shape({\n            ...this,\n            ...this.absolutePosition(size),\n        });\n    }\n}\n\nexport interface ShapeDataForCloze {\n    left: string;\n    top: string;\n    angle?: string;\n    fill?: string;\n    oi?: string;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/ellipse.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { SHAPE_MASK_COLOR } from \"../tools/lib\";\nimport type { ConstructorParams, Size } from \"../types\";\nimport type { ShapeDataForCloze } from \"./base\";\nimport { Shape } from \"./base\";\nimport { floatToDisplay } from \"./lib\";\nimport { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from \"./position\";\n\nexport class Ellipse extends Shape {\n    rx: number;\n    ry: number;\n\n    constructor({ rx = 0, ry = 0, ...rest }: ConstructorParams<Ellipse> = {}) {\n        super(rest);\n        this.rx = rx;\n        this.ry = ry;\n        this.id = \"ellipse-\" + new Date().getTime();\n    }\n\n    toDataForCloze(): EllipseDataForCloze {\n        return {\n            ...super.toDataForCloze(),\n            rx: floatToDisplay(this.rx),\n            ry: floatToDisplay(this.ry),\n            ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }),\n        };\n    }\n\n    toFabric(size: Size): fabric.Ellipse {\n        const absolute = this.toAbsolute(size);\n        return new fabric.Ellipse(absolute);\n    }\n\n    toNormal(size: Size): Ellipse {\n        return new Ellipse({\n            ...this,\n            ...super.normalPosition(size),\n            rx: xToNormalized(size, this.rx),\n            ry: yToNormalized(size, this.ry),\n        });\n    }\n\n    toAbsolute(size: Size): Ellipse {\n        return new Ellipse({\n            ...this,\n            ...super.absolutePosition(size),\n            rx: xFromNormalized(size, this.rx),\n            ry: yFromNormalized(size, this.ry),\n        });\n    }\n}\n\ninterface EllipseDataForCloze extends ShapeDataForCloze {\n    rx: string;\n    ry: string;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/from-cloze.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/* eslint\n@typescript-eslint/no-explicit-any: \"off\",\n*/\n\nimport type { GetImageOcclusionNoteResponse_ImageOcclusion } from \"@generated/anki/image_occlusion_pb\";\n\nimport type { Shape, ShapeOrShapes } from \"./base\";\nimport { Ellipse } from \"./ellipse\";\nimport { storedToAngle } from \"./lib\";\nimport { Point, Polygon } from \"./polygon\";\nimport { Rectangle } from \"./rectangle\";\nimport { Text } from \"./text\";\n\nexport function extractShapesFromClozedField(\n    occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],\n): ShapeOrShapes[] {\n    const output: ShapeOrShapes[] = [];\n    for (const occlusion of occlusions) {\n        const group: Shape[] = [];\n        for (const shape of occlusion.shapes) {\n            if (isValidType(shape.shape)) {\n                const props: Record<string, any> = Object.fromEntries(\n                    shape.properties.map(prop => [prop.name, prop.value]),\n                );\n                props.ordinal = occlusion.ordinal;\n                group.push(buildShape(shape.shape, props));\n            }\n        }\n        if (occlusion.ordinal === 0) {\n            output.push(...group);\n        } else if (group.length > 1) {\n            output.push(group);\n        } else {\n            output.push(group[0]);\n        }\n    }\n\n    return output;\n}\n\n/** Locate all cloze divs in the review screen for the given selector, and convert them into BaseShapes.\n */\nexport function extractShapesFromRenderedClozes(selector: string): Shape[] {\n    return Array.from(document.querySelectorAll(selector)).flatMap((cloze) => {\n        if (cloze instanceof HTMLDivElement) {\n            return extractShapeFromRenderedCloze(cloze) ?? [];\n        } else {\n            return [];\n        }\n    });\n}\n\nfunction extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {\n    const type = cloze.dataset.shape!;\n    if (\n        type !== \"rect\"\n        && type !== \"ellipse\"\n        && type !== \"polygon\"\n        && type !== \"text\"\n    ) {\n        return null;\n    }\n    const props = {\n        occludeInactive: cloze.dataset.occludeinactive === \"1\",\n        ordinal: parseInt(cloze.dataset.ordinal!),\n        left: cloze.dataset.left,\n        top: cloze.dataset.top,\n        width: cloze.dataset.width,\n        height: cloze.dataset.height,\n        rx: cloze.dataset.rx,\n        ry: cloze.dataset.ry,\n        points: cloze.dataset.points,\n        text: cloze.dataset.text,\n        scale: cloze.dataset.scale,\n        fs: cloze.dataset.fontSize,\n        angle: cloze.dataset.angle,\n        ...(cloze.dataset.fill == null ? {} : { fill: cloze.dataset.fill }),\n    };\n    return buildShape(type, props);\n}\n\ntype ShapeType = \"rect\" | \"ellipse\" | \"polygon\" | \"text\";\n\nfunction isValidType(type: string): type is ShapeType {\n    return [\"rect\", \"ellipse\", \"polygon\", \"text\"].includes(type);\n}\n\nfunction buildShape(type: ShapeType, props: Record<string, any>): Shape {\n    props.left = parseFloat(\n        Number.isNaN(Number(props.left)) ? \".0000\" : props.left,\n    );\n    props.top = parseFloat(\n        Number.isNaN(Number(props.top)) ? \".0000\" : props.top,\n    );\n    props.angle = storedToAngle(props.angle) ?? 0;\n    switch (type) {\n        case \"rect\": {\n            return new Rectangle({\n                ...props,\n                width: parseFloat(props.width),\n                height: parseFloat(props.height),\n            });\n        }\n        case \"ellipse\": {\n            return new Ellipse({\n                ...props,\n                rx: parseFloat(props.rx),\n                ry: parseFloat(props.ry),\n            });\n        }\n        case \"polygon\": {\n            if (props.points !== \"\") {\n                props.points = props.points.split(\" \").map((point) => {\n                    const [x, y] = point.split(\",\");\n                    return new Point({ x, y });\n                });\n            } else {\n                props.points = [new Point({ x: 0, y: 0 })];\n            }\n            return new Polygon(props);\n        }\n        case \"text\": {\n            const textProps: Record<string, any> = {\n                ...props,\n                scaleX: parseFloat(props.scale),\n                scaleY: parseFloat(props.scale),\n            };\n            if (props.fs) {\n                textProps.fontSize = parseFloat(props.fs);\n            }\n            return new Text(textProps);\n        }\n    }\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport type { ShapeOrShapes } from \"./base\";\nexport { Shape } from \"./base\";\nexport { Ellipse } from \"./ellipse\";\nexport { extractShapesFromRenderedClozes } from \"./from-cloze\";\nexport { Polygon } from \"./polygon\";\nexport { Rectangle } from \"./rectangle\";\nexport { Text } from \"./text\";\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n/** Convert a float to a string with up to 4 fraction digits,\n * which when rounded, reproduces identical pixels to input\n * for up to widths/heights of 10kpx.\n */\nexport function floatToDisplay(number: number): string {\n    if (Number.isNaN(number) || number == 0) {\n        return \".0000\";\n    }\n    return number.toFixed(4).replace(/^0+|0+$/g, \"\");\n}\n\nconst ANGLE_STEPS = 10000;\n\nexport function angleToStored(angle: any): number | null {\n    const angleDeg = Number(angle) % 360;\n    return Number.isNaN(angleDeg) ? null : Math.round((angleDeg / 360) * ANGLE_STEPS);\n}\n\nexport function storedToAngle(x: any): number | null {\n    const angleSteps = Number(x) % ANGLE_STEPS;\n    return Number.isNaN(angleSteps) ? null : (angleSteps / ANGLE_STEPS) * 360;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/polygon.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { SHAPE_MASK_COLOR } from \"../tools/lib\";\nimport type { ConstructorParams, Size } from \"../types\";\nimport type { ShapeDataForCloze } from \"./base\";\nimport { Shape } from \"./base\";\nimport { floatToDisplay } from \"./lib\";\nimport { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from \"./position\";\n\nexport class Polygon extends Shape {\n    points: Point[];\n\n    constructor({ points = [], ...rest }: ConstructorParams<Polygon> = {}) {\n        super(rest);\n        this.points = points;\n        this.id = \"polygon-\" + new Date().getTime();\n    }\n\n    toDataForCloze(): PolygonDataForCloze {\n        return {\n            ...super.toDataForCloze(),\n            points: this.points.map(({ x, y }) => `${floatToDisplay(x)},${floatToDisplay(y)}`).join(\" \"),\n            ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }),\n        };\n    }\n\n    toFabric(size: Size): fabric.Polygon {\n        const absolute = this.toAbsolute(size);\n        // @ts-expect-error absolute is our own object not a fabric.Polygon\n        return new fabric.Polygon(absolute.points, absolute);\n    }\n\n    toNormal(size: Size): Polygon {\n        const points: Point[] = [];\n        this.points.forEach((p) => {\n            points.push({\n                x: xToNormalized(size, p.x),\n                y: yToNormalized(size, p.y),\n            });\n        });\n        return new Polygon({\n            ...this,\n            ...super.normalPosition(size),\n            points,\n        });\n    }\n\n    toAbsolute(size: Size): Polygon {\n        const points: Point[] = [];\n        this.points.forEach((p) => {\n            points.push({\n                x: xFromNormalized(size, p.x),\n                y: yFromNormalized(size, p.y),\n            });\n        });\n        return new Polygon({\n            ...this,\n            ...super.absolutePosition(size),\n            points,\n        });\n    }\n}\n\ninterface PolygonDataForCloze extends ShapeDataForCloze {\n    // \"x1,y1 x2,y2 ...\"\"\n    points: string;\n}\n\nexport class Point {\n    x = 0;\n    y = 0;\n\n    constructor({ x = 0, y = 0 }: ConstructorParams<Point> = {}) {\n        this.x = x;\n        this.y = y;\n    }\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/position.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { Size } from \"../types\";\n\n/** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.25 */\nexport function xToNormalized(size: Size, x: number): number {\n    return x / size.width;\n}\n\n/** Position normalized to 0-1 range, e.g. 150px in a 600x300px canvas is 0.5 */\nexport function yToNormalized(size: Size, y: number): number {\n    return y / size.height;\n}\n\n/** Position in pixels from normalized range, e.g 0.25 in a 600x300px canvas is 150. */\nexport function xFromNormalized(size: Size, x: number): number {\n    return Math.round(x * size.width);\n}\n\n/** Position in pixels from normalized range, e.g 0.5 in a 600x300px canvas is 150. */\nexport function yFromNormalized(size: Size, y: number): number {\n    return Math.round(y * size.height);\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/rectangle.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { SHAPE_MASK_COLOR } from \"../tools/lib\";\nimport type { ConstructorParams, Size } from \"../types\";\nimport type { ShapeDataForCloze } from \"./base\";\nimport { Shape } from \"./base\";\nimport { floatToDisplay } from \"./lib\";\nimport { xFromNormalized, xToNormalized, yFromNormalized, yToNormalized } from \"./position\";\n\nexport class Rectangle extends Shape {\n    width: number;\n    height: number;\n\n    constructor({ width = 0, height = 0, ...rest }: ConstructorParams<Rectangle> = {}) {\n        super(rest);\n        this.width = width;\n        this.height = height;\n        this.id = \"rect-\" + new Date().getTime();\n    }\n\n    toDataForCloze(): RectangleDataForCloze {\n        return {\n            ...super.toDataForCloze(),\n            width: floatToDisplay(this.width),\n            height: floatToDisplay(this.height),\n            ...(this.fill === SHAPE_MASK_COLOR ? {} : { fill: this.fill }),\n        };\n    }\n\n    toFabric(size: Size): fabric.Rect {\n        const absolute = this.toAbsolute(size);\n        return new fabric.Rect(absolute);\n    }\n\n    toNormal(size: Size): Rectangle {\n        return new Rectangle({\n            ...this,\n            ...super.normalPosition(size),\n            width: xToNormalized(size, this.width),\n            height: yToNormalized(size, this.height),\n        });\n    }\n\n    toAbsolute(size: Size): Rectangle {\n        return new Rectangle({\n            ...this,\n            ...super.absolutePosition(size),\n            width: xFromNormalized(size, this.width),\n            height: yFromNormalized(size, this.height),\n        });\n    }\n}\n\ninterface RectangleDataForCloze extends ShapeDataForCloze {\n    width: string;\n    height: string;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/text.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { TEXT_BACKGROUND_COLOR, TEXT_COLOR, TEXT_FONT_FAMILY, TEXT_FONT_SIZE, TEXT_PADDING } from \"../tools/lib\";\nimport type { ConstructorParams, Size } from \"../types\";\nimport type { ShapeDataForCloze } from \"./base\";\nimport { Shape } from \"./base\";\nimport { floatToDisplay } from \"./lib\";\n\nexport class Text extends Shape {\n    text: string;\n    scaleX: number;\n    scaleY: number;\n    fontSize: number | undefined;\n\n    constructor({\n        text = \"\",\n        scaleX = 1,\n        scaleY = 1,\n        fill = TEXT_COLOR,\n        fontSize,\n        ...rest\n    }: ConstructorParams<Text> = {}) {\n        super(rest);\n        this.fill = fill;\n        this.text = text;\n        this.scaleX = scaleX;\n        this.scaleY = scaleY;\n        this.fontSize = fontSize;\n        this.id = \"text-\" + new Date().getTime();\n    }\n\n    toDataForCloze(): TextDataForCloze {\n        return {\n            ...super.toDataForCloze(),\n            text: this.text,\n            // scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio\n            scale: floatToDisplay(this.scaleX),\n            fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined,\n            ...(this.fill === TEXT_COLOR ? {} : { fill: this.fill }),\n        };\n    }\n\n    toFabric(size: Size): fabric.IText {\n        const absolute = this.toAbsolute(size);\n        return new fabric.IText(absolute.text, {\n            ...absolute,\n            fontFamily: TEXT_FONT_FAMILY,\n            backgroundColor: TEXT_BACKGROUND_COLOR,\n            padding: TEXT_PADDING,\n            lineHeight: 1,\n            lockScalingFlip: true,\n        });\n    }\n\n    toNormal(size: Size): Text {\n        const fontSize = this.fontSize ? this.fontSize : TEXT_FONT_SIZE;\n        return new Text({\n            ...this,\n            fontSize: fontSize / size.height,\n            ...super.normalPosition(size),\n        });\n    }\n\n    toAbsolute(size: Size): Text {\n        return new Text({\n            ...this,\n            fontSize: this.fontSize ? this.fontSize * size.height : TEXT_FONT_SIZE,\n            ...super.absolutePosition(size),\n        });\n    }\n}\n\ninterface TextDataForCloze extends ShapeDataForCloze {\n    text: string;\n    scale: string;\n    fs: string | undefined;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/shapes/to-cloze.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { cloneDeep } from \"lodash-es\";\n\nimport { getBoundingBoxSize } from \"../tools/lib\";\nimport type { Size } from \"../types\";\nimport type { Shape, ShapeOrShapes } from \"./base\";\nimport { Ellipse } from \"./ellipse\";\nimport { Polygon } from \"./polygon\";\nimport { Rectangle } from \"./rectangle\";\nimport { Text } from \"./text\";\n\nexport function exportShapesToClozeDeletions(occludeInactive: boolean): {\n    clozes: string;\n    noteCount: number;\n} {\n    const shapes = baseShapesFromFabric();\n\n    let clozes = \"\";\n    let noteCount = 0;\n\n    // take out all ordinal values from shapes\n    const ordinalList = shapes.map((shape) => {\n        if (Array.isArray(shape)) {\n            return shape[0].ordinal;\n        } else {\n            return shape.ordinal;\n        }\n    });\n\n    const filterOrdinalList: number[] = ordinalList.flatMap(v => typeof v === \"number\" ? [v] : []);\n    const maxOrdinal = Math.max(...filterOrdinalList, 0);\n\n    const missingOrdinals: number[] = [];\n    for (let i = 1; i <= maxOrdinal; i++) {\n        if (!ordinalList.includes(i)) {\n            missingOrdinals.push(i);\n        }\n    }\n\n    let nextOrdinal = maxOrdinal + 1;\n\n    shapes.map((shapeOrShapes) => {\n        if (shapeOrShapes === null) {\n            return;\n        }\n\n        // Maintain existing ordinal in editing mode\n        let ordinal: number | undefined;\n        if (Array.isArray(shapeOrShapes)) {\n            ordinal = shapeOrShapes[0].ordinal;\n        } else {\n            ordinal = shapeOrShapes.ordinal;\n        }\n\n        if (ordinal === undefined) {\n            // if ordinal is undefined, assign a missing ordinal if available\n            if (shapeOrShapes instanceof Text) {\n                ordinal = 0;\n            } else if (missingOrdinals.length > 0) {\n                ordinal = missingOrdinals.shift()!;\n            } else {\n                ordinal = nextOrdinal;\n                nextOrdinal++;\n            }\n\n            if (Array.isArray(shapeOrShapes)) {\n                shapeOrShapes.forEach((shape) => (shape.ordinal = ordinal));\n            } else {\n                shapeOrShapes.ordinal = ordinal;\n            }\n        }\n\n        clozes += shapeOrShapesToCloze(\n            shapeOrShapes,\n            ordinal,\n            occludeInactive,\n        );\n\n        if (!(shapeOrShapes instanceof Text)) {\n            noteCount++;\n        }\n    });\n    return { clozes, noteCount };\n}\n\n/** Gather all Fabric shapes, and convert them into BaseShapes or\n * BaseShape[]s.\n */\nexport function baseShapesFromFabric(): ShapeOrShapes[] {\n    const canvas = globalThis.canvas as fabric.Canvas;\n    const activeObject = canvas.getActiveObject();\n    const selectionContainingMultipleObjects = activeObject instanceof fabric.ActiveSelection\n            && (activeObject.size() > 1)\n        ? activeObject\n        : null;\n    const objects = canvas.getObjects();\n    const boundingBox = getBoundingBoxSize();\n    // filter transparent rectangles\n    return objects\n        .map((object) => {\n            // If the object is in the active selection containing multiple objects,\n            // we need to calculate its x and y coordinates relative to the canvas.\n            const parent = selectionContainingMultipleObjects?.contains(object)\n                ? selectionContainingMultipleObjects\n                : undefined;\n            // shapes with width or height less than 5 are not valid\n            // if shape is Rect and fill is transparent, skip it\n            if (object.width! < 5 || object.height! < 5 || object.fill == \"transparent\") {\n                return null;\n            }\n            return fabricObjectToBaseShapeOrShapes(\n                boundingBox,\n                object,\n                parent,\n            );\n        })\n        .filter((o): o is ShapeOrShapes => o !== null);\n}\n\n/** Convert a single Fabric object/group to one or more BaseShapes. */\nfunction fabricObjectToBaseShapeOrShapes(\n    size: Size,\n    object: fabric.Object,\n    parentObject?: fabric.Object,\n): ShapeOrShapes | null {\n    let shape: Shape;\n\n    // Prevents the original fabric object from mutating when a non-primitive\n    // property of a Shape mutates.\n    const cloned = cloneDeep(object);\n    if (parentObject) {\n        const scaling = parentObject.getObjectScaling();\n        cloned.width = cloned.width! * scaling.scaleX;\n        cloned.height = cloned.height! * scaling.scaleY;\n    }\n\n    switch (object.type) {\n        case \"rect\":\n            shape = new Rectangle(cloned as any);\n            break;\n        case \"ellipse\":\n            shape = new Ellipse(cloned as any);\n            break;\n        case \"polygon\":\n            shape = new Polygon(cloned as any);\n            break;\n        case \"i-text\":\n            shape = new Text(cloned as any);\n            break;\n        case \"group\":\n            return (object as fabric.Group).getObjects().flatMap((child) => {\n                return fabricObjectToBaseShapeOrShapes(\n                    size,\n                    child,\n                    object,\n                )!;\n            });\n        default:\n            return null;\n    }\n    if (parentObject) {\n        const newPosition = fabric.util.transformPoint(\n            new fabric.Point(shape.left, shape.top),\n            parentObject.calcTransformMatrix(),\n        );\n        shape.left = newPosition.x;\n        shape.top = newPosition.y;\n    }\n\n    shape = shape.toNormal(size);\n    return shape;\n}\n\n/** generate cloze data in form of\n {{c1::image-occlusion:rect:top=.1:left=.23:width=.4:height=.5}} */\nfunction shapeOrShapesToCloze(\n    shapeOrShapes: ShapeOrShapes,\n    ordinal: number,\n    occludeInactive: boolean,\n): string {\n    let text = \"\";\n    function addKeyValue(key: string, value: string) {\n        value = value.replace(\":\", \"\\\\:\");\n        text += `:${key}=${value}`;\n    }\n\n    let type: string;\n    if (Array.isArray(shapeOrShapes)) {\n        return shapeOrShapes\n            .map((shape) => shapeOrShapesToCloze(shape, ordinal, occludeInactive))\n            .join(\"\");\n    } else if (shapeOrShapes instanceof Rectangle) {\n        type = \"rect\";\n    } else if (shapeOrShapes instanceof Ellipse) {\n        type = \"ellipse\";\n    } else if (shapeOrShapes instanceof Polygon) {\n        type = \"polygon\";\n    } else if (shapeOrShapes instanceof Text) {\n        type = \"text\";\n    } else {\n        throw new Error(\"Unknown shape type\");\n    }\n\n    for (const [key, value] of Object.entries(shapeOrShapes.toDataForCloze())) {\n        addKeyValue(key, value);\n    }\n    if (occludeInactive) {\n        addKeyValue(\"oi\", \"1\");\n    }\n\n    text = `{{c${ordinal}::image-occlusion:${type}${text}}}<br>`;\n\n    return text;\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/store.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { writable } from \"svelte/store\";\n\n// it stores note's data for generate.ts, when function generate() is called it will be used to generate the note\nexport const notesDataStore = writable({ id: \"\", title: \"\", divValue: \"\", textareaValue: \"\" }[0]);\n// it stores the tags for the note in note editor\nexport const tagsWritable = writable([\"\"]);\n// it stores the visibility of mask editor\nexport const ioMaskEditorVisible = writable(true);\n// it store hide all or hide one mode\nexport const hideAllGuessOne = writable(true);\n// ioImageLoadedStore is used to store the image loaded event\nexport const ioImageLoadedStore = writable(false);\n// store opacity state of objects in canvas\nexport const opacityStateStore = writable(false);\n// store state of text editing\nexport const textEditingState = writable(false);\n// Stores if the canvas shapes data needs to be saved\nexport const saveNeededStore = writable(false);\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/add-from-cloze.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { GetImageOcclusionNoteResponse_ImageOcclusion } from \"@generated/anki/image_occlusion_pb\";\nimport type { fabric } from \"fabric\";\n\nimport { extractShapesFromClozedField } from \"../shapes/from-cloze\";\nimport { addShape, addShapeGroup } from \"./from-shapes\";\nimport { redraw } from \"./lib\";\n\nexport const addShapesToCanvasFromCloze = (\n    canvas: fabric.Canvas,\n    boundingBox: fabric.Rect,\n    occlusions: GetImageOcclusionNoteResponse_ImageOcclusion[],\n): void => {\n    for (const shapeOrShapes of extractShapesFromClozedField(occlusions)) {\n        if (Array.isArray(shapeOrShapes)) {\n            addShapeGroup(canvas, boundingBox, shapeOrShapes);\n        } else {\n            addShape(canvas, boundingBox, shapeOrShapes);\n        }\n    }\n    redraw(canvas);\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/api.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { fabric } from \"fabric\";\n\nimport type { ShapeOrShapes } from \"../shapes\";\nimport { Ellipse, Polygon, Rectangle, Shape, Text } from \"../shapes\";\nimport { baseShapesFromFabric, exportShapesToClozeDeletions } from \"../shapes/to-cloze\";\nimport { addShape, addShapeGroup } from \"./from-shapes\";\nimport { clear, redraw } from \"./lib\";\n\ninterface ClozeExportResult {\n    clozes: string;\n    cardCount: number;\n}\n\nexport class MaskEditorAPI {\n    readonly Shape = Shape;\n    readonly Rectangle = Rectangle;\n    readonly Ellipse = Ellipse;\n    readonly Polygon = Polygon;\n    readonly Text = Text;\n\n    readonly canvas: fabric.Canvas;\n\n    constructor(canvas) {\n        this.canvas = canvas;\n    }\n\n    addShape(bounding, shape: Shape): void {\n        addShape(this.canvas, bounding, shape);\n    }\n\n    addShapeGroup(bounding, shapes: Shape[]): void {\n        addShapeGroup(this.canvas, bounding, shapes);\n    }\n\n    getClozes(occludeInactive: boolean): ClozeExportResult {\n        const { clozes, noteCount: cardCount } = exportShapesToClozeDeletions(occludeInactive);\n        return { clozes, cardCount };\n    }\n\n    getShapes(): ShapeOrShapes[] {\n        return baseShapesFromFabric();\n    }\n\n    redraw(): void {\n        redraw(this.canvas);\n    }\n\n    clear(): void {\n        clear(this.canvas);\n    }\n}\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/from-shapes.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport type { Shape } from \"../shapes\";\nimport { addBorder, enableUniformScaling } from \"./lib\";\n\nexport const addShape = (\n    canvas: fabric.Canvas,\n    boundingBox: fabric.Rect,\n    shape: Shape,\n): void => {\n    const fabricShape = shape.toFabric(boundingBox.getBoundingRect(true));\n    if (fabricShape.type === \"i-text\") {\n        enableUniformScaling(canvas, fabricShape);\n    } else {\n        // No border around i-text shapes since it will be interpretted\n        // as character stroke, this is supposed to create an outline\n        // around the entire shape.\n        addBorder(fabricShape);\n    }\n    canvas.add(fabricShape);\n};\n\nexport const addShapeGroup = (\n    canvas: fabric.Canvas,\n    boundingBox: fabric.Rect,\n    shapes: Shape[],\n): void => {\n    const group = new fabric.Group();\n    shapes.map((shape) => {\n        const fabricShape = shape.toFabric(boundingBox.getBoundingRect(true));\n        addBorder(fabricShape);\n        group.addWithUpdate(fabricShape);\n    });\n    canvas.add(group);\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { drawEllipse } from \"./tool-ellipse\";\nimport { drawPolygon } from \"./tool-polygon\";\nimport { drawRectangle } from \"./tool-rect\";\nimport { drawText } from \"./tool-text\";\n\nexport { drawEllipse, drawPolygon, drawRectangle, drawText };\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport { opacityStateStore, saveNeededStore } from \"../store\";\nimport type { Size } from \"../types\";\n\nexport const SHAPE_MASK_COLOR = \"#ffeba2\";\nexport const BORDER_COLOR = \"#212121\";\nexport const TEXT_BACKGROUND_COLOR = \"#ffffff\";\nexport const TEXT_FONT_FAMILY = \"Arial\";\nexport const TEXT_PADDING = 5;\nexport const TEXT_FONT_SIZE = 40;\nexport const TEXT_COLOR = \"#000000\";\n\nlet _clipboard;\n\nexport const stopDraw = (canvas: fabric.Canvas): void => {\n    canvas.off(\"mouse:down\");\n    canvas.off(\"mouse:up\");\n    canvas.off(\"mouse:move\");\n};\n\nexport const enableSelectable = (\n    canvas: fabric.Canvas,\n    select: boolean,\n): void => {\n    canvas.selection = select;\n    canvas.forEachObject(function(o) {\n        if (o.fill === \"transparent\") {\n            return;\n        }\n        o.selectable = select;\n    });\n    canvas.renderAll();\n};\n\nexport const deleteItem = (canvas: fabric.Canvas): void => {\n    const active = canvas.getActiveObject();\n    if (active) {\n        canvas.remove(active);\n        if (active.type == \"activeSelection\") {\n            (active as fabric.ActiveSelection).getObjects().forEach((x) => canvas.remove(x));\n            canvas.discardActiveObject().renderAll();\n        }\n    }\n    redraw(canvas);\n};\n\nexport const duplicateItem = (canvas: fabric.Canvas): void => {\n    if (!canvas.getActiveObject()) {\n        return;\n    }\n    copyItem(canvas);\n    pasteItem(canvas);\n};\n\nexport const groupShapes = (canvas: fabric.Canvas): void => {\n    if (\n        canvas.getActiveObject()?.type !== \"activeSelection\"\n    ) {\n        return;\n    }\n\n    const activeObject = canvas.getActiveObject() as fabric.ActiveSelection;\n    const items = activeObject.getObjects();\n\n    let minOrdinal: number | undefined = Math.min(...items.map((item) => item.ordinal));\n    minOrdinal = Number.isNaN(minOrdinal) ? undefined : minOrdinal;\n\n    items.forEach((item) => {\n        item.set({ opacity: 1, ordinal: minOrdinal });\n    });\n\n    activeObject.toGroup().set({\n        opacity: get(opacityStateStore) ? 0.4 : 1,\n    }).setControlsVisibility({ mtr: false });\n\n    redraw(canvas);\n};\n\nexport const unGroupShapes = (canvas: fabric.Canvas): void => {\n    if (\n        canvas.getActiveObject()?.type !== \"group\"\n    ) {\n        return;\n    }\n\n    const group = canvas.getActiveObject() as fabric.Group;\n    const items = group.getObjects();\n    group._restoreObjectsState();\n    group.destroyed = true;\n\n    items.forEach((item) => {\n        item.set({\n            opacity: get(opacityStateStore) ? 0.4 : 1,\n            ordinal: undefined,\n        });\n        canvas.add(item);\n    });\n\n    canvas.remove(group);\n    redraw(canvas);\n};\n\nconst copyItem = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n\n    // clone what are you copying since you\n    // may want copy and paste on different moment.\n    // and you do not want the changes happened\n    // later to reflect on the copy.\n    activeObject.clone(function(cloned) {\n        _clipboard = cloned;\n    });\n};\n\nconst pasteItem = (canvas: fabric.Canvas): void => {\n    // clone again, so you can do multiple copies.\n    _clipboard.clone(function(clonedObj) {\n        canvas.discardActiveObject();\n\n        clonedObj.set({\n            left: clonedObj.left + 10,\n            top: clonedObj.top + 10,\n            evented: true,\n        });\n\n        if (clonedObj.type === \"activeSelection\") {\n            // active selection needs a reference to the canvas.\n            clonedObj.canvas = canvas;\n            clonedObj.forEachObject(function(obj) {\n                canvas.add(obj);\n            });\n\n            // this should solve the unselectability\n            clonedObj.setCoords();\n        } else {\n            canvas.add(clonedObj);\n        }\n\n        _clipboard.top += 10;\n        _clipboard.left += 10;\n        canvas.setActiveObject(clonedObj);\n        redraw(canvas);\n    });\n};\n\nexport const makeMaskTransparent = (\n    canvas: fabric.Canvas,\n    opacity = false,\n): void => {\n    opacityStateStore.set(opacity);\n    const objects = canvas.getObjects();\n    objects.forEach((object) => {\n        object.set({\n            opacity: opacity ? 0.4 : 1,\n            transparentCorners: false,\n        });\n    });\n    canvas.renderAll();\n};\n\nexport const moveShapeToCanvasBoundaries = (canvas: fabric.Canvas, boundingBox: fabric.Rect): void => {\n    canvas.on(\"object:modified\", function(o) {\n        const activeObject = o.target;\n        if (!activeObject) {\n            return;\n        }\n        if (activeObject.type === \"rect\") {\n            modifiedRectangle(boundingBox, activeObject);\n        }\n        if (activeObject.type === \"ellipse\") {\n            modifiedEllipse(boundingBox, activeObject as unknown as fabric.Ellipse);\n        }\n        if (activeObject.type === \"i-text\") {\n            modifiedText(boundingBox, activeObject);\n        }\n    });\n};\n\nconst modifiedRectangle = (\n    boundingBox: fabric.Rect,\n    object: fabric.Object,\n): void => {\n    const newWidth = object.width! * object.scaleX!;\n    const newHeight = object.height! * object.scaleY!;\n\n    object.set({\n        width: newWidth,\n        height: newHeight,\n        scaleX: 1,\n        scaleY: 1,\n    });\n    setShapePosition(boundingBox, object);\n};\n\nconst modifiedEllipse = (\n    boundingBox: fabric.Rect,\n    object: fabric.Ellipse,\n): void => {\n    const newRx = object.rx! * object.scaleX!;\n    const newRy = object.ry! * object.scaleY!;\n    const newWidth = object.width! * object.scaleX!;\n    const newHeight = object.height! * object.scaleY!;\n\n    object.set({\n        rx: newRx,\n        ry: newRy,\n        width: newWidth,\n        height: newHeight,\n        scaleX: 1,\n        scaleY: 1,\n    });\n    setShapePosition(boundingBox, object);\n};\n\nconst modifiedText = (boundingBox: fabric.Rect, object: fabric.Object): void => {\n    setShapePosition(boundingBox, object);\n};\n\nconst setShapePosition = (\n    boundingBox: fabric.Rect,\n    object: fabric.Object,\n): void => {\n    const { left, top, width, height } = object.getBoundingRect(true);\n\n    if (left < 0) {\n        object.set({ left: Math.max(object.left! - left, 0) });\n    }\n    if (top < 0) {\n        object.set({ top: Math.max(object.top! - top, 0) });\n    }\n    if (left > boundingBox.width!) {\n        object.set({ left: object.left! - left - width + boundingBox.width! });\n    }\n    if (top > boundingBox.height!) {\n        object.set({ top: object.top! - top - height + boundingBox.height! });\n    }\n\n    object.setCoords();\n    saveNeededStore.set(true);\n};\n\nexport function enableUniformScaling(canvas: fabric.Canvas, obj: fabric.Object): void {\n    obj.setControlsVisibility({ mb: false, ml: false, mt: false, mr: false });\n    let timer: number;\n    obj.on(\"scaling\", (e) => {\n        if ([\"bl\", \"br\", \"tr\", \"tl\"].includes(e.transform!.corner)) {\n            clearTimeout(timer);\n            canvas.uniformScaling = true;\n            // https://github.com/sveltejs/kit/issues/9348\n            timer = setTimeout(() => {\n                canvas.uniformScaling = false;\n            }, 500) as unknown as number;\n        }\n    });\n}\n\nexport function addBorder(obj: fabric.Object): void {\n    obj.stroke = BORDER_COLOR;\n    obj.strokeWidth = 1;\n    obj.strokeUniform = true;\n}\n\nexport const redraw = (canvas: fabric.Canvas): void => {\n    canvas.requestRenderAll();\n};\n\nexport const clear = (canvas: fabric.Canvas): void => {\n    canvas.clear();\n};\n\n/**\n * Creates a canvas event listener on shape movement to restrict movement to within the `boundingBox`\n */\nexport const makeShapesRemainInCanvas = (canvas: fabric.Canvas, boundingBox: fabric.Rect) => {\n    canvas.on(\"object:moving\", function(e) {\n        const obj = e.target!;\n\n        const { left: objBbLeft, top: objBbTop, width: objBbWidth, height: objBbHeight } = obj.getBoundingRect(\n            true,\n            true,\n        );\n\n        if (objBbWidth > boundingBox.width! || objBbHeight > boundingBox.height!) {\n            return;\n        }\n\n        const topBound = boundingBox.top!;\n        const bottomBound = topBound + boundingBox.height! + 5;\n        const leftBound = boundingBox.left!;\n        const rightBound = leftBound + boundingBox.width! + 5;\n\n        const newBbLeft = Math.min(Math.max(objBbLeft, leftBound), rightBound - objBbWidth);\n        const newBbTop = Math.min(Math.max(objBbTop, topBound), bottomBound - objBbHeight);\n\n        obj.left = obj.left! + newBbLeft - objBbLeft;\n        obj.top = obj.top! + newBbTop - objBbTop;\n    });\n};\n\nexport const selectAllShapes = (canvas: fabric.Canvas) => {\n    canvas.discardActiveObject();\n    // filter out the transparent bounding box from the selection\n    const sel = new fabric.ActiveSelection(\n        canvas.getObjects().filter((obj) => obj.fill !== \"transparent\"),\n        {\n            canvas: canvas,\n        },\n    );\n    canvas.setActiveObject(sel);\n    redraw(canvas);\n};\n\nexport const isPointerInBoundingBox = (pointer): boolean => {\n    const boundingBox = getBoundingBox();\n    if (boundingBox === undefined) {\n        return false;\n    }\n    boundingBox.selectable = false;\n    boundingBox.evented = false;\n    if (\n        pointer.x < boundingBox.left!\n        || pointer.x > boundingBox.left! + boundingBox.width!\n        || pointer.y < boundingBox.top!\n        || pointer.y > boundingBox.top! + boundingBox.height!\n    ) {\n        return false;\n    }\n    return true;\n};\n\nexport const getBoundingBox = (): fabric.Rect | undefined => {\n    const canvas: fabric.Canvas = globalThis.canvas;\n    return canvas.getObjects().find((obj) => obj.fill === \"transparent\");\n};\n\nexport const getBoundingBoxSize = (): Size => {\n    const boundingBoxSize = getBoundingBox()?.getBoundingRect(true);\n    if (boundingBoxSize) {\n        return { width: boundingBoxSize.width, height: boundingBoxSize.height };\n    }\n    return { width: 0, height: 0 };\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/more-tools.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\n\nimport {\n    mdiAlignHorizontalCenter,\n    mdiAlignHorizontalLeft,\n    mdiAlignHorizontalRight,\n    mdiAlignVerticalBottom,\n    mdiAlignVerticalCenter,\n    mdiAlignVerticalTop,\n    mdiCopy,\n    mdiDeleteOutline,\n    mdiGroup,\n    mdiSelectAll,\n    mdiUngroup,\n    mdiZoomIn,\n    mdiZoomOut,\n    mdiZoomReset,\n} from \"$lib/components/icons\";\n\nimport { deleteItem, duplicateItem, groupShapes, selectAllShapes, unGroupShapes } from \"./lib\";\nimport {\n    alignBottomKeyCombination,\n    alignHorizontalCenterKeyCombination,\n    alignLeftKeyCombination,\n    alignRightKeyCombination,\n    alignTopKeyCombination,\n    alignVerticalCenterKeyCombination,\n    deleteKeyCombination,\n    duplicateKeyCombination,\n    groupKeyCombination,\n    selectAllKeyCombination,\n    ungroupKeyCombination,\n    zoomInKeyCombination,\n    zoomOutKeyCombination,\n    zoomResetKeyCombination,\n} from \"./shortcuts\";\nimport {\n    alignBottom,\n    alignHorizontalCenter,\n    alignLeft,\n    alignRight,\n    alignTop,\n    alignVerticalCenter,\n} from \"./tool-aligns\";\nimport { zoomIn, zoomOut, zoomReset } from \"./tool-zoom\";\n\nexport const groupUngroupTools = [\n    {\n        name: \"group\",\n        icon: mdiGroup,\n        action: groupShapes,\n        tooltip: tr.editingImageOcclusionGroup,\n        shortcut: groupKeyCombination,\n    },\n    {\n        name: \"ungroup\",\n        icon: mdiUngroup,\n        action: unGroupShapes,\n        tooltip: tr.editingImageOcclusionUngroup,\n        shortcut: ungroupKeyCombination,\n    },\n    {\n        name: \"select-all\",\n        icon: mdiSelectAll,\n        action: selectAllShapes,\n        tooltip: tr.editingImageOcclusionSelectAll,\n        shortcut: selectAllKeyCombination,\n    },\n];\n\nexport const deleteDuplicateTools = [\n    {\n        name: \"delete\",\n        icon: mdiDeleteOutline,\n        action: deleteItem,\n        tooltip: tr.editingImageOcclusionDelete,\n        shortcut: deleteKeyCombination,\n    },\n    {\n        name: \"duplicate\",\n        icon: mdiCopy,\n        action: duplicateItem,\n        tooltip: tr.editingImageOcclusionDuplicate,\n        shortcut: duplicateKeyCombination,\n    },\n];\n\nexport const zoomTools = [\n    {\n        name: \"zoomOut\",\n        icon: mdiZoomOut,\n        action: zoomOut,\n        tooltip: tr.editingImageOcclusionZoomOut,\n        shortcut: zoomOutKeyCombination,\n    },\n    {\n        name: \"zoomIn\",\n        icon: mdiZoomIn,\n        action: zoomIn,\n        tooltip: tr.editingImageOcclusionZoomIn,\n        shortcut: zoomInKeyCombination,\n    },\n    {\n        name: \"zoomReset\",\n        icon: mdiZoomReset,\n        action: zoomReset,\n        tooltip: tr.editingImageOcclusionZoomReset,\n        shortcut: zoomResetKeyCombination,\n    },\n];\n\nexport const alignTools = [\n    {\n        id: 1,\n        icon: mdiAlignHorizontalLeft,\n        action: alignLeft,\n        tooltip: tr.editingImageOcclusionAlignLeft,\n        shortcut: alignLeftKeyCombination,\n    },\n    {\n        id: 2,\n        icon: mdiAlignHorizontalCenter,\n        action: alignHorizontalCenter,\n        tooltip: tr.editingImageOcclusionAlignHCenter,\n        shortcut: alignHorizontalCenterKeyCombination,\n    },\n    {\n        id: 3,\n        icon: mdiAlignHorizontalRight,\n        action: alignRight,\n        tooltip: tr.editingImageOcclusionAlignRight,\n        shortcut: alignRightKeyCombination,\n    },\n    {\n        id: 4,\n        icon: mdiAlignVerticalTop,\n        action: alignTop,\n        tooltip: tr.editingImageOcclusionAlignTop,\n        shortcut: alignTopKeyCombination,\n    },\n    {\n        id: 5,\n        icon: mdiAlignVerticalCenter,\n        action: alignVerticalCenter,\n        tooltip: tr.editingImageOcclusionAlignVCenter,\n        shortcut: alignVerticalCenterKeyCombination,\n    },\n    {\n        id: 6,\n        icon: mdiAlignVerticalBottom,\n        action: alignBottom,\n        tooltip: tr.editingImageOcclusionAlignBottom,\n        shortcut: alignBottomKeyCombination,\n    },\n];\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/shortcuts.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport const cursorKeyCombination = \"S\";\nexport const rectangleKeyCombination = \"R\";\nexport const ellipseKeyCombination = \"E\";\nexport const polygonKeyCombination = \"P\";\nexport const textKeyCombination = \"T\";\nexport const fillKeyCombination = \"C\";\nexport const magnifyKeyCombination = \"M\";\nexport const undoKeyCombination = \"Control+Z\";\nexport const redoKeyCombination = \"Control+Y\";\nexport const zoomOutKeyCombination = \"[\";\nexport const zoomInKeyCombination = \"]\";\nexport const zoomResetKeyCombination = \"F\";\nexport const toggleTranslucentKeyCombination = \"L\";\nexport const deleteKeyCombination = \"Delete\";\nexport const duplicateKeyCombination = \"D\";\nexport const groupKeyCombination = \"G\";\nexport const ungroupKeyCombination = \"U\";\nexport const selectAllKeyCombination = \"A\";\nexport const alignLeftKeyCombination = \"Shift+L\";\nexport const alignHorizontalCenterKeyCombination = \"Shift+H\";\nexport const alignRightKeyCombination = \"Shift+R\";\nexport const alignTopKeyCombination = \"Shift+T\";\nexport const alignVerticalCenterKeyCombination = \"Shift+V\";\nexport const alignBottomKeyCombination = \"Shift+B\";\nexport const toggleMaskEditorKeyCombination = \"Control+Alt+Shift+M\";\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-aligns.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { fabric } from \"fabric\";\n\nexport const alignLeft = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n\n    if (activeObject.type == \"activeSelection\") {\n        alignLeftGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ left: 0 });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\nexport const alignHorizontalCenter = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n\n    if (activeObject.type == \"activeSelection\") {\n        alignHorizontalCenterGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ left: (canvas.width!) / 2 - (activeObject.width!) / 2 });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\nexport const alignRight = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n\n    if (activeObject.type == \"activeSelection\") {\n        alignRightGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ left: canvas.getWidth() - activeObject.width! });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\nexport const alignTop = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n    if (activeObject.type == \"activeSelection\") {\n        alignTopGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ top: 0 });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\nexport const alignVerticalCenter = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n    if (activeObject.type == \"activeSelection\") {\n        alignVerticalCenterGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ top: canvas.getHeight() / 2 - activeObject.height! / 2 });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\nexport const alignBottom = (canvas: fabric.Canvas): void => {\n    const activeObject = canvas.getActiveObject();\n    if (!activeObject) {\n        return;\n    }\n    if (activeObject.type == \"activeSelection\") {\n        alignBottomGroup(canvas, activeObject as fabric.ActiveSelection);\n    } else {\n        activeObject.set({ top: canvas.height! - activeObject.height! });\n    }\n\n    activeObject.setCoords();\n    canvas.renderAll();\n};\n\n// group aligns\n\nconst alignLeftGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>) => {\n    const objects = group.getObjects();\n    let leftmostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        if (objects[i].left! < leftmostShape.left!) {\n            leftmostShape = objects[i];\n        }\n    }\n\n    objects.forEach((object) => {\n        object.left = leftmostShape.left;\n        object.setCoords();\n    });\n};\n\nconst alignRightGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>): void => {\n    const objects = group.getObjects();\n    let rightmostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        if (objects[i].left! > rightmostShape.left!) {\n            rightmostShape = objects[i];\n        }\n    }\n\n    objects.forEach((object) => {\n        object.left = rightmostShape.left! + rightmostShape.width! - object.width!;\n        object.setCoords();\n    });\n};\n\nconst alignTopGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>): void => {\n    const objects = group.getObjects();\n    let topmostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        if (objects[i].top! < topmostShape.top!) {\n            topmostShape = objects[i];\n        }\n    }\n\n    objects.forEach((object) => {\n        object.top = topmostShape.top;\n        object.setCoords();\n    });\n};\n\nconst alignBottomGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>): void => {\n    const objects = group.getObjects();\n    let bottommostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        if (objects[i].top! + objects[i].height! > bottommostShape.top! + bottommostShape.height!) {\n            bottommostShape = objects[i];\n        }\n    }\n\n    objects.forEach(function(object) {\n        if (object !== bottommostShape) {\n            object.set({ top: bottommostShape.top! + bottommostShape.height! - object.height! });\n            object.setCoords();\n        }\n    });\n};\n\nconst alignHorizontalCenterGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>) => {\n    const objects = group.getObjects();\n    let leftmostShape = objects[0];\n    let rightmostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        if (objects[i].left! < leftmostShape.left!) {\n            leftmostShape = objects[i];\n        }\n        if (objects[i].left! > rightmostShape.left!) {\n            rightmostShape = objects[i];\n        }\n    }\n\n    const centerX = (leftmostShape.left! + rightmostShape.left! + rightmostShape.width!) / 2;\n    objects.forEach((object) => {\n        object.left = centerX - object.width! / 2;\n        object.setCoords();\n    });\n};\n\nconst alignVerticalCenterGroup = (canvas: fabric.Canvas, group: fabric.ICollection<fabric.Object>) => {\n    const objects = group.getObjects();\n    let topmostShape = objects[0];\n    let bottommostShape = objects[0];\n\n    for (let i = 1; i < objects.length; i++) {\n        const current = objects[i];\n        if (current.top! < topmostShape.top!) {\n            topmostShape = current;\n        }\n        if (current.top! > bottommostShape.top!) {\n            bottommostShape = objects[i];\n        }\n    }\n\n    const centerY = (topmostShape.top! + bottommostShape.top! + bottommostShape.height!) / 2;\n    objects.forEach((object) => {\n        object.top = centerY - object.height! / 2;\n        object.setCoords();\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-buttons.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\n\nimport {\n    mdiCursorDefaultOutline,\n    mdiEllipseOutline,\n    mdiFormatColorFill,\n    mdiRectangleOutline,\n    mdiTextBox,\n    mdiVectorPolygonVariant,\n} from \"$lib/components/icons\";\n\nimport {\n    cursorKeyCombination,\n    ellipseKeyCombination,\n    fillKeyCombination,\n    polygonKeyCombination,\n    rectangleKeyCombination,\n    textKeyCombination,\n} from \"./shortcuts\";\n\nexport const tools = [\n    {\n        id: \"cursor\",\n        icon: mdiCursorDefaultOutline,\n        tooltip: tr.editingImageOcclusionSelectTool,\n        shortcut: cursorKeyCombination,\n    },\n    {\n        id: \"draw-rectangle\",\n        icon: mdiRectangleOutline,\n        tooltip: tr.editingImageOcclusionRectangleTool,\n        shortcut: rectangleKeyCombination,\n    },\n    {\n        id: \"draw-ellipse\",\n        icon: mdiEllipseOutline,\n        tooltip: tr.editingImageOcclusionEllipseTool,\n        shortcut: ellipseKeyCombination,\n    },\n    {\n        id: \"draw-polygon\",\n        icon: mdiVectorPolygonVariant,\n        tooltip: tr.editingImageOcclusionPolygonTool,\n        shortcut: polygonKeyCombination,\n    },\n    {\n        id: \"draw-text\",\n        icon: mdiTextBox,\n        tooltip: tr.editingImageOcclusionTextTool,\n        shortcut: textKeyCombination,\n    },\n    {\n        id: \"fill-mask\",\n        icon: mdiFormatColorFill,\n        iconSizeMult: 1.4,\n        tooltip: tr.editingImageOcclusionFillTool,\n        shortcut: fillKeyCombination,\n    },\n] as const;\n\nexport type ActiveTool = typeof tools[number][\"id\"];\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-cursor.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { fabric } from \"fabric\";\n\nimport { stopDraw } from \"./lib\";\nimport { onPinchZoom } from \"./tool-zoom\";\n\nexport const drawCursor = (canvas: fabric.Canvas): void => {\n    canvas.selectionColor = \"rgba(100, 100, 255, 0.3)\";\n    stopDraw(canvas);\n\n    canvas.on(\"mouse:down\", function(o) {\n        if (o.target) {\n            return;\n        }\n    });\n\n    canvas.on(\"mouse:move\", function(o) {\n        if (onPinchZoom(o)) {\n            return;\n        }\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-ellipse.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport { opacityStateStore } from \"../store\";\nimport { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from \"./lib\";\nimport { undoStack } from \"./tool-undo-redo\";\nimport { onPinchZoom } from \"./tool-zoom\";\n\nexport const drawEllipse = (canvas: fabric.Canvas): void => {\n    canvas.selectionColor = \"rgba(0, 0, 0, 0)\";\n    let ellipse, isDown, origX, origY;\n\n    stopDraw(canvas);\n\n    canvas.on(\"mouse:down\", function(o) {\n        if (o.target) {\n            return;\n        }\n        isDown = true;\n\n        const pointer = canvas.getPointer(o.e);\n        origX = pointer.x;\n        origY = pointer.y;\n\n        if (!isPointerInBoundingBox(pointer)) {\n            isDown = false;\n            return;\n        }\n\n        ellipse = new fabric.Ellipse({\n            left: origX,\n            top: origY,\n            originX: \"left\",\n            originY: \"top\",\n            rx: pointer.x - origX,\n            ry: pointer.y - origY,\n            fill: SHAPE_MASK_COLOR,\n            transparentCorners: false,\n            selectable: true,\n            stroke: BORDER_COLOR,\n            strokeWidth: 1,\n            strokeUniform: true,\n            noScaleCache: false,\n            opacity: get(opacityStateStore) ? 0.4 : 1,\n        });\n        ellipse[\"id\"] = \"ellipse-\" + new Date().getTime();\n\n        canvas.add(ellipse);\n    });\n\n    canvas.on(\"mouse:move\", function(o) {\n        if (onPinchZoom(o)) {\n            canvas.remove(ellipse);\n            canvas.renderAll();\n            return;\n        }\n\n        if (!isDown) {\n            return;\n        }\n        const pointer = canvas.getPointer(o.e);\n        let rx = Math.abs(origX - pointer.x) / 2;\n        let ry = Math.abs(origY - pointer.y) / 2;\n        const x = pointer.x;\n        const y = pointer.y;\n\n        if (rx > ellipse.strokeWidth) {\n            rx -= ellipse.strokeWidth / 2;\n        }\n        if (ry > ellipse.strokeWidth) {\n            ry -= ellipse.strokeWidth / 2;\n        }\n\n        if (x < origX) {\n            ellipse.set({ originX: \"right\" });\n        } else {\n            ellipse.set({ originX: \"left\" });\n        }\n\n        if (y < origY) {\n            ellipse.set({ originY: \"bottom\" });\n        } else {\n            ellipse.set({ originY: \"top\" });\n        }\n\n        ellipse.set({ rx: rx, ry: ry });\n\n        canvas.renderAll();\n    });\n\n    canvas.on(\"mouse:up\", function() {\n        isDown = false;\n        // probably changed from ellipse to rectangle\n        if (!ellipse) {\n            return;\n        }\n        if (ellipse.width < 5 || ellipse.height < 5) {\n            canvas.remove(ellipse);\n            ellipse = undefined;\n            return;\n        }\n\n        if (ellipse.originX === \"right\") {\n            ellipse.set({\n                originX: \"left\",\n                left: ellipse.left - ellipse.width + ellipse.strokeWidth,\n            });\n        }\n\n        if (ellipse.originY === \"bottom\") {\n            ellipse.set({\n                originY: \"top\",\n                top: ellipse.top - ellipse.height + ellipse.strokeWidth,\n            });\n        }\n\n        ellipse.setCoords();\n        canvas.setActiveObject(ellipse);\n        undoStack.onObjectAdded(ellipse.id);\n        ellipse = undefined;\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-fill.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\n\nimport { get, type Readable } from \"svelte/store\";\nimport { stopDraw } from \"./lib\";\nimport { undoStack } from \"./tool-undo-redo\";\n\nexport const fillMask = (canvas: fabric.Canvas, colourStore: Readable<string>): void => {\n    // remove selectable for shapes\n    canvas.discardActiveObject();\n    canvas.forEachObject(function(o) {\n        o.selectable = false;\n    });\n    canvas.selectionColor = \"rgba(0, 0, 0, 0)\";\n    stopDraw(canvas);\n\n    canvas.on(\"mouse:down\", function(o) {\n        const target = o.target instanceof fabric.Group ? canvas.targets[0] : o.target;\n        const colour = get(colourStore);\n        if (!target || target.fill === colour) { return; }\n        target.fill = colour;\n        undoStack.onObjectModified();\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-polygon.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport { opacityStateStore } from \"../store\";\nimport { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR } from \"./lib\";\nimport { undoStack } from \"./tool-undo-redo\";\nimport { onPinchZoom } from \"./tool-zoom\";\n\nlet activeLine;\nlet activeShape;\nlet linesList: fabric.Line[] = [];\nlet pointsList: fabric.Circle[] = [];\nlet drawMode = false;\n\nexport const drawPolygon = (canvas: fabric.Canvas): void => {\n    // remove selectable for shapes\n    canvas.discardActiveObject();\n    canvas.forEachObject(function(o) {\n        o.selectable = false;\n    });\n\n    canvas.selectionColor = \"rgba(0, 0, 0, 0)\";\n    canvas.on(\"mouse:down\", function(options) {\n        try {\n            if (options.target && options.target[\"id\"] === pointsList[0][\"id\"]) {\n                generatePolygon(canvas, pointsList);\n            } else {\n                addPoint(canvas, options);\n            }\n        } catch (e) {\n            // Cannot read properties of undefined (reading 'id')\n        }\n    });\n\n    canvas.on(\"mouse:move\", function(options) {\n        // if pinch zoom is active, remove all points and lines\n        if (onPinchZoom(options)) {\n            removeUnfinishedPolygon(canvas);\n            return;\n        }\n\n        if (activeLine && activeLine.class === \"line\") {\n            const pointer = canvas.getPointer(options.e);\n            activeLine.set({\n                x2: pointer.x,\n                y2: pointer.y,\n            });\n\n            const points = activeShape.get(\"points\");\n            points[pointsList.length] = {\n                x: pointer.x,\n                y: pointer.y,\n            };\n\n            activeShape.set({ points });\n        }\n        canvas.renderAll();\n    });\n};\n\nconst toggleDrawPolygon = (canvas: fabric.Canvas): void => {\n    drawMode = !drawMode;\n    if (drawMode) {\n        activeLine = null;\n        activeShape = null;\n        linesList = [];\n        pointsList = [];\n        drawMode = false;\n        canvas.selection = true;\n    } else {\n        drawMode = true;\n        canvas.selection = false;\n    }\n};\n\nconst addPoint = (canvas: fabric.Canvas, options): void => {\n    const pointer = canvas.getPointer(options.e);\n    const origX = pointer.x;\n    const origY = pointer.y;\n\n    if (!isPointerInBoundingBox(pointer)) {\n        return;\n    }\n\n    const point = new fabric.Circle({\n        radius: 5,\n        fill: \"transparent\",\n        stroke: \"#333333\",\n        strokeWidth: 1.5,\n        originX: \"center\",\n        originY: \"center\",\n        left: origX,\n        top: origY,\n        selectable: false,\n        hasBorders: false,\n        hasControls: false,\n        objectCaching: false,\n        perPixelTargetFind: false,\n    });\n\n    if (pointsList.length === 0) {\n        point.set({\n            stroke: \"red\",\n        });\n    }\n\n    const linePoints = [origX, origY, origX, origY];\n\n    const line = new fabric.Line(linePoints, {\n        strokeWidth: 2,\n        fill: \"#999999\",\n        stroke: \"#999999\",\n        originX: \"center\",\n        originY: \"center\",\n        selectable: false,\n        hasBorders: false,\n        hasControls: false,\n        evented: false,\n        objectCaching: false,\n    });\n    line[\"class\"] = \"line\";\n\n    if (activeShape) {\n        const pointer = canvas.getPointer(options.e);\n        const points = activeShape.get(\"points\");\n        points.push({\n            x: pointer.x,\n            y: pointer.y,\n        });\n\n        const polygon = new fabric.Polygon(points, {\n            stroke: \"#333333\",\n            strokeWidth: 1,\n            fill: \"#cccccc\",\n            opacity: 0.3,\n            selectable: false,\n            hasBorders: false,\n            hasControls: false,\n            evented: false,\n            objectCaching: false,\n        });\n\n        canvas.remove(activeShape);\n        canvas.add(polygon);\n        activeShape = polygon;\n        canvas.renderAll();\n    } else {\n        const polyPoint = [{ x: origX, y: origY }];\n        const polygon = new fabric.Polygon(polyPoint, {\n            stroke: \"#333333\",\n            strokeWidth: 1,\n            fill: \"#cccccc\",\n            opacity: 0.3,\n            selectable: false,\n            hasBorders: false,\n            hasControls: false,\n            evented: false,\n            objectCaching: false,\n        });\n\n        activeShape = polygon;\n        canvas.add(polygon);\n    }\n\n    activeLine = line;\n    pointsList.push(point);\n    linesList.push(line);\n\n    canvas.add(line);\n    canvas.add(point);\n    canvas.renderAll();\n};\n\nconst generatePolygon = (canvas: fabric.Canvas, pointsList): void => {\n    const points: { x: number; y: number }[] = [];\n    pointsList.forEach((point) => {\n        points.push({\n            x: point.left,\n            y: point.top,\n        });\n        canvas.remove(point);\n    });\n\n    linesList.forEach((line) => {\n        canvas.remove(line);\n    });\n\n    canvas.remove(activeShape).remove(activeLine);\n\n    const polygon = new fabric.Polygon(points, {\n        fill: SHAPE_MASK_COLOR,\n        objectCaching: false,\n        stroke: BORDER_COLOR,\n        strokeWidth: 1,\n        strokeUniform: true,\n        noScaleCache: false,\n        selectable: false,\n        opacity: get(opacityStateStore) ? 0.4 : 1,\n    });\n    polygon[\"id\"] = \"polygon-\" + new Date().getTime();\n    if (polygon.width! > 5 && polygon.height! > 5) {\n        canvas.add(polygon);\n        canvas.setActiveObject(polygon);\n        // view undo redo tools\n        undoStack.onObjectAdded(polygon[\"id\"]);\n    }\n\n    toggleDrawPolygon(canvas);\n};\n\n// https://github.com/fabricjs/fabric.js/issues/6522\nexport const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon): void => {\n    const matrix = polygon.calcTransformMatrix();\n    const transformedPoints = polygon.get(\"points\")!\n        .map(function(p) {\n            return new fabric.Point(p.x - polygon.pathOffset.x, p.y - polygon.pathOffset.y);\n        })\n        .map(function(p) {\n            return fabric.util.transformPoint(p, matrix);\n        });\n\n    const polygon1 = new fabric.Polygon(transformedPoints, {\n        fill: polygon.fill ?? SHAPE_MASK_COLOR,\n        objectCaching: false,\n        stroke: BORDER_COLOR,\n        strokeWidth: 1,\n        strokeUniform: true,\n        noScaleCache: false,\n        opacity: get(opacityStateStore) ? 0.4 : 1,\n    });\n    polygon1[\"id\"] = polygon[\"id\"];\n\n    canvas.remove(polygon);\n    canvas.add(polygon1);\n};\n\n/**\n * Removes the currently unfinished polygon, if any, and reset internal state\n * @returns whether or not such a polygon was removed and state was reset\n */\nexport const removeUnfinishedPolygon = (canvas: fabric.Canvas): boolean => {\n    if (!activeShape) {\n        // generatePolygon should've already removed points/lines and reset state\n        return false;\n    }\n    canvas.remove(activeShape).remove(activeLine);\n    pointsList.forEach((point) => {\n        canvas.remove(point);\n    });\n    linesList.forEach((line) => {\n        canvas.remove(line);\n    });\n    activeLine = null;\n    activeShape = null;\n    linesList = [];\n    pointsList = [];\n    drawMode = false;\n    canvas.selection = true;\n    return true;\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-rect.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport { opacityStateStore } from \"../store\";\nimport { BORDER_COLOR, isPointerInBoundingBox, SHAPE_MASK_COLOR, stopDraw } from \"./lib\";\nimport { undoStack } from \"./tool-undo-redo\";\nimport { onPinchZoom } from \"./tool-zoom\";\n\nexport const drawRectangle = (canvas: fabric.Canvas): void => {\n    canvas.selectionColor = \"rgba(0, 0, 0, 0)\";\n    let rect, isDown, origX, origY;\n\n    stopDraw(canvas);\n\n    canvas.on(\"mouse:down\", function(o) {\n        if (o.target) {\n            return;\n        }\n        isDown = true;\n\n        const pointer = canvas.getPointer(o.e);\n        origX = pointer.x;\n        origY = pointer.y;\n\n        if (!isPointerInBoundingBox(pointer)) {\n            isDown = false;\n            return;\n        }\n\n        rect = new fabric.Rect({\n            left: origX,\n            top: origY,\n            originX: \"left\",\n            originY: \"top\",\n            width: pointer.x - origX,\n            height: pointer.y - origY,\n            angle: 0,\n            fill: SHAPE_MASK_COLOR,\n            transparentCorners: false,\n            selectable: true,\n            stroke: BORDER_COLOR,\n            strokeWidth: 1,\n            strokeUniform: true,\n            noScaleCache: false,\n            opacity: get(opacityStateStore) ? 0.4 : 1,\n        });\n        rect[\"id\"] = \"rect-\" + new Date().getTime();\n\n        canvas.add(rect);\n    });\n\n    canvas.on(\"mouse:move\", function(o) {\n        if (onPinchZoom(o)) {\n            canvas.remove(rect);\n            canvas.renderAll();\n            return;\n        }\n\n        if (!isDown) {\n            return;\n        }\n        const pointer = canvas.getPointer(o.e);\n        const x = pointer.x;\n        const y = pointer.y;\n\n        if (x < origX) {\n            rect.set({ originX: \"right\" });\n        } else {\n            rect.set({ originX: \"left\" });\n        }\n\n        if (y < origY) {\n            rect.set({ originY: \"bottom\" });\n        } else {\n            rect.set({ originY: \"top\" });\n        }\n\n        rect.set({\n            width: Math.abs(x - rect.left),\n            height: Math.abs(y - rect.top),\n        });\n\n        canvas.renderAll();\n    });\n\n    canvas.on(\"mouse:up\", function() {\n        isDown = false;\n        // probably changed from rectangle to ellipse\n        if (!rect) {\n            return;\n        }\n        if (rect.width < 5 || rect.height < 5) {\n            canvas.remove(rect);\n            rect = undefined;\n            return;\n        }\n\n        if (rect.originX === \"right\") {\n            rect.set({\n                originX: \"left\",\n                left: rect.left - rect.width + rect.strokeWidth,\n            });\n        }\n\n        if (rect.originY === \"bottom\") {\n            rect.set({\n                originY: \"top\",\n                top: rect.top - rect.height + rect.strokeWidth,\n            });\n        }\n\n        rect.setCoords();\n        canvas.setActiveObject(rect);\n        undoStack.onObjectAdded(rect.id);\n        rect = undefined;\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-text.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { fabric } from \"fabric\";\nimport { get } from \"svelte/store\";\n\nimport type { Callback } from \"@tslib/helpers\";\nimport { opacityStateStore } from \"../store\";\nimport {\n    enableUniformScaling,\n    isPointerInBoundingBox,\n    stopDraw,\n    TEXT_BACKGROUND_COLOR,\n    TEXT_COLOR,\n    TEXT_FONT_FAMILY,\n    TEXT_PADDING,\n} from \"./lib\";\nimport { undoStack } from \"./tool-undo-redo\";\nimport { onPinchZoom } from \"./tool-zoom\";\n\nexport const drawText = (canvas: fabric.Canvas, onActivated: Callback): void => {\n    canvas.selectionColor = \"rgba(0, 0, 0, 0)\";\n    stopDraw(canvas);\n\n    let text: fabric.IText;\n\n    canvas.on(\"mouse:down\", function(o) {\n        if (o.target) {\n            return;\n        }\n        const pointer = canvas.getPointer(o.e);\n\n        if (!isPointerInBoundingBox(pointer)) {\n            return;\n        }\n\n        text = new fabric.IText(\"text\", {\n            left: pointer.x,\n            top: pointer.y,\n            originX: \"left\",\n            originY: \"top\",\n            selectable: true,\n            strokeWidth: 1,\n            noScaleCache: false,\n            fill: TEXT_COLOR,\n            fontFamily: TEXT_FONT_FAMILY,\n            backgroundColor: TEXT_BACKGROUND_COLOR,\n            padding: TEXT_PADDING,\n            opacity: get(opacityStateStore) ? 0.4 : 1,\n            lineHeight: 1,\n            lockScalingFlip: true,\n        });\n        text[\"id\"] = \"text-\" + new Date().getTime();\n\n        enableUniformScaling(canvas, text);\n        canvas.add(text);\n        canvas.setActiveObject(text);\n        undoStack.onObjectAdded(text.id);\n        text.enterEditing();\n        text.selectAll();\n        onActivated();\n    });\n\n    canvas.on(\"mouse:move\", function(o) {\n        if (onPinchZoom(o)) {\n            canvas.remove(text);\n            canvas.renderAll();\n            return;\n        }\n    });\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-undo-redo.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as tr from \"@generated/ftl\";\nimport { fabric } from \"fabric\";\nimport { writable } from \"svelte/store\";\n\nimport { mdiRedo, mdiUndo } from \"$lib/components/icons\";\n\nimport { saveNeededStore } from \"../store\";\nimport { redoKeyCombination, undoKeyCombination } from \"./shortcuts\";\nimport { removeUnfinishedPolygon } from \"./tool-polygon\";\n\n/**\n * Undo redo for rectangle and ellipse handled here,\n * view tool-polygon for handling undo redo in case of polygon\n */\n\ntype UndoState = {\n    undoable: boolean;\n    redoable: boolean;\n};\n\nconst shapeType = [\"rect\", \"ellipse\", \"i-text\", \"group\"];\n\nconst validShape = (shape: fabric.Object): boolean => {\n    if (shape.width! <= 5 || shape.height! <= 5) {\n        return false;\n    }\n    if (shapeType.indexOf(shape.type!) === -1) {\n        return false;\n    }\n    return true;\n};\n\nclass UndoStack {\n    private stack: string[] = [];\n    private index = -1;\n    private canvas: fabric.Canvas | undefined;\n    private locked = false;\n    private shapeIds = new Set<string>();\n    /** used to make the toolbar buttons reactive */\n    private state = writable<UndoState>({ undoable: false, redoable: false });\n    subscribe: typeof this.state.subscribe;\n\n    constructor() {\n        // allows an instance of the class to act as a store\n        this.subscribe = this.state.subscribe;\n    }\n\n    setCanvas(canvas: fabric.Canvas): void {\n        this.canvas = canvas;\n        this.canvas.on(\"object:modified\", (opts) => this.maybePush(opts));\n        this.canvas.on(\"object:removed\", (opts) => {\n            if (!opts.target!.group && !opts.target!.destroyed) {\n                this.maybePush(opts);\n            }\n        });\n    }\n\n    reset(): void {\n        this.shapeIds.clear();\n        this.stack.length = 0;\n        this.index = -1;\n        this.push();\n        this.updateState();\n    }\n\n    private canUndo(): boolean {\n        return this.index > 0;\n    }\n\n    private canRedo(): boolean {\n        return this.index < this.stack.length - 1;\n    }\n\n    private updateState(): void {\n        this.state.set({\n            undoable: this.canUndo(),\n            redoable: this.canRedo(),\n        });\n    }\n\n    private updateCanvas(): void {\n        this.locked = true;\n        this.canvas?.loadFromJSON(this.stack[this.index], () => {\n            this.canvas?.renderAll();\n            saveNeededStore.set(true);\n            this.locked = false;\n        });\n        // make bounding box unselectable\n        this.canvas?.forEachObject((obj) => {\n            if (obj instanceof fabric.Rect && obj.fill === \"transparent\") {\n                obj.selectable = false;\n            }\n        });\n    }\n\n    onObjectAdded(id: string): void {\n        if (!this.shapeIds.has(id)) {\n            this.push();\n        }\n        this.shapeIds.add(id);\n        saveNeededStore.set(true);\n    }\n\n    onObjectModified(): void {\n        this.push();\n        saveNeededStore.set(true);\n    }\n\n    private maybePush(obj: fabric.IEvent<MouseEvent>): void {\n        if (!this.locked && validShape(obj.target!)) {\n            this.push();\n        }\n    }\n\n    private push(): void {\n        const entry = JSON.stringify(this.canvas?.toJSON([\"id\"]));\n        if (entry === this.stack[this.index]) {\n            return;\n        }\n        this.stack.length = this.index + 1;\n        this.stack.push(entry);\n        this.index++;\n        this.updateState();\n    }\n\n    undo(): void {\n        if (this.canvas && removeUnfinishedPolygon(this.canvas)) {\n            // treat removing the unfinished polygon as an undo step\n            return;\n        }\n        if (this.canUndo()) {\n            this.index--;\n            this.updateState();\n            this.updateCanvas();\n        }\n    }\n\n    redo(): void {\n        if (this.canvas) {\n            // when redoing, removing an unfinished polygon doesn't make sense as a discrete step\n            removeUnfinishedPolygon(this.canvas);\n        }\n        if (this.canRedo()) {\n            this.index++;\n            this.updateState();\n            this.updateCanvas();\n        }\n    }\n}\n\nexport const undoStack = new UndoStack();\n\nexport const undoRedoTools = [\n    {\n        name: \"undo\",\n        icon: mdiUndo,\n        action: () => undoStack.undo(),\n        tooltip: tr.undoUndo,\n        shortcut: undoKeyCombination,\n    },\n    {\n        name: \"redo\",\n        icon: mdiRedo,\n        action: () => undoStack.redo(),\n        tooltip: tr.undoRedo,\n        shortcut: redoKeyCombination,\n    },\n];\n"
  },
  {
    "path": "ts/routes/image-occlusion/tools/tool-zoom.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\n// https://codepen.io/amsunny/pen/XWGLxye\n// canvas.viewportTransform = [ scaleX, skewX, skewY, scaleY, translateX, translateY ]\n\nimport type { fabric } from \"fabric\";\nimport Hammer from \"hammerjs\";\n\nimport { isDesktop } from \"$lib/tslib/platform\";\n\nimport type { Size } from \"../types\";\nimport { getBoundingBoxSize, redraw } from \"./lib\";\n\nconst minScale = 0.5;\nconst maxScale = 5;\nlet zoomScale = 1;\nexport let currentScale = 1;\n\nexport const enableZoom = (canvas: fabric.Canvas) => {\n    canvas.on(\"mouse:wheel\", onMouseWheel);\n};\n\nexport const enablePan = (canvas: fabric.Canvas) => {\n    canvas.on(\"mouse:down\", onMouseDown);\n    canvas.on(\"mouse:move\", onMouseMove);\n    canvas.on(\"mouse:up\", onMouseUp);\n};\n\nexport const disableZoom = (canvas: fabric.Canvas) => {\n    canvas.off(\"mouse:wheel\", onMouseWheel);\n};\n\nexport const disablePan = (canvas: fabric.Canvas) => {\n    canvas.off(\"mouse:down\", onMouseDown);\n    canvas.off(\"mouse:move\", onMouseMove);\n    canvas.off(\"mouse:up\", onMouseUp);\n};\n\nexport const zoomIn = (canvas: fabric.Canvas): void => {\n    let zoom = canvas.getZoom();\n    zoom = Math.min(maxScale, zoom * 1.1);\n    canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, zoom);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nexport const zoomOut = (canvas): void => {\n    let zoom = canvas.getZoom();\n    zoom = Math.max(minScale, zoom / 1.1);\n    canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, zoom / 1.1);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nexport const zoomReset = (canvas: fabric.Canvas): void => {\n    zoomResetInner(canvas);\n    // reset again to update the viewportTransform\n    zoomResetInner(canvas);\n};\n\nconst zoomResetInner = (canvas: fabric.Canvas): void => {\n    fitCanvasVptScale(canvas);\n    const vpt = canvas.viewportTransform!;\n    canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, vpt[0]);\n};\n\nexport const enablePinchZoom = (canvas: fabric.Canvas) => {\n    const hammer = new Hammer(upperCanvasElement(canvas));\n    hammer.get(\"pinch\").set({ enable: true });\n    hammer.on(\"pinchin pinchout\", ev => {\n        currentScale = Math.min(Math.max(minScale, ev.scale * zoomScale), maxScale);\n        canvas.zoomToPoint({ x: canvas.width! / 2, y: canvas.height! / 2 }, currentScale);\n        constrainBoundsAroundBgImage(canvas);\n        redraw(canvas);\n    });\n    hammer.on(\"pinchend pinchcancel\", () => {\n        zoomScale = currentScale;\n    });\n};\n\nfunction upperCanvasElement(canvas: fabric.Canvas): HTMLElement {\n    return canvas[\"upperCanvasEl\"] as HTMLElement;\n}\n\nexport const disablePinchZoom = (canvas: fabric.Canvas) => {\n    const hammer = new Hammer(upperCanvasElement(canvas));\n    hammer.get(\"pinch\").set({ enable: false });\n    hammer.off(\"pinch pinchmove pinchend pinchcancel\");\n};\n\nexport const onResize = (canvas: fabric.Canvas) => {\n    setCanvasSize(canvas);\n    zoomReset(canvas);\n};\n\nconst onMouseWheel = (opt) => {\n    const canvas = globalThis.canvas;\n    const delta = opt.e.deltaY;\n    let zoom = canvas.getZoom();\n    zoom *= 0.999 ** delta;\n    zoom = Math.max(minScale, Math.min(zoom, maxScale));\n    canvas.zoomToPoint({ x: opt.pointer.x, y: opt.pointer.y }, zoom);\n    opt.e.preventDefault();\n    opt.e.stopPropagation();\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nconst onMouseDown = (opt) => {\n    const canvas = globalThis.canvas;\n    canvas.discardActiveObject();\n    const { e } = opt;\n    const clientX = e.type === \"touchstart\" ? e.touches[0].clientX : e.clientX;\n    const clientY = e.type === \"touchstart\" ? e.touches[0].clientY : e.clientY;\n    canvas.lastPosX = clientX;\n    canvas.lastPosY = clientY;\n    redraw(canvas);\n};\n\nexport const onMouseMove = (opt) => {\n    const canvas = globalThis.canvas;\n    canvas.discardActiveObject();\n    if (!canvas.viewportTransform) {\n        return;\n    }\n\n    // handle pinch zoom and pan for mobile devices\n    if (onPinchZoom(opt)) {\n        return;\n    }\n\n    onDrag(canvas, opt);\n};\n\nexport const onPinchZoom = (opt): boolean => {\n    const { e } = opt;\n    const canvas = globalThis.canvas;\n    if ((e.type === \"touchmove\") && (e.touches.length > 1)) {\n        onDrag(canvas, opt);\n        return true;\n    }\n    return false;\n};\n\nconst onDrag = (canvas, opt) => {\n    const { e } = opt;\n    const clientX = e.type === \"touchmove\" ? e.touches[0].clientX : e.clientX;\n    const clientY = e.type === \"touchmove\" ? e.touches[0].clientY : e.clientY;\n    const vpt = canvas.viewportTransform;\n\n    vpt[4] += clientX - canvas.lastPosX;\n    vpt[5] += clientY - canvas.lastPosY;\n    canvas.lastPosX += clientX - canvas.lastPosX;\n    canvas.lastPosY += clientY - canvas.lastPosY;\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nexport const onWheelDrag = (canvas: fabric.Canvas, event: WheelEvent) => {\n    const deltaX = event.deltaX;\n    const deltaY = event.deltaY;\n    const vpt = canvas.viewportTransform!;\n    canvas[\"lastPosX\"] = event.clientX;\n    canvas[\"lastPosY\"] = event.clientY;\n\n    vpt[4] -= deltaX;\n    vpt[5] -= deltaY;\n\n    canvas[\"lastPosX\"] -= deltaX;\n    canvas[\"lastPosY\"] -= deltaY;\n    canvas.setViewportTransform(vpt);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nexport const onWheelDragX = (canvas: fabric.Canvas, event: WheelEvent) => {\n    const delta = event.deltaY;\n    const vpt = canvas.viewportTransform!;\n    (canvas as any).lastPosY = event.clientY!;\n    vpt[4] -= delta;\n    (canvas as any).lastPosX -= delta;\n    canvas.setViewportTransform(vpt);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nconst onMouseUp = () => {\n    const canvas = globalThis.canvas;\n    canvas.setViewportTransform(canvas.viewportTransform);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nexport const constrainBoundsAroundBgImage = (canvas: fabric.Canvas) => {\n    const boundingBox = getBoundingBoxSize();\n    const ioImage = document.getElementById(\"image\") as HTMLImageElement;\n\n    const width = boundingBox.width * canvas.getZoom();\n    const height = boundingBox.height * canvas.getZoom();\n\n    const left = canvas.viewportTransform![4];\n    const top = canvas.viewportTransform![5];\n\n    ioImage.width = width;\n    ioImage.height = height;\n    ioImage.style.left = `${left}px`;\n    ioImage.style.top = `${top}px`;\n};\n\nexport const setCanvasSize = (canvas: fabric.Canvas) => {\n    const width = window.innerWidth - 39;\n    let height = window.innerHeight;\n    height = isDesktop() ? height - 76 : height - 46;\n    canvas.setHeight(height);\n    canvas.setWidth(width);\n    redraw(canvas);\n};\n\nconst fitCanvasVptScale = (canvas: fabric.Canvas) => {\n    const boundingBox = getBoundingBoxSize();\n    const ratio = getScaleRatio(boundingBox);\n    const vpt = canvas.viewportTransform!;\n\n    const boundingBoxWidth = boundingBox.width * canvas.getZoom();\n    const boundingBoxHeight = boundingBox.height * canvas.getZoom();\n    const center = canvas.getCenter();\n    const translateX = center.left - (boundingBoxWidth / 2);\n    const translateY = center.top - (boundingBoxHeight / 2);\n\n    vpt[0] = ratio;\n    vpt[3] = ratio;\n    vpt[4] = Math.max(1, translateX);\n    vpt[5] = Math.max(1, translateY);\n\n    canvas.setViewportTransform(canvas.viewportTransform!);\n    constrainBoundsAroundBgImage(canvas);\n    redraw(canvas);\n};\n\nconst getScaleRatio = (boundingBox: Size) => {\n    const h1 = boundingBox.height!;\n    const w1 = boundingBox.width!;\n    const w2 = innerWidth - 42;\n    let h2 = window.innerHeight;\n    h2 = isDesktop() ? h2 - 79 : h2 - 48;\n    return Math.min(w2 / w1, h2 / h1);\n};\n"
  },
  {
    "path": "ts/routes/image-occlusion/types.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nexport interface Size {\n    width: number;\n    height: number;\n}\n\nexport type ConstructorParams<T> = {\n    [P in keyof T]?: T[P];\n};\n"
  },
  {
    "path": "ts/routes/import-anki-package/Header.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    export let heading: string;\n</script>\n\n<h1>\n    {heading}\n</h1>\n\n<style lang=\"scss\">\n    h1 {\n        padding-top: 0.5em;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-anki-package/ImportAnkiPackagePage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { ImportAnkiPackageOptions } from \"@generated/anki/import_export_pb\";\n    import { importAnkiPackage } from \"@generated/backend\";\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import ImportPage from \"../import-page/ImportPage.svelte\";\n    import { updateChoices } from \"./choices\";\n\n    export let path: string;\n    export let options: ImportAnkiPackageOptions;\n\n    const settings = {\n        withScheduling: {\n            title: tr.importingAlsoImportProgress(),\n            help: tr.importingIncludeReviewsHelp(),\n            url: HelpPage.PackageImporting.scheduling,\n        },\n        withDeckConfigs: {\n            title: tr.importingWithDeckConfigs(),\n            help: tr.importingWithDeckConfigsHelp(),\n            url: HelpPage.PackageImporting.scheduling,\n        },\n        mergeNotetypes: {\n            title: tr.importingMergeNotetypes(),\n            help: tr.importingMergeNotetypesHelp(),\n            url: HelpPage.PackageImporting.updating,\n        },\n        updateNotes: {\n            title: tr.importingUpdateNotes(),\n            help: tr.importingUpdateNotesHelp(),\n            url: HelpPage.PackageImporting.updating,\n        },\n        updateNotetypes: {\n            title: tr.importingUpdateNotetypes(),\n            help: tr.importingUpdateNotetypesHelp(),\n            url: HelpPage.PackageImporting.updating,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<ImportPage\n    {path}\n    importer={{\n        doImport: () =>\n            importAnkiPackage({ packagePath: path, options }, { alertOnError: false }),\n    }}\n>\n    <Row class=\"d-block\">\n        <TitledContainer title={tr.importingImportOptions()}>\n            <HelpModal\n                title={tr.importingImportOptions()}\n                url={HelpPage.PackageImporting.root}\n                slot=\"tooltip\"\n                {helpSections}\n                on:mount={(e) => {\n                    modal = e.detail.modal;\n                    carousel = e.detail.carousel;\n                }}\n            />\n\n            <SwitchRow bind:value={options.withScheduling} defaultValue={false}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"withScheduling\"))}\n                >\n                    {settings.withScheduling.title}\n                </SettingTitle>\n            </SwitchRow>\n\n            <SwitchRow bind:value={options.withDeckConfigs} defaultValue={false}>\n                <SettingTitle\n                    on:click={() =>\n                        openHelpModal(Object.keys(settings).indexOf(\"withDeckConfigs\"))}\n                >\n                    {settings.withDeckConfigs.title}\n                </SettingTitle>\n            </SwitchRow>\n\n            <details>\n                <summary>{tr.importingUpdates()}</summary>\n                <SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"mergeNotetypes\"),\n                            )}\n                    >\n                        {settings.mergeNotetypes.title}\n                    </SettingTitle>\n                </SwitchRow>\n\n                <EnumSelectorRow\n                    bind:value={options.updateNotes}\n                    defaultValue={0}\n                    choices={updateChoices()}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(Object.keys(settings).indexOf(\"updateNotes\"))}\n                    >\n                        {settings.updateNotes.title}\n                    </SettingTitle>\n                </EnumSelectorRow>\n\n                <EnumSelectorRow\n                    bind:value={options.updateNotetypes}\n                    defaultValue={0}\n                    choices={updateChoices()}\n                >\n                    <SettingTitle\n                        on:click={() =>\n                            openHelpModal(\n                                Object.keys(settings).indexOf(\"updateNotetypes\"),\n                            )}\n                    >\n                        {settings.updateNotetypes.title}\n                    </SettingTitle>\n                </EnumSelectorRow>\n            </details>\n        </TitledContainer>\n    </Row>\n</ImportPage>\n"
  },
  {
    "path": "ts/routes/import-anki-package/[...path]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ImportAnkiPackagePage from \"../ImportAnkiPackagePage.svelte\";\n    import type { PageData } from \"./$types\";\n\n    export let data: PageData;\n</script>\n\n<ImportAnkiPackagePage path={data.path} options={data.options} />\n"
  },
  {
    "path": "ts/routes/import-anki-package/[...path]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { getImportAnkiPackagePresets } from \"@generated/backend\";\n\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async ({ params }) => {\n    const options = await getImportAnkiPackagePresets({});\n    return { path: params.path, options };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/import-anki-package/choices.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { ImportAnkiPackageUpdateCondition } from \"@generated/anki/import_export_pb\";\nimport * as tr from \"@generated/ftl\";\n\nimport type { Choice } from \"$lib/components/EnumSelector.svelte\";\n\nexport function updateChoices(): Choice<ImportAnkiPackageUpdateCondition>[] {\n    return [\n        {\n            label: tr.importingUpdateIfNewer(),\n            value: ImportAnkiPackageUpdateCondition.IF_NEWER,\n        },\n        {\n            label: tr.importingUpdateAlways(),\n            value: ImportAnkiPackageUpdateCondition.ALWAYS,\n        },\n        {\n            label: tr.importingUpdateNever(),\n            value: ImportAnkiPackageUpdateCondition.NEVER,\n        },\n    ];\n}\n"
  },
  {
    "path": "ts/routes/import-anki-package/import-anki-package-base.scss",
    "content": "@use \"../lib/sass/bootstrap-dark\";\n\n@import \"../lib/sass/base\";\n\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/grid\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/carousel\";\n@import \"../lib/sass/bootstrap-forms\";\n@import \"../lib/sass/bootstrap-tooltip\";\n\n.night-mode {\n    @include bootstrap-dark.night-mode;\n}\n\nbody {\n    padding: 0 1em 1em 1em;\n}\n\nhtml {\n    height: initial;\n}\n"
  },
  {
    "path": "ts/routes/import-anki-package/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./import-anki-package-base.scss\";\n\nimport { getImportAnkiPackagePresets } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport { modalsKey } from \"$lib/components/context-keys\";\n\nimport ImportAnkiPackagePage from \"./ImportAnkiPackagePage.svelte\";\n\nconst i18n = setupI18n({\n    modules: [\n        ModuleName.IMPORTING,\n        ModuleName.ACTIONS,\n        ModuleName.HELP,\n        ModuleName.DECK_CONFIG,\n        ModuleName.ADDING,\n        ModuleName.EDITING,\n        ModuleName.KEYBOARD,\n    ],\n});\n\nexport async function setupImportAnkiPackagePage(\n    path: string,\n): Promise<ImportAnkiPackagePage> {\n    const [_, options] = await Promise.all([\n        i18n,\n        getImportAnkiPackagePresets({}),\n    ]);\n\n    const context = new Map();\n    context.set(modalsKey, new Map());\n    checkNightMode();\n\n    return new ImportAnkiPackagePage({\n        target: document.body,\n        props: {\n            path,\n            options,\n        },\n        context,\n    });\n}\n\n// eg http://localhost:40000/_anki/pages/import-anki-package.html#test-/home/dae/foo.apkg\nif (window.location.hash.startsWith(\"#test-\")) {\n    const apkgPath = window.location.hash.replace(\"#test-\", \"\");\n    setupImportAnkiPackagePage(apkgPath);\n}\n"
  },
  {
    "path": "ts/routes/import-csv/FieldMapper.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\n    import type { ImportCsvState } from \"./lib\";\n    import MapperRow from \"./MapperRow.svelte\";\n\n    export let state: ImportCsvState;\n\n    const metadata = state.metadata;\n    const globalNotetype = state.globalNotetype;\n    const fieldNamesPromise = state.fieldNames;\n    const columnOptions = state.columnOptions;\n</script>\n\n<TitledContainer title={tr.importingFieldMapping()}>\n    {#if $globalNotetype !== null}\n        {#await $fieldNamesPromise then fieldNames}\n            {#each fieldNames as label, idx}\n                <!-- first index is treated specially, because it must be assigned some column -->\n                <MapperRow\n                    {label}\n                    columnOptions={idx === 0 ? $columnOptions.slice(1) : $columnOptions}\n                    bind:value={$globalNotetype.fieldColumns[idx]}\n                />\n            {/each}\n        {/await}\n    {/if}\n    <MapperRow\n        label={tr.editingTags()}\n        columnOptions={$columnOptions}\n        bind:value={$metadata.tagsColumn}\n    />\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/import-csv/FileOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import SwitchRow from \"$lib/components/SwitchRow.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n\n    import { delimiterChoices } from \"./choices\";\n    import type { ImportCsvState } from \"./lib\";\n    import Preview from \"./Preview.svelte\";\n\n    export let state: ImportCsvState;\n\n    const metadata = state.metadata;\n\n    const settings = {\n        delimiter: {\n            title: tr.importingFieldSeparator(),\n            help: tr.importingFieldSeparatorHelp(),\n            url: HelpPage.TextImporting.root,\n        },\n        isHtml: {\n            title: tr.importingAllowHtmlInFields(),\n            help: tr.importingAllowHtmlInFieldsHelp(),\n            url: HelpPage.TextImporting.html,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n</script>\n\n<TitledContainer title={tr.importingFile()}>\n    <HelpModal\n        title={tr.importingFile()}\n        url={HelpPage.TextImporting.root}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n    <EnumSelectorRow\n        bind:value={$metadata.delimiter}\n        defaultValue={state.defaultDelimiter}\n        choices={delimiterChoices()}\n        disabled={$metadata.forceDelimiter}\n    >\n        <SettingTitle\n            on:click={() => openHelpModal(Object.keys(settings).indexOf(\"delimiter\"))}\n        >\n            {$metadata.forceDelimiter\n                ? settings.delimiter.title\n                : tr.importingFieldSeparatorGuessed()}\n        </SettingTitle>\n    </EnumSelectorRow>\n\n    <SwitchRow\n        bind:value={$metadata.isHtml}\n        defaultValue={state.defaultIsHtml}\n        disabled={$metadata.forceIsHtml}\n    >\n        <SettingTitle\n            on:click={() => openHelpModal(Object.keys(settings).indexOf(\"isHtml\"))}\n        >\n            {settings.isHtml.title}\n        </SettingTitle>\n    </SwitchRow>\n\n    <Preview {state} />\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/import-csv/ImportCsvPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Row from \"$lib/components/Row.svelte\";\n\n    import ImportPage from \"../import-page/ImportPage.svelte\";\n    import FieldMapper from \"./FieldMapper.svelte\";\n    import FileOptions from \"./FileOptions.svelte\";\n    import ImportOptions from \"./ImportOptions.svelte\";\n    import type { ImportCsvState } from \"./lib\";\n\n    export let state: ImportCsvState;\n</script>\n\n<ImportPage path={state.path} importer={state}>\n    <Row><FileOptions {state} /></Row>\n    <Row><ImportOptions {state} /></Row>\n    <Row><FieldMapper {state} /></Row>\n</ImportPage>\n"
  },
  {
    "path": "ts/routes/import-csv/ImportOptions.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { HelpPage } from \"@tslib/help-page\";\n    import type Carousel from \"bootstrap/js/dist/carousel\";\n    import type Modal from \"bootstrap/js/dist/modal\";\n\n    import EnumSelectorRow from \"$lib/components/EnumSelectorRow.svelte\";\n    import HelpModal from \"$lib/components/HelpModal.svelte\";\n    import SettingTitle from \"$lib/components/SettingTitle.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n    import type { HelpItem } from \"$lib/components/types\";\n    import TagsRow from \"$lib/tag-editor/TagsRow.svelte\";\n\n    import { dupeResolutionChoices, matchScopeChoices } from \"./choices\";\n    import type { ImportCsvState } from \"./lib\";\n    import Warning from \"../deck-options/Warning.svelte\";\n\n    export let state: ImportCsvState;\n\n    const metadata = state.metadata;\n    const globalNotetype = state.globalNotetype;\n    const deckId = state.deckId;\n    const deckName = state.newDeckName;\n\n    const settings = {\n        notetype: {\n            title: tr.notetypesNotetype(),\n            help: tr.importingNotetypeHelp(),\n            url: HelpPage.TextImporting.root,\n        },\n        deck: {\n            title: tr.decksDeck(),\n            help: tr.importingDeckHelp(),\n            url: HelpPage.TextImporting.root,\n        },\n        dupeResolution: {\n            title: tr.importingExistingNotes(),\n            help: tr.importingExistingNotesHelp(),\n            url: HelpPage.TextImporting.updating,\n        },\n        matchScope: {\n            title: tr.importingMatchScope(),\n            help: tr.importingMatchScopeHelp(),\n            url: HelpPage.TextImporting.updating,\n        },\n        globalTags: {\n            title: tr.importingTagAllNotes(),\n            help: tr.importingTagAllNotesHelp(),\n            url: HelpPage.TextImporting.root,\n        },\n        updatedTags: {\n            title: tr.importingTagUpdatedNotes(),\n            help: tr.importingTagUpdatedNotesHelp(),\n            url: HelpPage.TextImporting.root,\n        },\n    };\n    const helpSections: HelpItem[] = Object.values(settings);\n    let modal: Modal;\n    let carousel: Carousel;\n\n    function openHelpModal(index: number): void {\n        modal.show();\n        carousel.to(index);\n    }\n\n    const choices = state.deckNameIds.map(({ id, name }) => {\n        return { label: name, value: id };\n    });\n\n    if (deckName) {\n        choices.push({\n            label: deckName,\n            value: 0n,\n        });\n    }\n\n    $: newDeckCreationNotice =\n        deckName && $deckId === 0n\n            ? tr.importingNewDeckWillBeCreated({ name: deckName })\n            : \"\";\n</script>\n\n<TitledContainer title={tr.importingImportOptions()}>\n    <HelpModal\n        title={tr.importingImportOptions()}\n        url={HelpPage.TextImporting.root}\n        slot=\"tooltip\"\n        {helpSections}\n        on:mount={(e) => {\n            modal = e.detail.modal;\n            carousel = e.detail.carousel;\n        }}\n    />\n\n    {#if $globalNotetype !== null}\n        <EnumSelectorRow\n            bind:value={$globalNotetype.id}\n            defaultValue={state.defaultNotetypeId}\n            choices={state.notetypeNameIds.map(({ id, name }) => {\n                return { label: name, value: id };\n            })}\n        >\n            <SettingTitle\n                on:click={() =>\n                    openHelpModal(Object.keys(settings).indexOf(\"notetype\"))}\n            >\n                {settings.notetype.title}\n            </SettingTitle>\n        </EnumSelectorRow>\n    {/if}\n\n    {#if deckName || $deckId}\n        <EnumSelectorRow\n            bind:value={$deckId}\n            defaultValue={state.defaultDeckId}\n            {choices}\n        >\n            <SettingTitle\n                on:click={() => openHelpModal(Object.keys(settings).indexOf(\"deck\"))}\n            >\n                {settings.deck.title}\n            </SettingTitle>\n        </EnumSelectorRow>\n    {/if}\n\n    <Warning warning={newDeckCreationNotice} className=\"alert-info\" />\n\n    <EnumSelectorRow\n        bind:value={$metadata.dupeResolution}\n        defaultValue={0}\n        choices={dupeResolutionChoices()}\n    >\n        <SettingTitle\n            on:click={() =>\n                openHelpModal(Object.keys(settings).indexOf(\"dupeResolution\"))}\n        >\n            {settings.dupeResolution.title}\n        </SettingTitle>\n    </EnumSelectorRow>\n\n    <EnumSelectorRow\n        bind:value={$metadata.matchScope}\n        defaultValue={0}\n        choices={matchScopeChoices()}\n    >\n        <SettingTitle\n            on:click={() => openHelpModal(Object.keys(settings).indexOf(\"matchScope\"))}\n        >\n            {settings.matchScope.title}\n        </SettingTitle>\n    </EnumSelectorRow>\n\n    <TagsRow bind:tags={$metadata.globalTags} keyCombination={\"Control+T\"}>\n        <SettingTitle\n            on:click={() => openHelpModal(Object.keys(settings).indexOf(\"globalTags\"))}\n        >\n            {settings.globalTags.title}\n        </SettingTitle>\n    </TagsRow>\n\n    <TagsRow bind:tags={$metadata.updatedTags} keyCombination={\"Control+Shift+T\"}>\n        <SettingTitle\n            on:click={() => openHelpModal(Object.keys(settings).indexOf(\"updatedTags\"))}\n        >\n            {settings.updatedTags.title}\n        </SettingTitle>\n    </TagsRow>\n</TitledContainer>\n"
  },
  {
    "path": "ts/routes/import-csv/MapperRow.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Col from \"$lib/components/Col.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import Select from \"$lib/components/Select.svelte\";\n\n    import type { ColumnOption } from \"./lib\";\n\n    let rowLabel: string;\n    export { rowLabel as label };\n\n    export let columnOptions: ColumnOption[];\n    export let value: number;\n\n    $: label = columnOptions.find((o) => o.value === value)?.label;\n</script>\n\n<Row --cols={2}>\n    <Col --col-size={1}>\n        {rowLabel}\n    </Col>\n    <Col --col-size={1}>\n        <Select\n            bind:value\n            {label}\n            list={columnOptions}\n            parser={(item) => ({\n                content: item.label,\n                value: item.value,\n                disabled: item.disabled,\n            })}\n        />\n    </Col>\n</Row>\n"
  },
  {
    "path": "ts/routes/import-csv/Preview.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import Warning from \"../deck-options/Warning.svelte\";\n    import { type ImportCsvState } from \"./lib\";\n    import * as tr from \"@generated/ftl\";\n\n    export let state: ImportCsvState;\n    export let maxColumns = 1000;\n\n    const metadata = state.metadata;\n    const columnOptions = state.columnOptions;\n\n    let rows: string[][];\n    let truncated = false;\n\n    function sanitisePreview(preview: typeof $metadata.preview) {\n        let truncated = false;\n        const rows = preview.map((x) => {\n            if (x.vals.length > maxColumns) {\n                truncated = true;\n                return x.vals.slice(0, maxColumns);\n            }\n            return x.vals;\n        });\n        return { rows, truncated };\n    }\n\n    $: ({ rows, truncated } = sanitisePreview($metadata.preview));\n\n    $: warning = truncated ? tr.importingPreviewTruncated({ count: maxColumns }) : \"\";\n</script>\n\n<div class=\"outer\">\n    <table class=\"preview\">\n        <thead>\n            <tr>\n                {#each $columnOptions.slice(1) as { label, shortLabel }}\n                    <th>\n                        {shortLabel || label}\n                    </th>\n                {/each}\n            </tr>\n        </thead>\n        <tbody>\n            {#each rows as row}\n                <tr>\n                    {#each row as cell}\n                        <td>{cell}</td>\n                    {/each}\n                </tr>\n            {/each}\n        </tbody>\n    </table>\n</div>\n<Warning {warning} />\n\n<style lang=\"scss\">\n    .outer {\n        overflow: auto;\n        margin-bottom: 0.5rem;\n    }\n\n    .preview {\n        border-collapse: collapse;\n        white-space: nowrap;\n\n        th,\n        td {\n            text-overflow: ellipsis;\n            overflow: hidden;\n            border: 1px solid var(--border-subtle);\n            padding: 0.25rem 0.5rem;\n            max-width: 15em;\n        }\n\n        th {\n            background: var(--border);\n            text-align: center;\n        }\n\n        tr {\n            &:nth-child(even) {\n                background: var(--canvas);\n            }\n        }\n\n        td {\n            text-align: start;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-csv/[...path]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import ImportCsvPage from \"../ImportCsvPage.svelte\";\n    import type { PageData } from \"./$types\";\n\n    export let data: PageData;\n</script>\n\n<ImportCsvPage state={data.state} />\n"
  },
  {
    "path": "ts/routes/import-csv/[...path]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport { getCsvMetadata, getDeckNames, getNotetypeNames } from \"@generated/backend\";\n\nimport { ImportCsvState } from \"../lib\";\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async ({ params }) => {\n    const [notetypes, decks, metadata] = await Promise.all([\n        getNotetypeNames({}),\n        getDeckNames({\n            skipEmptyDefault: false,\n            includeFiltered: false,\n        }),\n        getCsvMetadata({ path: params.path }, { alertOnError: false }),\n    ]);\n    const state = new ImportCsvState(params.path, notetypes, decks, metadata);\n    return { state };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/import-csv/choices.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport {\n    CsvMetadata_Delimiter,\n    CsvMetadata_DupeResolution,\n    CsvMetadata_MatchScope,\n} from \"@generated/anki/import_export_pb\";\nimport * as tr from \"@generated/ftl\";\n\nimport type { Choice } from \"$lib/components/EnumSelector.svelte\";\n\nexport function delimiterChoices(): Choice<CsvMetadata_Delimiter>[] {\n    return [\n        {\n            label: tr.importingTab(),\n            value: CsvMetadata_Delimiter.TAB,\n        },\n        {\n            label: tr.importingPipe(),\n            value: CsvMetadata_Delimiter.PIPE,\n        },\n        {\n            label: tr.importingSemicolon(),\n            value: CsvMetadata_Delimiter.SEMICOLON,\n        },\n        {\n            label: tr.importingColon(),\n            value: CsvMetadata_Delimiter.COLON,\n        },\n        {\n            label: tr.importingComma(),\n            value: CsvMetadata_Delimiter.COMMA,\n        },\n        {\n            label: tr.studyingSpace(),\n            value: CsvMetadata_Delimiter.SPACE,\n        },\n    ];\n}\n\nexport function dupeResolutionChoices(): Choice<CsvMetadata_DupeResolution>[] {\n    return [\n        { label: tr.importingUpdate(), value: CsvMetadata_DupeResolution.UPDATE },\n        { label: tr.importingPreserve(), value: CsvMetadata_DupeResolution.PRESERVE },\n        { label: tr.importingDuplicate(), value: CsvMetadata_DupeResolution.DUPLICATE },\n    ];\n}\n\nexport function matchScopeChoices(): Choice<CsvMetadata_MatchScope>[] {\n    return [\n        { label: tr.notetypesNotetype(), value: CsvMetadata_MatchScope.NOTETYPE },\n        { label: tr.importingNotetypeAndDeck(), value: CsvMetadata_MatchScope.NOTETYPE_AND_DECK },\n    ];\n}\n"
  },
  {
    "path": "ts/routes/import-csv/import-csv-base.scss",
    "content": "@use \"../lib/sass/bootstrap-dark\";\n\n@import \"../lib/sass/base\";\n\n@import \"bootstrap/scss/alert\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/button-group\";\n@import \"bootstrap/scss/close\";\n@import \"bootstrap/scss/grid\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/carousel\";\n@import \"../lib/sass/bootstrap-forms\";\n@import \"../lib/sass/bootstrap-tooltip\";\n\n.night-mode {\n    @include bootstrap-dark.night-mode;\n}\n\nbody {\n    padding: 0 1em 1em 1em;\n}\n\nhtml {\n    height: initial;\n}\n"
  },
  {
    "path": "ts/routes/import-csv/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./import-csv-base.scss\";\n\nimport { getCsvMetadata, getDeckNames, getNotetypeNames } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport { modalsKey } from \"$lib/components/context-keys\";\nimport ErrorPage from \"$lib/components/ErrorPage.svelte\";\n\nimport ImportCsvPage from \"./ImportCsvPage.svelte\";\nimport { ImportCsvState } from \"./lib\";\n\nconst i18n = setupI18n({\n    modules: [\n        ModuleName.ACTIONS,\n        ModuleName.CHANGE_NOTETYPE,\n        ModuleName.DECKS,\n        ModuleName.EDITING,\n        ModuleName.IMPORTING,\n        ModuleName.KEYBOARD,\n        ModuleName.NOTETYPES,\n        ModuleName.STUDYING,\n        ModuleName.ADDING,\n        ModuleName.HELP,\n        ModuleName.DECK_CONFIG,\n    ],\n});\n\nexport async function setupImportCsvPage(path: string): Promise<ImportCsvPage | ErrorPage> {\n    const context = new Map();\n    context.set(modalsKey, new Map());\n    checkNightMode();\n\n    return Promise.all([\n        getNotetypeNames({}),\n        getDeckNames({\n            skipEmptyDefault: false,\n            includeFiltered: false,\n        }),\n        getCsvMetadata({ path }, { alertOnError: false }),\n        i18n,\n    ]).then(([notetypes, decks, metadata]) => {\n        return new ImportCsvPage({\n            target: document.body,\n            props: {\n                state: new ImportCsvState(path, notetypes, decks, metadata),\n            },\n            context,\n        });\n    }).catch((error) => {\n        return new ErrorPage({ target: document.body, props: { error } });\n    });\n}\n\n/* // use #testXXXX where XXXX is notetype ID to test\nif (window.location.hash.startsWith(\"#test\")) {\n    const ntid = parseInt(window.location.hash.substr(\"#test\".length), 10);\n    setupCsvImportPage(ntid, ntid);\n} */\n"
  },
  {
    "path": "ts/routes/import-csv/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { DeckNameId, DeckNames } from \"@generated/anki/decks_pb\";\nimport type { CsvMetadata, CsvMetadata_Delimiter, ImportResponse } from \"@generated/anki/import_export_pb\";\nimport { type CsvMetadata_MappedNotetype } from \"@generated/anki/import_export_pb\";\nimport type { NotetypeNameId, NotetypeNames } from \"@generated/anki/notetypes_pb\";\nimport { getCsvMetadata, getFieldNames, importCsv } from \"@generated/backend\";\nimport * as tr from \"@generated/ftl\";\nimport { cloneDeep, isEqual, noop } from \"lodash-es\";\nimport type { Readable, Writable } from \"svelte/store\";\nimport { readable, writable } from \"svelte/store\";\n\nexport interface ColumnOption {\n    label: string;\n    shortLabel?: string;\n    value: number;\n    disabled: boolean;\n}\n\nexport function getGlobalNotetype(meta: CsvMetadata): CsvMetadata_MappedNotetype | null {\n    return meta.notetype.case === \"globalNotetype\" ? meta.notetype.value : null;\n}\n\nexport function getDeckId(meta: CsvMetadata): bigint {\n    return meta.deck.case === \"deckId\" ? meta.deck.value : 0n;\n}\n\nexport function getDeckName(meta: CsvMetadata): string | null {\n    return meta.deck.case === \"deckName\" ? meta.deck.value : null;\n}\n\nexport class ImportCsvState {\n    readonly path: string;\n    readonly deckNameIds: DeckNameId[];\n    readonly notetypeNameIds: NotetypeNameId[];\n\n    readonly defaultDelimiter: CsvMetadata_Delimiter;\n    readonly defaultIsHtml: boolean;\n    readonly defaultNotetypeId: bigint | null;\n    readonly defaultDeckId: bigint | null;\n    readonly newDeckName: string | null;\n\n    readonly metadata: Writable<CsvMetadata>;\n    readonly globalNotetype: Writable<CsvMetadata_MappedNotetype | null>;\n    readonly deckId: Writable<bigint | null>;\n    readonly fieldNames: Readable<Promise<string[]>>;\n    readonly columnOptions: Readable<ColumnOption[]>;\n\n    private lastMetadata: CsvMetadata;\n    private lastGlobalNotetype: CsvMetadata_MappedNotetype | null;\n    private lastDeckId: bigint | null;\n    private fieldNamesSetter: (val: Promise<string[]>) => void = noop;\n    private columnOptionsSetter: (val: ColumnOption[]) => void = noop;\n\n    constructor(path: string, notetypes: NotetypeNames, decks: DeckNames, metadata: CsvMetadata) {\n        this.path = path;\n        this.deckNameIds = decks.entries;\n        this.notetypeNameIds = notetypes.entries;\n\n        this.lastMetadata = cloneDeep(metadata);\n        this.metadata = writable(metadata);\n        this.metadata.subscribe(this.onMetadataChanged.bind(this));\n\n        const globalNotetype = getGlobalNotetype(metadata);\n        this.lastGlobalNotetype = cloneDeep(getGlobalNotetype(metadata));\n        this.globalNotetype = writable(cloneDeep(globalNotetype));\n        this.globalNotetype.subscribe(this.onGlobalNotetypeChanged.bind(this));\n\n        this.lastDeckId = getDeckId(metadata);\n        this.deckId = writable(getDeckId(metadata));\n        this.deckId.subscribe(this.onDeckIdChanged.bind(this));\n\n        this.fieldNames = readable(\n            globalNotetype === null\n                ? Promise.resolve([])\n                : getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals),\n            (set) => {\n                this.fieldNamesSetter = set;\n            },\n        );\n\n        this.columnOptions = readable(getColumnOptions(metadata), (set) => {\n            this.columnOptionsSetter = set;\n        });\n\n        this.defaultDelimiter = metadata.delimiter;\n        this.defaultIsHtml = metadata.isHtml;\n        this.defaultNotetypeId = this.lastGlobalNotetype?.id || null;\n        this.defaultDeckId = this.lastDeckId;\n        this.newDeckName = getDeckName(metadata);\n    }\n\n    doImport(): Promise<ImportResponse> {\n        return importCsv({\n            path: this.path,\n            metadata: { ...this.lastMetadata, preview: [] },\n        }, { alertOnError: false });\n    }\n\n    private async onMetadataChanged(changed: CsvMetadata) {\n        if (isEqual(changed, this.lastMetadata)) {\n            return;\n        }\n\n        const shouldRefetchMetadata = this.shouldRefetchMetadata(changed);\n        if (shouldRefetchMetadata) {\n            const { globalTags, updatedTags } = changed;\n            changed = await getCsvMetadata({\n                path: this.path,\n                delimiter: changed.delimiter,\n                notetypeId: getGlobalNotetype(changed)?.id,\n                deckId: getDeckId(changed) || undefined,\n                isHtml: changed.isHtml,\n            });\n            // carry over tags\n            changed.globalTags = globalTags;\n            changed.updatedTags = updatedTags;\n        }\n\n        const globalNotetype = getGlobalNotetype(changed);\n        this.globalNotetype.set(globalNotetype);\n        if (globalNotetype !== null && globalNotetype.id !== getGlobalNotetype(this.lastMetadata)?.id) {\n            this.fieldNamesSetter(getFieldNames({ ntid: globalNotetype.id }).then((list) => list.vals));\n        }\n        if (this.shouldRebuildColumnOptions(changed)) {\n            this.columnOptionsSetter(getColumnOptions(changed));\n        }\n\n        this.lastMetadata = cloneDeep(changed);\n        if (shouldRefetchMetadata) {\n            this.metadata.set(changed);\n        }\n    }\n\n    private shouldRefetchMetadata(changed: CsvMetadata): boolean {\n        return changed.delimiter !== this.lastMetadata.delimiter || changed.isHtml !== this.lastMetadata.isHtml\n            || getGlobalNotetype(changed)?.id !== getGlobalNotetype(this.lastMetadata)?.id;\n    }\n\n    private shouldRebuildColumnOptions(changed: CsvMetadata): boolean {\n        return !isEqual(changed.columnLabels, this.lastMetadata.columnLabels)\n            || !isEqual(changed.preview[0], this.lastMetadata.preview[0]);\n    }\n\n    private onGlobalNotetypeChanged(globalNotetype: CsvMetadata_MappedNotetype | null) {\n        if (isEqual(globalNotetype, this.lastGlobalNotetype)) {\n            return;\n        }\n        this.lastGlobalNotetype = cloneDeep(globalNotetype);\n        if (globalNotetype !== null) {\n            this.metadata.update((metadata) => {\n                metadata.notetype.value = globalNotetype;\n                return metadata;\n            });\n        }\n    }\n\n    private onDeckIdChanged(deckId: bigint | null) {\n        if (deckId === this.lastDeckId) {\n            return;\n        }\n        this.lastDeckId = deckId;\n        if (deckId !== null) {\n            this.metadata.update((metadata) => {\n                if (deckId !== 0n) {\n                    metadata.deck.case = \"deckId\";\n                    metadata.deck.value = deckId;\n                } else {\n                    metadata.deck.case = \"deckName\";\n                    metadata.deck.value = this.newDeckName!;\n                }\n                return metadata;\n            });\n        }\n    }\n}\n\nfunction getColumnOptions(\n    metadata: CsvMetadata,\n): ColumnOption[] {\n    const notetypeColumn = getNotetypeColumn(metadata);\n    const deckColumn = getDeckColumn(metadata);\n    return [{ label: tr.changeNotetypeNothing(), value: 0, disabled: false }].concat(\n        metadata.columnLabels.map((label, index) => {\n            index += 1;\n            if (index === notetypeColumn) {\n                return columnOption(tr.notetypesNotetype(), true, index);\n            } else if (index === deckColumn) {\n                return columnOption(tr.decksDeck(), true, index);\n            } else if (index === metadata.guidColumn) {\n                return columnOption(\"GUID\", true, index);\n            } else if (label === \"\") {\n                return columnOption(metadata.preview[0].vals[index - 1], false, index, true);\n            } else {\n                return columnOption(label, false, index);\n            }\n        }),\n    );\n}\n\nfunction columnOption(\n    label: string,\n    disabled: boolean,\n    index: number,\n    shortLabel?: boolean,\n): ColumnOption {\n    return {\n        label: label ? `${index}: ${label}` : index.toString(),\n        shortLabel: shortLabel ? index.toString() : undefined,\n        value: index,\n        disabled,\n    };\n}\n\nfunction getDeckColumn(meta: CsvMetadata): number | null {\n    return meta.deck.case === \"deckColumn\" ? meta.deck.value : null;\n}\n\nfunction getNotetypeColumn(meta: CsvMetadata): number | null {\n    return meta.notetype.case === \"notetypeColumn\" ? meta.notetype.value : null;\n}\n"
  },
  {
    "path": "ts/routes/import-page/DetailsTable.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconButton from \"$lib/components/IconButton.svelte\";\n    import { magnifyIcon } from \"$lib/components/icons\";\n    import VirtualTable from \"$lib/components/VirtualTable.svelte\";\n\n    import { getRows, showInBrowser } from \"./lib\";\n    import TableCellWithTooltip from \"./TableCellWithTooltip.svelte\";\n    import type { SummarizedLogQueues } from \"./types\";\n\n    export let summaries: SummarizedLogQueues[];\n    export let bottomOffset: number = 0;\n\n    let bottom: HTMLElement;\n    $: rows = getRows(summaries);\n</script>\n\n<div bind:this={bottom}>\n    {#if bottom}\n        <VirtualTable\n            class=\"details-table\"\n            itemHeight={40}\n            itemsCount={rows.length}\n            {bottomOffset}\n        >\n            <tr slot=\"headers\">\n                <th>#</th>\n                <th>{tr.importingStatus()}</th>\n                <th>{tr.editingFields()}</th>\n                <th></th>\n            </tr>\n            <svelte:fragment slot=\"row\" let:index>\n                <tr>\n                    <td class=\"index-cell\">{index + 1}</td>\n                    <TableCellWithTooltip\n                        class=\"status-cell\"\n                        tooltip={rows[index].queue.reason}\n                    >\n                        {rows[index].summary.action}\n                    </TableCellWithTooltip>\n                    <TableCellWithTooltip\n                        class=\"contents-cell\"\n                        tooltip={rows[index].note.fields.join(\",\")}\n                    >\n                        {rows[index].note.fields.join(\",\")}\n                    </TableCellWithTooltip>\n                    <td class=\"search-cell\">\n                        <IconButton\n                            class=\"search-icon\"\n                            iconSize={100}\n                            active={false}\n                            disabled={!rows[index].summary.canBrowse}\n                            on:click={() => {\n                                showInBrowser([rows[index].note]);\n                            }}\n                        >\n                            <Icon icon={magnifyIcon} />\n                        </IconButton>\n                    </td>\n                </tr>\n            </svelte:fragment>\n        </VirtualTable>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    :global(.details-table) {\n        margin: 0 auto;\n        width: 100%;\n\n        :global(.search-icon) {\n            border: none !important;\n            background: transparent !important;\n        }\n        tr {\n            height: 40px;\n            text-align: center;\n        }\n        .index-cell {\n            width: 3em;\n        }\n        :global(.status-cell) {\n            width: 5em;\n        }\n        :global(.contents-cell) {\n            text-align: left;\n        }\n        :global(.search-cell) {\n            width: 3em;\n        }\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-page/ImportLogPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import type { ImportResponse } from \"@generated/anki/import_export_pb\";\n    import * as tr from \"@generated/ftl\";\n\n    import Container from \"$lib/components/Container.svelte\";\n    import Row from \"$lib/components/Row.svelte\";\n    import TitledContainer from \"$lib/components/TitledContainer.svelte\";\n\n    import DetailsTable from \"./DetailsTable.svelte\";\n    import { getSummaries } from \"./lib\";\n    import QueueSummary from \"./QueueSummary.svelte\";\n\n    export let response: ImportResponse;\n    $: summaries = getSummaries(response.log!);\n    $: foundNotes = response.log?.foundNotes ?? 0;\n\n    const gutterBlockSize = 0.5;\n    const computedStyle = getComputedStyle(document.documentElement);\n    const rootFontSize = parseInt(computedStyle.fontSize);\n    // Container padding + Row padding + Row margin + TitledContainer padding\n    const bottomOffset = (3 * gutterBlockSize + 0.75) * rootFontSize;\n</script>\n\n<Container\n    breakpoint=\"sm\"\n    --gutter-inline=\"0.25rem\"\n    --gutter-block={`${gutterBlockSize}rem`}\n>\n    <Row>\n        <TitledContainer title={tr.importingOverview()}>\n            <p>\n                {tr.importingNotesFoundInFile2({\n                    notes: foundNotes,\n                })}\n            </p>\n            <ul>\n                {#each summaries as summary}\n                    <QueueSummary {summary} />\n                {/each}\n            </ul>\n        </TitledContainer>\n    </Row>\n    <Row>\n        <TitledContainer title={tr.importingDetails()}>\n            <DetailsTable {summaries} {bottomOffset} />\n        </TitledContainer>\n    </Row>\n</Container>\n"
  },
  {
    "path": "ts/routes/import-page/ImportPage.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script context=\"module\" lang=\"ts\">\n    export interface Importer {\n        doImport: () => Promise<ImportResponse>;\n    }\n</script>\n\n<script lang=\"ts\">\n    import type { ImportResponse } from \"@generated/anki/import_export_pb\";\n    import { importDone } from \"@generated/backend\";\n\n    import BackendProgressIndicator from \"$lib/components/BackendProgressIndicator.svelte\";\n    import Container from \"$lib/components/Container.svelte\";\n    import ErrorPage from \"$lib/components/ErrorPage.svelte\";\n    import StickyHeader from \"./StickyHeader.svelte\";\n\n    import ImportLogPage from \"./ImportLogPage.svelte\";\n\n    export let path: string;\n    export let importer: Importer;\n    export const noOptions: boolean = false;\n\n    let importResponse: ImportResponse | undefined = undefined;\n    let error: Error | undefined = undefined;\n    let importing = noOptions;\n\n    async function onImport(): Promise<ImportResponse> {\n        const result = await importer.doImport();\n        await importDone({});\n        importing = false;\n        return result;\n    }\n</script>\n\n{#if error}\n    <ErrorPage {error} />\n{:else if importResponse}\n    <ImportLogPage response={importResponse} />\n{:else if importing}\n    <BackendProgressIndicator task={onImport} bind:result={importResponse} bind:error />\n{:else}\n    <div class=\"pre-import-page\">\n        <StickyHeader {path} onImport={() => (importing = true)} />\n        <Container\n            breakpoint=\"sm\"\n            --gutter-inline=\"0.25rem\"\n            --gutter-block=\"0.5rem\"\n            class=\"container-columns\"\n        >\n            <slot />\n        </Container>\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    :global(.row) {\n        // rows have negative margins by default\n        --bs-gutter-x: 0;\n        margin-bottom: 0.5rem;\n    }\n\n    .pre-import-page {\n        margin: 0 auto;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-page/QueueSummary.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n\n    import Icon from \"$lib/components/Icon.svelte\";\n    import IconConstrain from \"$lib/components/IconConstrain.svelte\";\n\n    import { showInBrowser } from \"./lib\";\n    import type { SummarizedLogQueues } from \"./types\";\n\n    export let summary: SummarizedLogQueues;\n\n    $: notes = summary.queues.map((queue) => queue.notes).flat();\n\n    function onShow(event: MouseEvent) {\n        showInBrowser(notes);\n        event.preventDefault();\n    }\n</script>\n\n{#if notes.length}\n    <li>\n        <IconConstrain>\n            <Icon icon={summary.icon} />\n        </IconConstrain>\n        {summary.summaryTemplate({ count: notes.length })}\n        {#if summary.canBrowse}\n            <button class=\"desktop-only\" on:click={onShow}>{tr.importingShow()}</button>\n        {/if}\n    </li>\n{/if}\n\n<style lang=\"scss\">\n    li {\n        list-style-type: none;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-page/StickyHeader.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import * as tr from \"@generated/ftl\";\n    import { getPlatformString } from \"@tslib/shortcuts\";\n\n    import ButtonToolbar from \"$lib/components/ButtonToolbar.svelte\";\n    import LabelButton from \"$lib/components/LabelButton.svelte\";\n    import Shortcut from \"$lib/components/Shortcut.svelte\";\n    import StickyContainer from \"$lib/components/StickyContainer.svelte\";\n\n    export let path: string;\n    export let onImport: () => void;\n\n    const keyCombination = \"Control+Enter\";\n\n    function basename(path: String): String {\n        return path.split(/[\\\\/]/).pop()!;\n    }\n</script>\n\n<StickyContainer\n    --gutter-block=\"0.5rem\"\n    --gutter-inline=\"0.25rem\"\n    --sticky-borders=\"0 0 1px\"\n    breakpoint=\"sm\"\n>\n    <ButtonToolbar class=\"justify-content-between\" wrap={false}>\n        <div class=\"filename\">{basename(path)}</div>\n        <LabelButton\n            primary\n            tooltip={getPlatformString(keyCombination)}\n            on:click={onImport}\n            --border-left-radius=\"5px\"\n            --border-right-radius=\"5px\"\n        >\n            <div class=\"import\">{tr.actionsImport()}</div>\n        </LabelButton>\n        <Shortcut {keyCombination} on:action={onImport} />\n    </ButtonToolbar>\n</StickyContainer>\n\n<style lang=\"scss\">\n    .import {\n        margin: 0.2rem 0.75rem;\n    }\n\n    .filename {\n        word-break: break-word;\n    }\n</style>\n"
  },
  {
    "path": "ts/routes/import-page/TableCell.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { createEventDispatcher, onMount } from \"svelte\";\n\n    let className = \"\";\n    export { className as class };\n\n    const dispatch = createEventDispatcher();\n    let element: HTMLElement;\n\n    onMount(async () => {\n        dispatch(\"mount\", { element });\n    });\n</script>\n\n<td bind:this={element} class={className}>\n    <slot />\n</td>\n"
  },
  {
    "path": "ts/routes/import-page/TableCellWithTooltip.svelte",
    "content": "<!--\n    Copyright: Ankitects Pty Ltd and contributors\n    License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import WithTooltip from \"$lib/components/WithTooltip.svelte\";\n\n    import TableCell from \"./TableCell.svelte\";\n\n    let className = \"\";\n    export { className as class };\n    export let tooltip: string;\n</script>\n\n<WithTooltip {tooltip} let:createTooltip>\n    <TableCell\n        class={className}\n        on:mount={(event) => createTooltip(event.detail.element)}\n    >\n        <slot />\n    </TableCell>\n</WithTooltip>\n"
  },
  {
    "path": "ts/routes/import-page/[...path]/+page.svelte",
    "content": "<!--\nCopyright: Ankitects Pty Ltd and contributors\nLicense: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n-->\n<script lang=\"ts\">\n    import { importJsonFile } from \"@generated/backend\";\n\n    import ImportPage, { type Importer } from \"../ImportPage.svelte\";\n    import type { PageData } from \"./$types\";\n\n    export let data: PageData;\n\n    const importer: Importer = {\n        doImport: () => importJsonFile({ val: data.path }, {}),\n    };\n</script>\n\n<ImportPage path={data.path} {importer} />\n"
  },
  {
    "path": "ts/routes/import-page/[...path]/+page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport type { PageLoad } from \"./$types\";\n\nexport const load = (async ({ params }) => {\n    return { path: params.path };\n}) satisfies PageLoad;\n"
  },
  {
    "path": "ts/routes/import-page/import-page-base.scss",
    "content": "@use \"../lib/sass/bootstrap-dark\";\n\n@import \"../lib/sass/base\";\n\n@import \"../lib/sass/bootstrap-tooltip\";\n@import \"bootstrap/scss/buttons\";\n\n.night-mode {\n    @include bootstrap-dark.night-mode;\n}\n\nbody {\n    padding: 0 1em 1em 1em;\n}\n\nhtml {\n    height: initial;\n}\n"
  },
  {
    "path": "ts/routes/import-page/index.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport \"./import-page-base.scss\";\n\nimport { importJsonFile, importJsonString } from \"@generated/backend\";\nimport { ModuleName, setupI18n } from \"@tslib/i18n\";\nimport { checkNightMode } from \"@tslib/nightmode\";\n\nimport ImportPage from \"./ImportPage.svelte\";\nimport type { LogParams } from \"./types\";\n\nconst i18n = setupI18n({\n    modules: [\n        ModuleName.IMPORTING,\n        ModuleName.ADDING,\n        ModuleName.EDITING,\n        ModuleName.ACTIONS,\n        ModuleName.KEYBOARD,\n    ],\n});\n\nconst postOptions = { alertOnError: false };\n\nexport async function setupImportPage(\n    params: LogParams,\n): Promise<ImportPage> {\n    await i18n;\n\n    checkNightMode();\n\n    return new ImportPage({\n        target: document.body,\n        props: {\n            path: params.path,\n            noOptions: true,\n            importer: {\n                doImport: () => {\n                    switch (params.type) {\n                        case \"json_file\":\n                            return importJsonFile({ val: params.path }, postOptions);\n                        case \"json_string\":\n                            return importJsonString({ val: params.json }, postOptions);\n                    }\n                },\n            },\n        },\n    });\n}\n\nif (window.location.hash.startsWith(\"#test-\")) {\n    const path = window.location.hash.replace(\"#test-\", \"\");\n    setupImportPage({ type: \"json_file\", path });\n}\n"
  },
  {
    "path": "ts/routes/import-page/lib.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { ImportResponse_Log, ImportResponse_Note } from \"@generated/anki/import_export_pb\";\nimport { CsvMetadata_DupeResolution } from \"@generated/anki/import_export_pb\";\nimport { searchInBrowser } from \"@generated/backend\";\nimport * as tr from \"@generated/ftl\";\n\nimport { checkCircle, closeBox, newBox, updateIcon } from \"$lib/components/icons\";\n\nimport type { LogQueue, NoteRow, SummarizedLogQueues } from \"./types\";\n\nfunction getFirstFieldQueue(log: ImportResponse_Log): {\n    action: string;\n    queue: LogQueue;\n} {\n    let reason: string;\n    let action: string;\n    if (log.dupeResolution === CsvMetadata_DupeResolution.DUPLICATE) {\n        reason = tr.importingDuplicateNoteAdded();\n        action = tr.importingAdded();\n    } else if (log.dupeResolution === CsvMetadata_DupeResolution.PRESERVE) {\n        reason = tr.importingExistingNoteSkipped();\n        action = tr.importingSkipped();\n    } else {\n        reason = tr.importingNoteUpdatedAsFileHadNewer();\n        action = tr.importingUpdated();\n    }\n    const queue: LogQueue = {\n        reason,\n        notes: log.firstFieldMatch,\n    };\n    return { action, queue };\n}\n\nexport function getSummaries(log: ImportResponse_Log): SummarizedLogQueues[] {\n    const summarizedQueues = [\n        {\n            queues: [\n                {\n                    notes: log.new,\n                    reason: tr.importingAddedNewNote(),\n                },\n            ],\n            action: tr.addingAdded(),\n            summaryTemplate: tr.importingNotesAdded,\n            canBrowse: true,\n            icon: newBox,\n        },\n        {\n            queues: [\n                {\n                    notes: log.duplicate,\n                    reason: tr.importingExistingNoteSkipped(),\n                },\n            ],\n            action: tr.importingSkipped(),\n            summaryTemplate: tr.importingExistingNotesSkipped,\n            canBrowse: true,\n            icon: checkCircle,\n        },\n        {\n            queues: [\n                {\n                    notes: log.updated,\n                    reason: tr.importingNoteUpdatedAsFileHadNewer(),\n                },\n            ],\n            action: tr.importingUpdated(),\n            summaryTemplate: tr.importingNotesUpdated,\n            canBrowse: true,\n            icon: updateIcon,\n        },\n        {\n            queues: [\n                {\n                    notes: log.conflicting,\n                    reason: tr.importingNoteSkippedUpdateDueToNotetype2(),\n                },\n                {\n                    notes: log.missingNotetype,\n                    reason: tr.importingNoteSkippedDueToMissingNotetype(),\n                },\n                {\n                    notes: log.missingDeck,\n                    reason: tr.importingNoteSkippedDueToMissingDeck(),\n                },\n                {\n                    notes: log.emptyFirstField,\n                    reason: tr.importingNoteSkippedDueToEmptyFirstField(),\n                },\n            ],\n            action: tr.importingSkipped(),\n            summaryTemplate: tr.importingNotesFailed,\n            canBrowse: false,\n            icon: closeBox,\n        },\n    ];\n    const firstFieldQueue = getFirstFieldQueue(log);\n    for (const summary of summarizedQueues) {\n        if (summary.action === firstFieldQueue.action) {\n            summary.queues.push(firstFieldQueue.queue);\n            break;\n        }\n    }\n    return summarizedQueues;\n}\n\nexport function getRows(summaries: SummarizedLogQueues[]): NoteRow[] {\n    const rows: NoteRow[] = [];\n    for (const summary of summaries) {\n        for (const queue of summary.queues) {\n            if (queue.notes) {\n                for (const note of queue.notes) {\n                    rows.push({ summary, queue, note });\n                }\n            }\n        }\n    }\n    return rows;\n}\n\nexport function showInBrowser(notes: ImportResponse_Note[]): void {\n    searchInBrowser({\n        filter: {\n            case: \"nids\",\n            value: { ids: notes.map((note) => note.id!.nid) },\n        },\n    });\n}\n"
  },
  {
    "path": "ts/routes/import-page/types.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport type { ImportResponse_Note } from \"@generated/anki/import_export_pb\";\n\nimport type { IconData } from \"$lib/components/types\";\n\nexport type LogQueue = {\n    notes: ImportResponse_Note[];\n    reason: string;\n};\n\nexport type SummarizedLogQueues = {\n    queues: LogQueue[];\n    action: string;\n    summaryTemplate: (args: { count: number }) => string;\n    canBrowse: boolean;\n    icon: IconData;\n};\n\nexport type NoteRow = {\n    summary: SummarizedLogQueues;\n    queue: LogQueue;\n    note: ImportResponse_Note;\n};\n\ntype PathParams = {\n    type: \"json_file\";\n    path: string;\n};\n\ntype JsonParams = {\n    type: \"json_string\";\n    path: string;\n    json: string;\n};\n\nexport type LogParams = PathParams | JsonParams;\n"
  },
  {
    "path": "ts/routes/tmp/_page.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n// this route pulls in code that's currently bundled separately, so that\n// errors in it get caught by svelte-check\nimport * as _editor from \"$lib/../editor\";\nimport * as _reviewer from \"$lib/../reviewer\";\n"
  },
  {
    "path": "ts/src/app.d.ts",
    "content": "import \"@poppanator/sveltekit-svg/dist/svg\";\n\n// See https://kit.svelte.dev/docs/types#app\n// for information about these interfaces\ndeclare global {\n    namespace App {\n        // interface Error {}\n        // interface Locals {}\n        // interface PageData {}\n        // interface PageState {}\n        // interface Platform {}\n    }\n}\n\nexport {};\n"
  },
  {
    "path": "ts/src/app.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<link rel=\"icon\" href=\"%sveltekit.assets%/favicon.ico\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t%sveltekit.head%\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">%sveltekit.body%</div>\n\t</body>\n</html>\n"
  },
  {
    "path": "ts/src/hooks.client.js",
    "content": "/** @type {import('@sveltejs/kit').HandleClientError} */\nexport async function handleError({ error, event, status, message }) {\n    /** @type {any} */\n    const anyError = error;\n    return {\n        message: anyError.message,\n    };\n}\n"
  },
  {
    "path": "ts/svelte.config.js",
    "content": "import adapter from \"@sveltejs/adapter-static\";\nimport { vitePreprocess } from \"@sveltejs/vite-plugin-svelte\";\nimport { dirname, join } from \"path\";\nimport preprocess from \"svelte-preprocess\";\nimport { fileURLToPath } from \"url\";\n\n// This prevents errors being shown when opening VSCode on the root of the\n// project, instead of the ts folder.\nconst tsFolder = dirname(fileURLToPath(import.meta.url));\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n    // preprocess() slows things down by about 10%, but allows us to use :global { ... }\n    preprocess: [vitePreprocess(), preprocess()],\n\n    kit: {\n        adapter: adapter(\n            { pages: \"../out/sveltekit\", fallback: \"index.html\", precompress: false },\n        ),\n        alias: {\n            \"@tslib\": join(tsFolder, \"lib/tslib\"),\n            \"@generated\": join(tsFolder, \"../out/ts/lib/generated\"),\n        },\n        files: {\n            lib: join(tsFolder, \"lib\"),\n            routes: join(tsFolder, \"routes\"),\n        },\n        // outside of out/; as things break when out/ is a symlink\n        outDir: join(tsFolder, \".svelte-kit\"),\n        output: { preloadStrategy: \"preload-mjs\" },\n        prerender: {\n            crawl: false,\n            entries: [],\n        },\n        paths: {},\n    },\n};\n\nexport default config;\n"
  },
  {
    "path": "ts/tools/markpure.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\nfunction allFilesInDir(directory): string[] {\n    let results: string[] = [];\n    const list = fs.readdirSync(directory);\n\n    list.forEach(function(file) {\n        file = path.join(directory, file);\n        const stat = fs.statSync(file);\n\n        if (stat && stat.isDirectory()) {\n            results = results.concat(allFilesInDir(file));\n        } else {\n            results.push(file);\n        }\n    });\n\n    return results;\n}\n\nfunction adjustFiles() {\n    const root = process.argv[2];\n    const typeRe = /(make(Enum|MessageType))\\(\\n\\s+\".*\",/g;\n\n    const jsFiles = allFilesInDir(root).filter(f => f.endsWith(\".js\"));\n    for (const file of jsFiles) {\n        const contents = fs.readFileSync(file, \"utf8\");\n\n        // strip out typeName info, which appears to only be required for\n        // certain JSON functionality (though this only saves a few hundred\n        // bytes)\n        const newContents = contents.replace(typeRe, \"$1(\\\"\\\",\");\n\n        if (contents != newContents) {\n            fs.writeFileSync(file, newContents, \"utf8\");\n        }\n    }\n}\n\nadjustFiles();\n"
  },
  {
    "path": "ts/tools/sql_format.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport sqlFormatter from \"@sqltools/formatter\";\nimport { createPatch } from \"diff\";\nimport { readFileSync, writeFileSync } from \"fs\";\nimport { argv } from \"process\";\n\nfunction formatText(text: string): string {\n    let newText: string = sqlFormatter.format(text, {\n        indent: \"  \",\n        reservedWordCase: \"upper\",\n    });\n    // downcase some keywords that Anki uses in tables/columns\n    for (const keyword of [\"type\", \"fields\"]) {\n        newText = newText.replace(\n            new RegExp(`\\\\b${keyword.toUpperCase()}\\\\b`, \"g\"),\n            keyword,\n        );\n    }\n    return newText;\n}\n\nconst [_tsx, _script, mode, ...files] = argv;\nconst wantFix = mode == \"fix\";\nlet errorFound = false;\nfor (const path of files) {\n    const orig = readFileSync(path).toString();\n    const formatted = formatText(orig);\n    if (orig !== formatted) {\n        if (wantFix) {\n            writeFileSync(path, formatted);\n            console.log(`Fixed ${path}`);\n        } else {\n            if (!errorFound) {\n                errorFound = true;\n                console.log(\"SQL formatting issues found:\");\n            }\n            console.log(createPatch(path, orig, formatted));\n        }\n    }\n}\nif (errorFound) {\n    process.exit(1);\n}\n"
  },
  {
    "path": "ts/transform_ts.mjs",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\n\nimport { buildSync } from \"esbuild\";\nimport { argv } from \"process\";\n\nconst [_node, _script, entrypoint, js_out] = argv;\n\n// support Qt 5.14\nconst target = [\"es6\", \"chrome77\"];\n\nbuildSync({\n    bundle: false,\n    entryPoints: [entrypoint],\n    outfile: js_out,\n    minify: true,\n    preserveSymlinks: true,\n    target,\n});\n"
  },
  {
    "path": "ts/tsconfig.json",
    "content": "{\n    \"extends\": [\"./.svelte-kit/tsconfig.json\"],\n    \"compilerOptions\": {\n        \"allowJs\": true,\n        \"checkJs\": true,\n        \"esModuleInterop\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"resolveJsonModule\": true,\n        \"skipLibCheck\": true,\n        \"sourceMap\": true,\n        \"strict\": true,\n        \"noImplicitAny\": false\n    }\n}\n"
  },
  {
    "path": "ts/tsconfig_legacy.json",
    "content": "{\n    \"include\": [],\n    \"exclude\": [],\n    \"references\": [\n        { \"path\": \"components\" },\n        { \"path\": \"congrats\" },\n        { \"path\": \"deck-options\" },\n        { \"path\": \"editable\" },\n        { \"path\": \"editor\" },\n        { \"path\": \"graphs\" },\n        { \"path\": \"html-filter\" },\n        { \"path\": \"reviewer\" },\n        { \"path\": \"lib\" },\n        { \"path\": \"mathjax\" },\n        { \"path\": \"domlib\" },\n        { \"path\": \"sveltelib\" },\n        { \"path\": \"icons\" }\n    ],\n    \"compilerOptions\": {\n        \"declaration\": true,\n        \"isolatedModules\": true,\n        \"composite\": false,\n        \"target\": \"es2020\",\n        \"module\": \"es2020\",\n        \"lib\": [\n            \"es2017\",\n            \"es2018\",\n            \"es2019\",\n            \"es2020\",\n            \"dom\",\n            \"dom.iterable\"\n        ],\n        \"outDir\": \"../out\",\n        \"rootDir\": \"..\",\n        \"rootDirs\": [\n            \"..\",\n            \"../out\"\n        ],\n        \"baseUrl\": \".\",\n        \"paths\": {\n            \"@generated/*\": [\"../out/ts/lib/generated/*\"],\n            \"@tslib/*\": [\"lib/tslib/*\"],\n            \"$lib/*\": [\"lib/*\"]\n        },\n        \"types\": [],\n        \"verbatimModuleSyntax\": true,\n        \"strict\": true,\n        \"noImplicitAny\": false,\n        \"strictNullChecks\": true,\n        \"strictFunctionTypes\": true,\n        \"strictBindCallApply\": true,\n        \"strictPropertyInitialization\": true,\n        \"noImplicitThis\": true,\n        \"alwaysStrict\": true,\n        \"moduleResolution\": \"node\",\n        \"allowSyntheticDefaultImports\": true,\n        \"esModuleInterop\": true,\n        \"jsx\": \"react\",\n        \"noEmitHelpers\": true,\n        \"importHelpers\": true\n    }\n}\n"
  },
  {
    "path": "ts/vite.config.ts",
    "content": "// Copyright: Ankitects Pty Ltd and contributors\n// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html\nimport svg from \"@poppanator/sveltekit-svg\";\nimport { sveltekit } from \"@sveltejs/kit/vite\";\nimport { realpathSync } from \"fs\";\nimport { defineConfig as defineViteConfig, mergeConfig } from \"vite\";\nimport { defineConfig as defineVitestConfig } from \"vitest/config\";\n\nconst configure = (proxy: any, _options: any) => {\n    proxy.on(\"error\", (err: any) => {\n        console.log(\"proxy error\", err);\n    });\n    proxy.on(\"proxyReq\", (proxyReq: any, req: any) => {\n        console.log(\"Sending Request to the Target:\", req.method, req.url);\n    });\n    proxy.on(\"proxyRes\", (proxyRes: any, req: any) => {\n        console.log(\"Received Response from the Target:\", proxyRes.statusCode, req.url);\n    });\n};\n\nconst viteConfig = defineViteConfig({\n    plugins: [sveltekit(), svg({})],\n    build: {\n        reportCompressedSize: false,\n        // defaults use chrome87, but we need 77 for qt 5.14\n        target: [\"es2020\", \"edge88\", \"firefox78\", \"chrome77\", \"safari14\"],\n    },\n    server: {\n        host: \"127.0.0.1\",\n        fs: {\n            // Allow serving files project root and out dir\n            allow: [\n                // realpathSync(\"..\"),\n                // \"/home/dae/Local/build/anki/node_modules\",\n                realpathSync(\"../out\"),\n                // realpathSync(\"../out/node_modules\"),\n            ],\n        },\n        proxy: {\n            \"/_anki\": {\n                target: \"http://127.0.0.1:40000\",\n                changeOrigin: true,\n                autoRewrite: true,\n                configure,\n            },\n        },\n    },\n});\n\nconst vitestConfig = defineVitestConfig({\n    test: {\n        include: [\"**/*.{test,spec}.{js,ts}\"],\n        cache: {\n            // prevent vitest from creating ts/node_modules/.vitest\n            dir: \"../node_modules/.vitest\",\n        },\n    },\n});\n\nexport default mergeConfig(viteConfig, vitestConfig);\n"
  },
  {
    "path": "yarn",
    "content": "#!/bin/bash\n# Execute subcommand (eg 'yarn <cmd> ...')\n\nset -e\n\nexport PATH=\"./out/extracted/node/bin:$PATH\"\n./out/extracted/node/bin/yarn $*\n./node_modules/.bin/license-checker-rseidelsohn --production --json \\\n    --excludePackages anki --relativeLicensePath \\\n    --relativeModulePath > ts/licenses.json\n"
  },
  {
    "path": "yarn.bat",
    "content": "call .\\out\\extracted\\node\\yarn %*\n"
  }
]